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. Four providers are supported โ Anthropic (Claude), OpenAI (GPT), OpenRouter, and Azure OpenAI using a deployment in your own Azure subscription โ 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).
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 four 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
|
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 and Azure share one implementation (aiProviderCallOpenAICompatible()) because all three 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).
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.
-
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
|
<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
|
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>')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.