AI Providers - edmozley/freeitsm GitHub Wiki
FreeITSM's AI features share a single, provider-agnostic building block. Any module can offer a provider / model / key settings panel and call out to an LLM without re-implementing the plumbing. Five providers are supported โ Anthropic (Claude), OpenAI (GPT), OpenRouter, Azure OpenAI using a deployment in your own Azure subscription, and any OpenAI-compatible server you choose (Ollama, LM Studio, vLLM, LocalAI - see Your own model server) โ and each AI feature is configured independently with its own key.
This page documents the shared layer added in June 2026. For the per-module behaviour see the individual module pages (Knowledge, CMDB, Workflows, Forms, Tickets, Contracts).
Embeddings (turning text into a vector, for search by meaning) are a separate building block:
includes/ai_embeddings.php. See Embeddings - Developer Guide.
Five pieces, of which a module only ever touches the first:
| File | Responsibility |
|---|---|
includes/ai_settings_panel.php |
renderAiSettingsPanel('<ns>') โ renders the provider/model/key panel. One line per settings page. |
assets/js/ai-settings.js |
Drives every [data-ai-panel] on the page โ provider switching, the model dropdown, save/test. |
includes/ai_settings.php |
Namespace registry + load/save/forUi. Reads config from system_settings, decrypts keys. |
includes/ai_provider.php |
Provider-agnostic aiProviderChat() โ the only code that knows each vendor's wire format. Plus the OpenRouter model catalogue. |
api/system/ai/{get_settings,save_settings,test_connection,openrouter_models}.php |
Shared endpoints behind the panel. No per-module logic. |
The split matters: includes/ai_provider.php is storage-agnostic (it takes a plain config array and never reads settings itself), so it can be reused anywhere, while includes/ai_settings.php is the part that knows about system_settings.
aiProviderChat(array $cfg, array $opts) normalises all five into one return shape (content, tokens_in, tokens_out, provider, model, duration_ms).
| Provider | Endpoint | Wire format |
|---|---|---|
anthropic |
https://api.anthropic.com/v1/messages |
Anthropic Messages API (x-api-key, anthropic-version: 2023-06-01) |
openai |
https://api.openai.com/v1/chat/completions |
OpenAI chat-completions (Authorization: Bearer) |
openrouter |
https://openrouter.ai/api/v1/chat/completions |
OpenAI-compatible โ same code path as openai
|
openai_compatible |
{base URL}/chat/completions - the administrator's own address |
OpenAI chat-completions; Authorization: Bearer only when a key is saved
|
azure |
{endpoint}/openai/deployments/{deployment}/chat/completions?api-version=โฆ - or, with API version v1, {endpoint}/openai/v1/chat/completions
|
OpenAI-compatible, but api-key: instead of Authorization: Bearer, and no model field (on v1, the deployment IS sent as model) |
OpenAI, OpenRouter, Azure and custom endpoints share one implementation (aiProviderCallOpenAICompatible()) because all four speak the OpenAI wire format. It takes a full URL and an auth-header list rather than a base URL and a key, which is what lets Azure reuse it: Azure differs in exactly those two places. All outbound POSTs go through aiProviderHttpPost(), which retries on 429 / 5xx / network errors with exponential backoff (3 attempts, 2s base).
The providers are an enum, AiProvider (includes/ai_provider.php). Its value is what <ns>_provider stores in system_settings, so a case's value must never change.
enum AiProvider: string
{
case Anthropic = 'anthropic';
case OpenAI = 'openai';
case OpenRouter = 'openrouter';
case Azure = 'azure';
case OpenAICompatible = 'openai_compatible';
}Both call paths ask the case rather than branching on its name: AiProvider::fromConfig($cfg) (an unknown provider throws, so a typo in a setting is never sent anywhere), then needsApiKey(), and for the four OpenAI-format providers chatUrl($cfg), authHeaders($key) and extraHeaders($opts) - so aiProviderChat() and aiProviderChatTools() each have one OpenAI-format branch, not three. label() gives the name shown to an administrator (Debug tool D014 uses it). Saving a feature's settings validates with AiProvider::tryFrom().
To add an OpenAI-format provider:
- Add the case.
matchmakes you answerlabel(),chatUrl()andauthHeaders()for it; add it toextraHeaders()if it wants headers of its own, and toneedsApiKey()if its key is optional. - The settings panel still lists providers itself: add an
<option>inincludes/ai_settings_panel.php(with itscommon.ai.provider_*translation), and its default model and any fields of its own inassets/js/ai-settings.js. - Point
tests/ai-provider-enum.phpat the local stand-in for it, as it does for OpenAI and OpenRouter.
A provider with a different wire format (as Anthropic's is) needs its own call function and branch beside aiProviderCallAnthropic(), and a tool-calling branch in aiProviderChatTools().
The older Forms, Workflow and RFP Builder AI paths keep their own two-provider lists (anthropic, openai) - separate code that does not go through AiProvider.
Both OpenAI-format paths - the single-turn aiProviderCallOpenAICompatible() and tool calling in aiProviderChatTools() - send through aiOpenAICompatPost(), which puts right the two parameters newer models (the o-series, gpt-5 and after) refuse:
| Refusal | What FreeITSM does |
|---|---|
'max_tokens' is not supported โฆ Use 'max_completion_tokens' |
renames the field |
Unsupported value: 'temperature' does not support 0.0 โฆ |
leaves temperature out, which means "the model's default" - the only value these models allow |
Each fix fires at most once per call, and only when the provider's own words name it, so it cannot loop and cannot swallow an unrelated 400 (a bad key, a content filter). A model that refuses both takes three requests. $payload is passed by reference so tool calling learns the fix once rather than every round. The temperature fix arrived with GH #171 (gpt-5-nano on Azure); before it, such a model failed every call on any provider.
Added for discussion #86, for organisations whose governance will not let prompts leave their own Azure tenant.
-
A deployment, not a model. On a deployment-based endpoint the deployment already decides the model, so
aiAzureChatUrl()puts it in the path and nomodelis sent in the body โ some api-versions reject one that does not match. The panel hides the Model box entirely when Azure is selected. -
The URL cannot be a base URL.
api-versionis a required query string, which is why Azure gets its own URL builder rather than another entry in the base-URL ternary. -
The endpoint is normalised. Administrators paste it from the portal with or without a trailing slash, and sometimes with
/openaialready on the end. All four spellings resolve to the same URL. Since #171 the deployment's whole Target URI (โฆ/openai/deployments/x/chat/completions?api-version=โฆ) and a Foundry project address (โฆ/api/projects/<name>) are cut back to the resource too - everything after it is FreeITSM's to build. -
โ ๏ธ The API version is not the model version. The portal shows a date beside every model (gpt-5-nano's is2025-08-07). It has exactly the shape of an API version, Azure answers it with a bare404 Resource not found, and nothing on the screen says which box was wrong - that was #171.aiAzureApiVersionLooksValid()recognises the published shapes (YYYY-MM-DD-preview, the GA dates,v1) and is used only to explain a failure, never to refuse a value, because Azure publishes new versions and the list will fall behind. -
v1. Enteringv1as the API version uses Azure's newer route,{endpoint}/openai/v1/chat/completions: no query string, and the deployment travels asmodel(aiAzureIsV1()). -
max_tokensโmax_completion_tokens, and temperature - see Parameters newer models refuse by name above.
๐ด Never tested against a real Azure tenant. This was written without an Azure subscription.
tests/azure-openai/covers everything FreeITSM sends โ the URL, theapi-keyheader, the absent model field, the retry, and controls proving the OpenAI path is unchanged โ against a local stand-in (tests/azure-openai/mock.php). It cannot cover what Azure does at its end: content filtering, api-version drift, quota or regional behaviour. Debug tool D014 exists for exactly that gap - see below.
Asked in GH #171: "Is there a way to inspect the payload and response without the use of dev tools?" Not in the browser - the call is made by the server, and the browser only ever talks to FreeITSM, which is why dev tools show no API key header. So the server records its own exchange:
-
aiProviderTraceStart()/aiProviderTraceTake()switch on a recording insideaiProviderHttpPost(): URL, headers, body, status, raw answer, cURL error and time, per attempt. Off unless started, so ordinary calls pay nothing. -
aiExplainFailure($cfg, $message)turns a failure into one sentence an administrator can act on (key from another resource, missing deployment, an API version that is really a model version, content filter, rate limit, network). The Test button and D014 both use it, so they never disagree. -
The Test button (
api/system/ai/test_connection.php) returns that sentence ashintand, for an administrator (debug tools are admin-only), adebug_urltosystem/debug-tools/d014/?ns=<feature>.ai-settings.jsshows both under the error. -
Debug tool D014 - AI connections (
api/system/debug-tools/D014_ai_connections.php) - every provider, not just Azure (the id is kept so links given out in #86 still work). It lists every feature and its provider, then makes the Test button's call once per distinct setup (features sharing a provider, model and key are called once) and prints every request and answer in full, the key replaced by its first four characters and length wherever it appears. For an Azure 404 or 400 it retries with the default API version,2024-10-21,2025-04-01-previewandv1, stopping at the first that works, and names it as the fix. - The tool page fills an input from the address (
?ns=โฆ), so the signpost lands on the right feature. That is generic intool-page.php- any tool's input can be prefilled the same way.
Asked for in GH #192: organisations running FreeITSM on premises want ticket text, articles and drafts processed by a model inside their own network. The provider client could already send the OpenAI format anywhere; this exposes it as a fifth provider, OpenAI-compatible (custom endpoint) (openai_compatible), in the same shared panel - so every feature on the shared layer can use it.
| Field | Example | Notes |
|---|---|---|
| Base URL |
http://llm-host:11434/v1 (Ollama) |
FreeITSM appends /chat/completions, as the OpenAI clients do. A pasted full address or a trailing slash is normalised (aiCompatibleChatUrl()). Must be http:// or https:// with a host - checked when saving (aiCompatibleBaseUrlValid()) |
| Model | llama3.1:8b |
Typed, never picked from a cloud catalogue - the server decides what exists |
| API key | (blank) |
Optional. Blank sends no Authorization header at all, so nobody has to invent a dummy credential. A key that is entered is encrypted and masked like every other; Remove the saved key clears it |
- ๐ด No fallback, ever. A custom endpoint that fails reports the failure. There is no code path from
openai_compatibleto any other address -tests/ai-compatible/run.phpreads every URL each call reached from the provider trace and asserts it was the stand-in, for chat, tool calling and every failure. -
Chat and tool calling both go to the custom address (
aiProviderChat()andaiProviderChatTools()). Before this, abase_urloverride existed only as an undocumented$cfgkey, andaiProviderChatTools()would have taken it too - but nothing set it. - The Test button checks tool calling too, for this provider only. A one-word answer proves the address, key and model; it does not prove the model can call tools, which the war room bot and the Projects assistant need and many small local models cannot. The result line says supported, did not call the tool or not supported (with the server's own words).
-
Failures are explained by
aiExplainCompatibleFailure(), called first fromaiExplainFailure(): nothing listening (naminglocalhostas the FreeITSM server - in Docker, the container - andhost.docker.internal, orOLLAMA_HOST=0.0.0.0for a remote host), a timeout, a model without tool support, a 401/403, a 404 that names the model (ollama pull) versus one that does not (the base URL, usually missing/v1), and an answer that is not a chat completion. -
An answer that is not a chat completion is an error (
AiResponseFormatError), not an empty reply. A base URL pointing at a gateway's status page answers 200 with JSON; reading that as "the model said nothing" would send the administrator to the wrong place. This applies to every OpenAI-format provider, which always sendchoices. -
HTTP(S) only.
aiProviderHttpPost()setsCURLOPT_PROTOCOLSto HTTP and HTTPS now that an administrator can name the address, and redirects are not followed. -
TLS is the install's own:
SSL_VERIFY_PEERandSSL_CA_BUNDLEinconfig.php. An internal certificate authority is added to the bundle; verification never has to be switched off. Plainhttp://on an internal network is allowed because it was deliberately typed. - Extended thinking (System โ AI thinking) has no effect, as with Anthropic and OpenAI - the parameter is OpenRouter's own.
- Streaming: the features that stream on Anthropic (Forms AI Assist, Reply Cleanup, the Knowledge assistant) send a custom endpoint's answer as one event, as they do for OpenAI and OpenRouter.
aiSettingsSave() keeps a saved key when the key box is left blank - which is right between Anthropic and OpenAI, and would be wrong here. Switching a feature from OpenAI to a custom endpoint without typing a key would send the OpenAI key, as a Bearer token, to whatever server the base URL names; switching back would send a local server's key to OpenAI. So when the provider changes to or from openai_compatible and no key is typed, the saved key is dropped. Between the other providers nothing changed.
-
Knowledge's search by meaning creates embeddings with OpenAI (
text-embedding-3-small) using the separate key on Knowledge โ Settings โ Embeddings. With that key empty, Knowledge searches by words and sends nothing to OpenAI. The embedding model is deliberately not a setting: stored vectors only compare within one model. - The RFP Builder (Contracts) keeps its own settings, as below.
Every feature used to test ($cfg['api_key'] ?? '') === '' for not configured. With an optional key that is wrong: a keyless Ollama would have been "not configured" in every feature while the Test button said it worked. All 21 checks now call aiConfigured($cfg) (includes/ai_provider.php) - an address and a model for a custom endpoint, a key for the rest. Use it in any new feature.
AI buttons only when the feature's AI is set up (5.1.0, #309)
A button that only says "no API key" after the click is clutter on an install that does not use AI, so these are shown only when their feature is set up - aiFeatureReady($conn, '<namespace>') in includes/ai_settings.php (which calls aiConfigured(), so a keyless custom endpoint counts):
| Button | Namespace |
|---|---|
| Knowledge header Ask AI | knowledge_ai |
Ticket toolbar Ask AI (window.TICKET_ASK_AI, set by tickets/index.php) |
knowledge_ai |
Projects AI assistant ($prjAskAi in projects/view.php) |
projects_ai |
| CMDB settings Suggest with AI | cmdb_ai |
A new AI button should follow the same rule.
-
One key, hundreds of models. OpenRouter is an aggregator โ a single key reaches Claude, GPT, Gemini, Llama, Mistral, DeepSeek and more. Models are addressed by namespaced id, e.g.
anthropic/claude-3.5-sonnet,google/gemini-2.0-flash. -
Live model catalogue.
aiProviderListOpenRouterModels()fetcheshttps://openrouter.ai/api/v1/models(no key required โ public) and caches it insystem_settings(openrouter_models_cache,openrouter_models_cached_at) for 24h. On a failed refresh it falls back to the stale cache rather than returning an empty list. The panel renders this as a searchable dropdown showing each model's id and per-million-token pricing (in / out). -
Attribution headers. OpenRouter calls send
HTTP-Referer: https://freeitsm.co.ukandX-Title: FreeITSMso usage is attributed on the OpenRouter dashboard. - Per-key spend limits. OpenRouter supports multiple keys with per-key spend limits, so per-module granular billing is preserved under a single OpenRouter account.
Each AI feature registers a namespace in aiSettingsRegistry() (includes/ai_settings.php) with a default provider and model:
| Namespace | Feature | Settings location | Default model |
|---|---|---|---|
knowledge_ai |
Knowledge โ Ask AI | Knowledge โ Settings โ AI | claude-haiku-4-5-20251001 |
cmdb_ai |
CMDB โ summaries + Suggest Properties | CMDB โ Settings โ AI Integration | claude-haiku-4-5-20251001 |
workflow_ai |
Workflows โ AI co-author | Workflows โ Settings โ AI | claude-sonnet-4-6 |
forms_ai |
Forms โ AI Assist | Forms โ Settings โ AI | claude-sonnet-4-6 |
tickets_reply_cleanup |
Tickets โ Reply Cleanup | Tickets โ Settings โ Reply Cleanup | claude-haiku-4-5-20251001 |
problem_ai |
Problem Management โ AI analysis | Problems โ Settings โ AI | claude-sonnet-4-6 |
lms_ai |
LMS โ course authoring helpers | Learning โ Settings โ AI | claude-sonnet-4-6 |
knowledge_writeup |
Knowledge Assistant โ gap judgement + drafting | Knowledge โ Settings โ AI | claude-sonnet-4-6 |
RFP Builder (Contracts) is deliberately deferred from the shared panel โ its long streaming generations need a dedicated OpenRouter SSE path first, so it keeps its existing Anthropic streaming for now.
The registry is also a security boundary โ see below.
Config for a namespace <ns> lives in a handful of system_settings rows:
| Key | Contents |
|---|---|
<ns>_provider |
anthropic | openai | openrouter | azure | openai_compatible
|
<ns>_model |
Model id (unused for azure โ the deployment stands in for it) |
<ns>_api_key |
Encrypted at rest (AES-256-GCM). Must be listed in ENCRYPTED_SETTING_KEYS + MASKED_SETTING_KEYS in includes/encryption.php. |
<ns>_verify_ssl |
'1' | '0' (absent โ falls back to the global SSL_VERIFY_PEER) |
<ns>_azure_endpoint |
Azure only โ the resource address from the portal |
<ns>_azure_deployment |
Azure only โ the deployment name, which stands in for the model |
<ns>_azure_api_version |
Azure only โ blank falls back to 2024-02-01
|
<ns>_compatible_base_url |
Custom endpoint only (#192) โ the server's base URL. Not a secret, returned in the clear. base_url: aiProviderChat() has long honoured a base_url override for openai/openrouter, so a saved custom address arriving under that name would quietly redirect an OpenAI-configured feature to it |
The three Azure rows are not encrypted: a hostname, a deployment name and a date-shaped version string are not secrets, and aiSettingsForUi() returns them in the clear on purpose โ an endpoint you cannot read back is an endpoint whose typo you cannot find. They are also written whenever the client sends them, whatever the provider is, so switching a namespace to OpenAI and back does not quietly empty an endpoint somebody spent time getting right.
Security properties:
-
The registry is an allowlist. The generic
api/system/ai/*endpoints only ever read or write keys derived from a registered namespace, so they can't be abused to read or overwrite arbitrarysystem_settings(e.g. other secrets). An unregistered namespace is rejected. -
Keys never leave the server.
aiSettingsForUi()returns only a mask (****<last4>) and ahas_keyflag โ the plaintext key is never sent to the browser. It exists in plaintext only for the moment a request is made to the provider. -
Masked / no-change saves.
aiSettingsSave()treats a masked or empty key submission as "leave unchanged", so re-saving provider + model never wipes the stored key. You only overwrite it by typing a new one. -
SSL verify dial. The effective verify flag is the global
SSL_VERIFY_PEERconstant ANDed with the per-namespace toggle. On a dev box where the global is off (no CA bundle), outbound HTTPS skips verification regardless; in production set the global on and use the toggle to opt out per feature.
No schema change was required โ everything lives in system_settings.
Two features stream their output live (claude.ai-style SSE). The rollout deliberately left the proven streaming engine (includes/rfp_ai.php) untouched:
-
Provider =
anthropicโ existing native streaming path (live tokens), unchanged. -
Provider =
openai/openrouterโ one-shotaiProviderChat()emitted as a single SSE event (no token-by-token streaming for these providers yet).
This applies to Forms AI Assist and Tickets Reply Cleanup.
A model that reasons before it answers spends completion_tokens on a hidden
reasoning field before it writes a single character of content. Several
of the models OpenRouter serves work this way, and the effect on a caller with a
modest max_tokens is:
HTTP 200
usage: { completion_tokens: 700, reasoning_tokens: 700 }
choices[0].message.content: ""
choices[0].finish_reason: "length"
A success, a full bill, and an empty answer. Measured on qwen/qwen3.7-plus: a
one-sentence question about the colour of the sky spent 375 reasoning tokens
of 383.
For a long time aiProviderCallOpenAICompatible() read only
message.content, so every AI feature reported this as its own generic
failure โ which sent administrators to check an API key that was never the
problem. Two things now guard against it:
-
aiProviderChat()also returnsfinish_reasonandreasoning_tokens(purely additive โ a caller that ignores them behaves exactly as before).finish_reasonoflength/max_tokenswith empty content is a nameable case: the budget ran out before the answer started. - Budgets are set for a model that thinks first, not for the length of the answer asked for. The ticket summary asks for ~180 words and allows 6000 tokens. A model that does not reason out loud stops at its own natural end, so the higher ceiling costs it nothing.
A truncated answer is worse than an empty one, because it reads almost like a
complete one and the section it loses is the last. Where an answer is stored
(ticket_ai_summaries.truncated), whether it was cut off is stored with it and
said out loud on screen โ never presented as the whole of an answer.
One switch per AI feature, at System โ AI thinking. The rows come from
aiSettingsRegistry(), so a tenth feature appears there the moment it is
registered โ and includes/settings_keys.php generates <ns>_reasoning from the
same registry, so it is writable without a second edit.
Measured on one install, one feature summarising a two-message ticket on
qwen/qwen3.7-plus:
| Time | Thinking tokens | Answer | |
|---|---|---|---|
| Thinking on | 54.7s | 2,927 | 1,020 chars |
| Thinking off | 6.9s | 0 | 1,376 chars |
Eight times faster and the better answer โ the fast one caught details the slow one missed. That is not a quirk of one prompt: every AI feature in FreeITSM summarises, extracts or drafts from text already in front of it. None of them is a puzzle, which is what extended thinking is for.
Note the ticket size. Two messages, 324 characters โ none of that minute was the input. A reasoning model deliberates about the same amount whatever you give it, so a tiny ticket is just as slow as a huge one.
reasoning is OpenRouter's own parameter and OpenAI's /chat/completions
rejects an unknown field outright โ so sending it to both would break every
OpenAI-configured feature. aiProviderCallOpenAICompatible() tests the base URL,
not the provider name, because the URL is what decides where the request lands.
It is also only ever sent to turn thinking off. Leaving it out means "whatever the model does by default", which is what an administrator who wants thinking has asked for.
A feature on Anthropic or OpenAI is still listed, still saves its setting, and is marked no effect โ "why is this one not here?" is a worse question than "why is this one greyed out?".
It arrives on $cfg from aiSettingsLoad() with everything else, and
aiProviderChat() reads it from there. No caller has to know about it, which
is how all nine features got it at once. An explicit $opts['reasoning'] still
wins, for a caller with a reason.
- Add a namespace to
aiSettingsRegistry()inincludes/ai_settings.php(label + default provider + default model). - Add
<ns>_api_keytoENCRYPTED_SETTING_KEYSandMASKED_SETTING_KEYSinincludes/encryption.php. - Drop
<?php renderAiSettingsPanel('<ns>'); ?>on the module's settings page (ensureassets/js/ai-settings.js, thecommoni18n namespace, andshowToast()are available). - In the backend, load config with
aiSettingsLoad($conn, '<ns>'), refuse with!aiConfigured($cfg)(never by testing the key - a custom endpoint may have none), and pass it toaiProviderChat(). - Set
max_tokensfor a model that reasons before answering, and treat empty content withfinish_reason: lengthas its own case โ see the section above.
That's the whole adoption cost โ no per-module provider plumbing.