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

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

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.

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
<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 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>') 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** โš ๏ธ