AI Providers - edmozley/freeitsm GitHub Wiki

AI Providers

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.

The building block

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.

Providers

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).

Adding a provider (4.0)

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:

  1. Add the case. match makes you answer label(), chatUrl() and authHeaders() for it; add it to extraHeaders() if it wants headers of its own, and to needsApiKey() if its key is optional.
  2. The settings panel still lists providers itself: add an <option> in includes/ai_settings_panel.php (with its common.ai.provider_* translation), and its default model and any fields of its own in assets/js/ai-settings.js.
  3. Point tests/ai-provider-enum.php at 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.

Parameters newer models refuse by name

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.

Azure OpenAI specifics

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 no model is 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-version is 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 /openai already 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 is 2025-08-07). It has exactly the shape of an API version, Azure answers it with a bare 404 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. Entering v1 as the API version uses Azure's newer route, {endpoint}/openai/v1/chat/completions: no query string, and the deployment travels as model (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, the api-key header, 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.

Diagnosing a failing connection (D014)

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 inside aiProviderHttpPost(): 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 as hint and, for an administrator (debug tools are admin-only), a debug_url to system/debug-tools/d014/?ns=<feature>. ai-settings.js shows 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-preview and v1, 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 in tool-page.php - any tool's input can be prefilled the same way.

Your own model server (OpenAI-compatible endpoint)

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.

What the administrator enters

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

Behaviour

  • ๐Ÿ”ด No fallback, ever. A custom endpoint that fails reports the failure. There is no code path from openai_compatible to any other address - tests/ai-compatible/run.php reads 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() and aiProviderChatTools()). Before this, a base_url override existed only as an undocumented $cfg key, and aiProviderChatTools() 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 from aiExplainFailure(): nothing listening (naming localhost as the FreeITSM server - in Docker, the container - and host.docker.internal, or OLLAMA_HOST=0.0.0.0 for 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 send choices.
  • HTTP(S) only. aiProviderHttpPost() sets CURLOPT_PROTOCOLS to HTTP and HTTPS now that an administrator can name the address, and redirects are not followed.
  • TLS is the install's own: SSL_VERIFY_PEER and SSL_CA_BUNDLE in config.php. An internal certificate authority is added to the bundle; verification never has to be switched off. Plain http:// 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.

๐Ÿ”ด The key does not follow the feature across the custom boundary

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.

What does not use it

  • 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.

TRAP: "is AI set up?" is aiConfigured(), not "is there a key?"

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.

OpenRouter specifics

  • 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() fetches https://openrouter.ai/api/v1/models (no key required โ€” public) and caches it in system_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.uk and X-Title: FreeITSM so 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.

Namespace registry

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.

Storage & security

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. โš ๏ธ Deliberately not named 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 arbitrary system_settings (e.g. other secrets). An unregistered namespace is rejected.
  • Keys never leave the server. aiSettingsForUi() returns only a mask (****<last4>) and a has_key flag โ€” 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_PEER constant 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.

Streaming

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-shot aiProviderChat() 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.

โš ๏ธ Reasoning models can return nothing at all

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 returns finish_reason and reasoning_tokens (purely additive โ€” a caller that ignores them behaves exactly as before). finish_reason of length / max_tokens with 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.

System โ†’ AI thinking

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.

๐Ÿ”‘ It defaults to OFF, and the numbers are why

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.

โš ๏ธ OpenRouter only

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?".

How a caller gets it

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.

Adding a new AI feature

  1. Add a namespace to aiSettingsRegistry() in includes/ai_settings.php (label + default provider + default model).
  2. Add <ns>_api_key to ENCRYPTED_SETTING_KEYS and MASKED_SETTING_KEYS in includes/encryption.php.
  3. Drop <?php renderAiSettingsPanel('<ns>'); ?> on the module's settings page (ensure assets/js/ai-settings.js, the common i18n namespace, and showToast() are available).
  4. 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 to aiProviderChat().
  5. Set max_tokens for a model that reasons before answering, and treat empty content with finish_reason: length as its own case โ€” see the section above.

That's the whole adoption cost โ€” no per-module provider plumbing.

Related pages

โš ๏ธ **GitHub.com Fallback** โš ๏ธ