diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cce6dafb..0445e4052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.6) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.8) in the same pass. Nothing else is required. This is a big release, with fifty-seven database steps in one go, so take a backup before you update. @@ -55,6 +55,8 @@ Vendor templates keep everything they hold, and the vendors made from them stay The app logs are kept for as many days as the audit log, as before, and each can now be set on its own in Admin > Settings > Logs, which is where the log retention went from Settings > Security. A retention of 0 now keeps a log forever (see Breaking Changes, Logs). Nothing is sent to a syslog server or SIEM until you set one up there. +AI providers keep their address, key and models. Each is given a kind from where it points - OpenAI, Anthropic, Azure OpenAI, Google Gemini, Mistral, Groq, OpenRouter, Ollama or LM Studio - or Other (OpenAI-compatible), and passwords and keys are taken out of what is sent to each from now on, which can be turned off for a provider in its Edit window. Every role keeps using AI, through a new Use AI permission in Admin > Roles that can now be taken away from a role. A model's prompt is now its instructions, sent after what each feature asks for (see Breaking Changes, AI). AI Usage starts empty. + If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. ### Breaking Changes and Notes @@ -223,6 +225,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The `auth_logs` table is removed. Nothing was ever written to it. - Logs: log retention moved from Admin > Settings > Security to Admin > Settings > Logs, where the audit log (with API key use) and the app logs each keep their own number of days. A retention of 0 now keeps a log forever - it used to delete everything older than today each night. - Audit log: each thing done now has one name, in the entries already there as well as new ones. Unarchive and Restored are Restore, Deleted is Delete, and Closed, Resolved, Reopened, Merged and Sent are Close, Resolve, Reopen, Merge and Send. An agent turned away from a client they can't open is Denied, where it was Access. A saved card or payment provider deleted is Delete and a card saved is Create, where both were Update, and a contract template changed is Edit. A report or script that reads the `logs` table by the old names needs the new ones. +- AI: + - Using AI is a permission of its own, Use AI, in Admin > Roles. Every role has it after the update; a role without it gets no Reword button, no ticket summary and no document template draft. + - The Reword button is only on the editors when a model is set up for it and the role may use AI. It used to be on every editor, and say an error when pressed. + - A model's prompt is now its instructions - your house style, sent after what each feature asks of the model. Reword says what rewording is itself now, so a prompt written to make Reword work still works but can be cut down to the style you want. + - Passwords, PINs, API keys, private keys, 2FA links and card numbers in what is sent are replaced with placeholders, and put back in the answer. A provider can be set to send them as they are, in its Edit window. + - A provider's address is its chat completions address, as before - one ending /v1 now has /chat/completions put on when saved. ### New Features & Updates @@ -1046,6 +1054,18 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Removing a card says first which recurring invoices stop autopaying with it. It is taken off Stripe as well, each of those recurring invoices says in its history that its autopay stopped and why, and when Stripe can't be reached the card is still removed and you are told it is saved there. - What goes through it: each provider shows its card payments in the last 30 days and when the last came, what its fees came to, and how many are still to record - flagged once they are more than two days old, as the nightly run should have had them. Record fees now does what the nightly run does, and says what it recorded, what Stripe hasn't settled yet and what it wouldn't say. Check connection asks Stripe whose account the key is, and says so - or why it isn't connected. - Settings > Invoices and billing says how card payments stand, under Paid online: none set up, turned off, or on - with live or test keys, where payments go, the card limit and how many came in the last 30 days. The Settings overview flags test keys, card payments going into an archived account, and Stripe's fees not being recorded, and the Invoices and billing tile says whether cards are on. +- **AI Providers** (rebuilt) + - Admin > AI Providers lists each provider with its kind and address, whether its key is saved (it is never shown), whether it runs on your own network, whether passwords and keys go to it as they are, its models and what each is for, and its requests and tokens in the last 30 days, with how many failed. Above the list, which model General, Tickets and Documentation each use - their own, or General's. Search finds a provider by a model on it too, and the list sorts by name, kind, models, requests or when it was last used. + - Add and Edit are one window. Pick its kind - OpenAI, Anthropic (Claude), Azure OpenAI, Google Gemini, Mistral, Groq, OpenRouter, Ollama, LM Studio, or any other OpenAI-compatible service - and its address and name are filled in, with a link to where its keys are made. Test asks it for its models: it says it answered and how many there are, or why not - a key it turned away, an address that isn't there or doesn't answer - beside the field. Save checks first the same way, and a provider that doesn't answer right now (one on your network that is off, say) can still be saved, once it has said so. The key is no longer put into the window; left empty it stays, and Remove the saved key takes it off. + - Adding the first provider asks for the model to use, with the ones it has suggested as you type, so one window sets AI up for everything. + - Test on a provider's menu says whether it answers with its key, and names any model set up on it that it doesn't have. Delete says which models go with it and which features stop. + - Admin > AI Models: each model with its provider, what it is for, its instructions and temperature, and its use in the last 30 days. A second model for a use case is marked Not used, as only the first is, and adding one says so. Filter by provider; sort by name, provider, use case or requests. + - A model's window suggests the models its provider has as you type, says what the use case picked has now - that this one will be used for it, or that another is and this one won't be - and Try it sends the model a one-line request as it is in the window, unsaved, and shows what came back and how long it took. Its instructions go with every request it handles: your house style, things to always or never say. + - Admin > AI Usage: every request - when, the feature (Reword, Reword a ticket reply, Ticket summary, Document template draft, Test), who asked, the client it was for, the model and provider, the tokens it took, how long, how many secrets were kept out, and why it failed when it did. Totals for what is shown above the list - requests, tokens in and out, failed, secrets kept out and answer time. Tabs for the failed ones, the ones secrets were kept out of, and tests; filter by date, provider, model, feature and who; search by any of them or the client. Kept as long as the audit log. + - Keeping secrets out: passwords, PINs, API keys (OpenAI, Anthropic, Stripe, AWS, GitHub, GitLab, Slack, Google), JWTs, private keys, 2FA setup links and card numbers in a reply or ticket are taken out before it goes to the provider, as [secret-1] and so on, and put back in the answer - the provider never sees them, and AI Usage counts them. On by default for every provider; it can be turned off for one you run yourself. + - Use AI permission in Admin > Roles, so a role can be kept from AI altogether. + - The Settings overview says when AI requests have failed in the last day and are still failing, with the provider's reason. + - Reword is the first button on the editors where it is there, says when there is nothing to reword yet, keeps your formatting, placeholders, links and names, and says Undo puts your words back - which it does, in one step. It works on the admin pages' editors too. ### Bug Fixes @@ -1207,6 +1227,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Settings > Notifications, and recurring invoices: the note that recurring invoices are made as drafts pointed to the billing settings, but the switch is in Notifications. - Mail Queue: cancelling an email marked it Failed, and an email to a bad address or with no from address showed Failed with no time. - Mail Queue: Resend was only offered once all four tries were used up, and the next failure quietly gave a resent email three more. +- AI: Reword on the admin pages' editors (document templates and the like) always failed - it asked a page that isn't there. +- AI: the AI provider window showed its API key; adding or changing one said "AI Model ... created" or "edited"; and the model window picked the last provider in the list whichever one the model was on. +- AI: with no prompt on its model, Reword sent the text with nothing to say what to do with it. +- AI: a ticket summary and a document template draft went into the page as the model wrote them, so something in a ticket could get a script into the summary. They go through the same clean-up as documents now. - `setup_cli.php`: a name with an apostrophe in it stopped the install part-way, a password with a space at either end couldn't sign in, and it wrote config.php before knowing the tables could be made - over a database that already had tables in it, too. ### Developer Updates @@ -1779,7 +1803,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Mail Queue: new `functions/mail_queue.php` - `MAIL_QUEUE_TRIES` (4), `MAIL_QUEUE_RETRY_MINUTES` (30) and `MAIL_QUEUE_KEEP_DAYS` (90), which `cron/mail_queue.php`'s retry pass and the nightly clean-up now use too; `mailQueueTabs()`, `mailQueueState($row)`, `mailQueueCan($row, $action)`, `mailQueueAct($action, $ids)` and `mailQueueHealth()`. `email_status` 4 is Cancelled - never selected by the worker. Retry now sets status 0, no attempts and no failure time. Handlers: `retry_mail`, `cancel_mail`, `delete_mail` (GET with the CSRF token) and `bulk_retry_emails`, `bulk_cancel_emails`, `bulk_delete_emails`; `send_failed_mail` still works. The worker sets `email_failed_at` on the bad sender, bad recipient and no-MX paths. - Mail Queue: database 3.3.7 adds `email_queue.email_error` varchar(500) - why it last failed, or "Cancelled by ..."; cleared when sent or retried. The worker writes it on every failure path, through `mailQueueErrorSql()`, which leaves it out until the update has run, so an install between updating its files and its database still sends; `mailQueueErrorColumn()` does the same for reads. The retry pass's bad-recipient path now sets `email_failed_at`, so its backoff counts from that try. An alert (`appNotify`) when the retry pass gives up. The email's window shows the queued HTML in an iframe `srcdoc` with `sandbox="allow-popups allow-popups-to-escape-sandbox"` (no scripts, no same origin) and a `script-src 'none'` policy, instead of HTMLPurifier in the page. - Mail Queue: `mailQueueSummary()` - waiting, retrying, failed in the last day and week, and the last sent - read once a request, for the Settings overview (attention items and the Mail tile), Mail's Tests tab and the admin menu's count (`admin/includes/side_nav.php`). `globalSearchPages()` has Mail Queue for admins. -- `js/app.js`: the four `tinymce.init()` calls go through `itflowTinyMce`, which does nothing where TinyMCE isn't loaded. +- AI: database 3.3.8 adds `ai_providers.ai_provider_kind` varchar(50) (guessed from the address for those already there, else `custom`) and `ai_provider_redact` tinyint(1) default 1, widens `ai_provider_api_url` and `ai_provider_api_key` to varchar(500), adds the `ai_usage` table (feature, use case, provider and model ids and names, user, client, tokens in and out, ms, redacted, ok, error, created at) and the `module_ai` module, given level 1 on every non-admin role. `setup/seed_data.php` and `db.sql` match. +- AI: `functions/ai.php` - `aiProviderKinds()` (address, key, how it is sent - bearer or Azure's `api-key` - how its models are listed, local or not, where keys are made, an example model), `aiProviderKind()`, `aiUseCases()`, `aiFeatures()`, `aiCan()` (the `module_ai` permission), `aiReady($use_case)`, `aiClientConfig()`. `getAiModel()` joins the provider (an `INNER JOIN` now), returns its id, kind and redact with the model's id, and is cached per request. `callAiApi($model, $messages, $context)` takes `feature` and `client_id`, sends the key as the kind takes it, takes secrets out with `aiRedact()` and puts them back with `aiRestore()`, puts the model's prompt after the feature's system message as the company's instructions, and records every request with `aiUsageRecord()` (until 3.3.8 has run, `aiUsageTableExists()` says so and nothing is recorded or read from the new columns). `aiCleanHtml()` takes code fences off and runs `documentHtmlPurify()`. Also `aiUsageTotals()`, `aiTokensText()`, `aiTemperatureText()`, `aiModelsUrl()`, `aiProviderTest($kind, $url, $key)` (the models it lists; Anthropic's own `/v1/models` with `x-api-key`), `aiProviderUrl()` (a base address gets `/chat/completions`), `aiProviderRow()`, `aiModelRow()`, `aiProviderCheck()`, `aiProviderConnectionChanged()`, `aiProviderWrite()` and `aiModelWrite()` (raw values, escaped with `mysqli_real_escape_string`, not `escapeSql()`), `aiProviderChangeLines()`, `aiModelCheck()`, `aiModelChangeLines()`, `aiUseCaseMap($without)`, `aiUseCasesStopping()`, `aiProviderIcon()`, `aiUseCaseStripHtml()` and `aiProblems()`. +- AI: handlers - `check_ai_provider` (JSON: `ok`, `errors` by field, `soft` when only not answering, `models`, `listed`, `url` as it will be saved; `test` to ask even when unchanged) before `add_ai_provider` / `edit_ai_provider`, which take `kind`, `name`, `url`, `api_key`, `clear_key`, `redact`, `save_anyway` and, adding the first, `first_model`; `ai_provider_models` (JSON, cached ten minutes); `test_ai_provider` (GET with the CSRF token); `check_ai_model` (JSON, `try` to send a one-line request as feature `test`) before `add_ai_model` / `edit_ai_model`. `admin/modals/ai/ai_provider_add.php` and `ai_model_add.php` are gone - the edit windows add with no id, and `ai_model_edit.php?provider=` picks the provider. New page `admin/ai_usage.php`. +- AI: `agent/ajax.php` - `ai_reword`, `ai_ticket_summary` and `ai_create_document_template` need `module_ai` (a JSON 403 otherwise), pass their feature and client to `callAiApi()`, and answer purified HTML. `ai_reword` takes `client_id` in its JSON and has its own instructions, by use case. The nightly run deletes `ai_usage` rows older than the audit log's retention. +- `js/app.js`: the four `tinymce.init()` calls go through `itflowTinyMce`, which does nothing where TinyMCE isn't loaded. `itflowAiOn(ticket)` and `itflowAiReword(editor, ticket)` add Reword, first in the toolbar, only when `window.itflowAi` (printed by `includes/footer.php` from `aiClientConfig()`) says so, posting to its `endpoint`, `/agent/ajax.php`. A textarea's `data-ai-client-id` goes with the request. ### Library Updates diff --git a/admin/ai_models.php b/admin/ai_models.php index ecea6371e..04d00c96b 100644 --- a/admin/ai_models.php +++ b/admin/ai_models.php @@ -1,131 +1,210 @@ AI Models - the models on the AI providers, each for a use case + * (functions/ai.php aiUseCases()) with General the fallback. Above, which + * model each use case lands on; in the list, a second model for a use case is + * marked as not used, the provider each is on, its instructions and + * temperature, and its use in the last 30 days. Add and Edit are one window + * (modals/ai/ai_model_edit.php), with Try it; the rest are + * admin/post/ai_models.php. + */ + // Default Column Sortby Filter $sort = "ai_model_name"; $order = "ASC"; require_once "includes/inc_all_admin.php"; -$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_provider_id, - ai_provider_name FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id - WHERE ai_model_name LIKE '%$q%' OR ai_provider_name LIKE '%$q%' - ORDER BY $sort $order LIMIT $record_from, $record_to"); +$page_title = 'AI Models'; + +// Only these sort - an unknown column would take the page down +$sortable_columns = [ + 'ai_model_name' => "ai_model_name $order", + 'ai_provider_name' => "ai_provider_name $order, ai_model_name ASC", + 'ai_model_use_case' => "FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation') " . ($order === 'ASC' ? 'ASC' : 'DESC') . ", ai_model_id ASC", + 'requests' => "requests $order, ai_model_name ASC", +]; +if (!isset($sortable_columns[$sort])) { + $sort = 'ai_model_name'; +} + +$provider_filter = intval($_GET['provider'] ?? 0); + +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature, + ai_provider_id, ai_provider_name, ai_provider_kind, + COALESCE(used.requests, 0) AS requests, COALESCE(used.failed, 0) AS failed, COALESCE(used.tokens_in, 0) AS tokens_in, COALESCE(used.tokens_out, 0) AS tokens_out, used.last_used + FROM ai_models + JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id + LEFT JOIN ( + SELECT ai_usage_model_id, + SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY) AS requests, + SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY AND ai_usage_ok = 0) AS failed, + SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_in, 0)) AS tokens_in, + SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_out, 0)) AS tokens_out, + MAX(ai_usage_created_at) AS last_used + FROM ai_usage WHERE ai_usage_feature <> 'test' GROUP BY ai_usage_model_id + ) used ON used.ai_usage_model_id = ai_model_id + WHERE (ai_model_name LIKE '%$q%' OR ai_provider_name LIKE '%$q%' OR ai_model_use_case LIKE '%$q%' OR ai_model_prompt LIKE '%$q%') + " . ($provider_filter ? "AND ai_provider_id = $provider_filter" : '') . " + ORDER BY {$sortable_columns[$sort]}, ai_model_id ASC + LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$unused = aiUseCaseMap()['unused']; +$providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name, ai_provider_kind FROM ai_providers ORDER BY ai_provider_name"); +$any_provider = mysqli_num_rows($providers) > 0; + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; + +$add_url = 'modals/ai/ai_model_edit.php' . ($provider_filter ? "?provider=$provider_filter" : ''); +$csrf = $_SESSION['csrf_token']; + ?>
-

AI Models

+

AI Models

- + Usage + + +
-
+
- - + +
+
+ +
+ +
+
- -
- - "> +
+
+ "> - - - - - + + + + + + - $model_id]); + $then = $is_unused ? null : aiUseCaseMap(['model' => $model_id])['map'][$use_case] ?? null; + if ($is_unused) { + $delete_text = 'Nothing uses it - ' . ($unused[$model_id] ? $unused[$model_id]['ai_model_name'] . " is the one used for $use_case." : "$use_case isn't a use case."); + } elseif ($stopping) { + $delete_text = 'AI stops for ' . implode(' and ', $stopping) . ' until another model is set up.'; + } else { + $delete_text = "$use_case uses " . ($then && $then['model'] ? $then['model']['ai_model_name'] . ($then['own'] ? '' : ' (General)') : 'nothing') . ' instead.'; + } ?> - + - - - - + + + + - - - +
- - Model - - - - Provider - - - - Use Case - - - Prompt - ActionInstructions
- - - + + + ">Not used + +
+ +
Temperature
+
- + + +
+
+
+ +
+ + None + +
+ + +
in · out
+ + + + +
+ + +
Last
+ +
+
'AI models', - 'icon' => 'fa-robot', - 'text' => 'The models ITFlow uses for ticket summaries and rewording - add a provider first.', - 'actions' => [['label' => 'Add Model', 'modal' => 'modals/ai/ai_model_add.php']], + 'icon' => 'fa-brain', + 'text' => $any_provider ? 'The model on each provider that ITFlow uses - one for everything (General), and others for tickets or documents if you like.' : 'Add an AI provider first - then the models on it ITFlow uses.', + 'actions' => $any_provider ? [['label' => 'Add Model', 'modal' => $add_url]] : [['label' => 'AI Providers', 'href' => 'ai_providers.php', 'icon' => 'fa-robot']], ]; ?> diff --git a/admin/ai_providers.php b/admin/ai_providers.php index 73fc5d268..251d6e1aa 100644 --- a/admin/ai_providers.php +++ b/admin/ai_providers.php @@ -1,113 +1,209 @@ AI Providers - the AI services ITFlow asks (functions/ai.php): a + * ready-made kind each (OpenAI, Claude, Gemini, Azure, a model on your own + * network...), its address, whether its key is saved (never shown), whether + * passwords and keys are taken out of what is sent to it, the models on it and + * what each is for, and its use in the last 30 days. Above, which model each + * use case lands on. Test asks it for its models; Add and Edit are one window + * (modals/ai/ai_provider_edit.php); the rest are admin/post/ai_provider.php. + */ + // Default Column Sortby Filter $sort = "ai_provider_name"; $order = "ASC"; require_once "includes/inc_all_admin.php"; -$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_provider_api_key, ai_provider_api_url, ai_provider_id, ai_provider_name FROM ai_providers - WHERE ai_provider_name LIKE '%$q%' OR ai_provider_api_url LIKE '%$q%' - ORDER BY $sort $order LIMIT $record_from, $record_to"); +// Only these sort - an unknown column would take the page down +$sortable_columns = [ + 'ai_provider_name' => "ai_provider_name $order", + 'ai_provider_kind' => "ai_provider_kind $order, ai_provider_name ASC", + 'model_count' => "model_count $order, ai_provider_name ASC", + 'requests' => "requests $order, ai_provider_name ASC", + 'last_used' => "last_used IS NULL, last_used $order, ai_provider_name ASC", +]; +if (!isset($sortable_columns[$sort])) { + $sort = 'ai_provider_name'; +} + +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_provider_id, ai_provider_name, ai_provider_kind, ai_provider_api_url, ai_provider_redact, + ai_provider_api_key IS NOT NULL AND ai_provider_api_key <> '' AS has_key, + (SELECT COUNT(*) FROM ai_models WHERE ai_model_ai_provider_id = ai_provider_id) AS model_count, + COALESCE(used.requests, 0) AS requests, COALESCE(used.failed, 0) AS failed, COALESCE(used.tokens_in, 0) AS tokens_in, COALESCE(used.tokens_out, 0) AS tokens_out, used.last_used + FROM ai_providers + LEFT JOIN ( + SELECT ai_usage_provider_id, + SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY) AS requests, + SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY AND ai_usage_ok = 0) AS failed, + SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_in, 0)) AS tokens_in, + SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_out, 0)) AS tokens_out, + MAX(ai_usage_created_at) AS last_used + FROM ai_usage WHERE ai_usage_feature <> 'test' GROUP BY ai_usage_provider_id + ) used ON used.ai_usage_provider_id = ai_provider_id + WHERE (ai_provider_name LIKE '%$q%' OR ai_provider_kind LIKE '%$q%' OR ai_provider_api_url LIKE '%$q%' + OR EXISTS (SELECT 1 FROM ai_models WHERE ai_model_ai_provider_id = ai_provider_id AND ai_model_name LIKE '%$q%')) + ORDER BY {$sortable_columns[$sort]}, ai_provider_id ASC + LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +// Each provider's models, with what they are for - and those nothing uses +$models_by_provider = []; +$model_sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_use_case, ai_model_ai_provider_id FROM ai_models ORDER BY FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation'), ai_model_name"); +while ($model = mysqli_fetch_assoc($model_sql)) { + $models_by_provider[intval($model['ai_model_ai_provider_id'])][] = $model; +} +$unused = aiUseCaseMap()['unused']; +$any_provider = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM ai_providers"))[0]) > 0; + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; + +$add_url = 'modals/ai/ai_provider_edit.php'; +$csrf = $_SESSION['csrf_token']; + ?>

AI Providers

- + Models + Usage +
-
+
- - + +
+ +
+
- -
- - "> +
+
+ "> - - - - - + + + + + + - $provider_id]); + $model_names = array_column($models, 'ai_model_name'); + $delete_text = ($models ? (count($models) === 1 ? "Its model, {$model_names[0]}, goes with it." : 'Its ' . count($models) . ' models go with it - ' . implode(', ', $model_names) . '.') : 'It has no models.') + . ($stopping ? ' AI stops for ' . implode(' and ', $stopping) . ' until another model is set up.' : ''); ?> - + - - - + + + + - - - +
- - Provider - - - - URL - - - - Key - - - Models - ActionKey
- - - +
+ +
+ + + Your network + + + Secrets sent + +
+ +
+
+
- - - + + Saved + + Needs one - add it in Edit + + None + + + +
+ + · + + Not used + +
+ + + Add a model + +
+ + +
in · out
+ + + + +
+ + +
Last
+ +
+
@@ -115,8 +211,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $empty_state = [ 'thing' => 'AI providers', 'icon' => 'fa-robot', - 'text' => 'Connect an AI service, then pick the models ITFlow uses from it.', - 'actions' => [['label' => 'Add Provider', 'modal' => 'modals/ai/ai_provider_add.php']], + 'text' => 'Connect OpenAI, Claude, Gemini, Azure OpenAI, or a model on your own network like Ollama - then agents can reword replies, summarize tickets and draft documents, with passwords and keys taken out of what is sent.', + 'actions' => [['label' => 'Add Provider', 'modal' => $add_url]], ]; ?> diff --git a/admin/ai_usage.php b/admin/ai_usage.php new file mode 100644 index 000000000..6d25a213c --- /dev/null +++ b/admin/ai_usage.php @@ -0,0 +1,276 @@ + AI Usage - every request made to an AI provider (ai_usage, + * recorded by callAiApi() in functions/ai.php): when, the feature, who asked, + * the client it was about, the model and provider, the tokens it took, how + * long, how many passwords and keys were taken out of it first, and whether + * it worked - why not, when it didn't. Totals for what is shown sit above. + * Searched, and filtered by date, provider, model, feature and who; views for + * the failed ones, the ones secrets were taken out of, and tests. Kept as long + * as the audit log is (Admin > Settings > Logs). + */ + +// Default Column Sortby Filter +$sort = "ai_usage_id"; +$order = "DESC"; + +require_once "includes/inc_all_admin.php"; + +$page_title = 'AI Usage'; + +// Only these sort - an unknown column would take the page down +$sortable_columns = [ + 'ai_usage_id' => "ai_usage_id $order", + 'ai_usage_feature' => "ai_usage_feature $order, ai_usage_id DESC", + 'user_name' => "user_name IS NULL, user_name $order, ai_usage_id DESC", + 'client_name' => "client_name IS NULL, client_name $order, ai_usage_id DESC", + 'ai_usage_model' => "ai_usage_model $order, ai_usage_id DESC", + 'tokens' => "(ai_usage_tokens_in + ai_usage_tokens_out) $order, ai_usage_id DESC", + 'ai_usage_ms' => "ai_usage_ms $order, ai_usage_id DESC", +]; +if (!isset($sortable_columns[$sort])) { + $sort = 'ai_usage_id'; +} + +$features = aiFeatures(); +$views = [ + '' => ['label' => 'All', 'icon' => 'fa-list', 'where' => "ai_usage_feature <> 'test'", 'title' => 'Every request but tests'], + 'failed' => ['label' => 'Failed', 'icon' => 'fa-times-circle', 'where' => "ai_usage_feature <> 'test' AND ai_usage_ok = 0", 'title' => 'The provider turned them away, or couldn\'t be reached'], + 'redacted' => ['label' => 'Secrets kept out', 'icon' => 'fa-user-secret', 'where' => "ai_usage_redacted > 0", 'title' => 'Passwords or keys were taken out before they were sent'], + 'test' => ['label' => 'Tests', 'icon' => 'fa-vial', 'where' => "ai_usage_feature = 'test'", 'title' => 'Try it, from a model\'s window'], +]; +$view = is_string($_GET['view'] ?? null) && isset($views[$_GET['view']]) ? $_GET['view'] : ''; +$provider_filter = intval($_GET['provider'] ?? 0); +$model_filter = intval($_GET['model'] ?? 0); +$user_filter = intval($_GET['user'] ?? 0); +$feature_filter = is_string($_GET['feature'] ?? null) && isset($features[$_GET['feature']]) ? $_GET['feature'] : ''; +$from = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $dtf) ? $dtf : '1970-01-01'; +$to = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $dtt) ? $dtt : '2099-12-31'; + +// Everything but the view - the views are counted with it +$where = ["DATE(ai_usage_created_at) BETWEEN '$from' AND '$to'"]; +if ($q !== '') { + $where[] = "(ai_usage_model LIKE '%$q%' OR ai_usage_provider_name LIKE '%$q%' OR ai_usage_feature LIKE '%$q%' OR ai_usage_use_case LIKE '%$q%' + OR user_name LIKE '%$q%' OR client_name LIKE '%$q%' OR ai_usage_error LIKE '%$q%')"; +} +if ($provider_filter) { + $where[] = "ai_usage_provider_id = $provider_filter"; +} +if ($model_filter) { + $where[] = "ai_usage_model_id = $model_filter"; +} +if ($user_filter) { + $where[] = "ai_usage_user_id = $user_filter"; +} +if ($feature_filter !== '') { + $where[] = "ai_usage_feature = '" . escapeSql($feature_filter) . "'"; +} +$where_sql = implode(' AND ', $where); +$from_sql = "FROM ai_usage LEFT JOIN users ON user_id = ai_usage_user_id LEFT JOIN clients ON client_id = ai_usage_client_id"; + +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_usage.*, user_name, client_name + $from_sql + WHERE $where_sql AND {$views[$view]['where']} + ORDER BY {$sortable_columns[$sort]} + LIMIT $record_from, $record_to"); +$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); + +// Each view counted for the rest of the filters, and the totals for this one +$count_sql = []; +foreach ($views as $definition) { + $count_sql[] = "COALESCE(SUM({$definition['where']}), 0)"; +} +$counts = array_combine(array_keys($views), array_map('intval', mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " $from_sql WHERE $where_sql")))); +$totals = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS requests, COALESCE(SUM(ai_usage_tokens_in), 0) AS tokens_in, COALESCE(SUM(ai_usage_tokens_out), 0) AS tokens_out, + COALESCE(SUM(ai_usage_ok = 0), 0) AS failed, COALESCE(SUM(ai_usage_redacted), 0) AS redacted, COALESCE(AVG(IF(ai_usage_ok = 1, ai_usage_ms, NULL)), 0) AS ms, COUNT(DISTINCT ai_usage_user_id) AS people + $from_sql WHERE $where_sql AND {$views[$view]['where']}")); + +$providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name, ai_provider_kind FROM ai_providers ORDER BY ai_provider_name"); +$people = mysqli_query($mysqli, "SELECT DISTINCT user_id, user_name FROM ai_usage JOIN users ON user_id = ai_usage_user_id ORDER BY user_name"); +$model_row = $model_filter ? aiModelRow($model_filter) : null; +$retention = appLogRetention(); + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; +// A view keeps the filters, and starts from its first page +$view_url = function ($key) { + $query = $_GET; + unset($query['page']); + $query['view'] = $key; + return '?' . http_build_query(array_filter($query, function ($value) { + return $value !== '' && $value !== null && $value !== []; + })); +}; +// The page without one filter +$without_url = function ($key) { + $query = $_GET; + unset($query['page'], $query[$key]); + return '?' . http_build_query($query); +}; +$ms_text = function ($ms) { + $ms = intval($ms); + return $ms >= 1000 ? number_format($ms / 1000, 1) . 's' : $ms . 'ms'; +}; + +?> + + + +
+
+

AI Usage

+
+ Models + Keeping +
+
+
+
+ $view, 'model' => $model_filter ?: ''] as $keep => $value) { ?> + + +
+
+
+ + +
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ Just + +
+ + + +
+
Requests
+
Tokens in
+
Tokens out
+
Failed
+
Secrets kept out
+
Answer time
+
+ +
+
+ + "> + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + +
secret kept out
+ +
+
' . (intval($row['ai_usage_user_id']) ? 'Removed' : 'ITFlow') . '' ?> + + + + - + + +
+
+
+ +
in
+
out
+ + - + +
+
+ 'AI requests', + 'icon' => 'fa-chart-bar', + 'views' => ['view'], + 'view' => $view !== '' ? ['icon' => $views[$view]['icon'], 'title' => 'None ' . ($view === 'test' ? 'tried yet' : 'yet'), 'text' => escapeHtml($views[$view]['title']) . ' - they show here.'] : null, + 'text' => 'Each time an agent rewords a reply, summarizes a ticket or drafts a document, it shows here - who, for what, the model and the tokens it took. ' . ($retention['audit'] ? 'Kept for ' . escapeHtml(appLogRetentionText($retention['audit'])) . ', as the audit log is.' : 'Kept as long as the audit log is.'), + ]; + ?> + +
+ + 0; + }; + + // AI providers - which kind each is (how it is called and its models listed), + // and whether passwords and keys are taken out of what is sent to it. Room for + // the longer keys and Azure's longer addresses. + if (!$itflow_column('ai_providers', 'ai_provider_kind')) { + mysqli_query($mysqli, "ALTER TABLE `ai_providers` ADD `ai_provider_kind` varchar(50) NOT NULL DEFAULT 'custom' AFTER `ai_provider_name`"); + // Those set up already, by where they point + foreach ([ + 'api.openai.com' => 'openai', 'openai.azure.com' => 'azure', 'api.anthropic.com' => 'anthropic', 'generativelanguage.googleapis.com' => 'gemini', + 'api.mistral.ai' => 'mistral', 'api.groq.com' => 'groq', 'openrouter.ai' => 'openrouter', ':11434' => 'ollama', ':1234' => 'lmstudio', + ] as $itflow_host => $itflow_kind) { + mysqli_query($mysqli, "UPDATE `ai_providers` SET `ai_provider_kind` = '$itflow_kind' WHERE `ai_provider_kind` = 'custom' AND `ai_provider_api_url` LIKE '%$itflow_host%'"); + } + } + if (!$itflow_column('ai_providers', 'ai_provider_redact')) { + mysqli_query($mysqli, "ALTER TABLE `ai_providers` ADD `ai_provider_redact` tinyint(1) NOT NULL DEFAULT 1 AFTER `ai_provider_api_key`"); + } + mysqli_query($mysqli, "ALTER TABLE `ai_providers` MODIFY `ai_provider_api_url` varchar(500) NOT NULL, MODIFY `ai_provider_api_key` varchar(500) DEFAULT NULL"); + + // Each AI request - what it was for, who asked, the model, tokens and how long it took + mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `ai_usage` ( + `ai_usage_id` int(11) NOT NULL AUTO_INCREMENT, + `ai_usage_feature` varchar(50) NOT NULL, + `ai_usage_use_case` varchar(50) DEFAULT NULL, + `ai_usage_provider_id` int(11) NOT NULL DEFAULT 0, + `ai_usage_provider_name` varchar(200) DEFAULT NULL, + `ai_usage_model_id` int(11) NOT NULL DEFAULT 0, + `ai_usage_model` varchar(200) DEFAULT NULL, + `ai_usage_user_id` int(11) NOT NULL DEFAULT 0, + `ai_usage_client_id` int(11) NOT NULL DEFAULT 0, + `ai_usage_tokens_in` int(11) NOT NULL DEFAULT 0, + `ai_usage_tokens_out` int(11) NOT NULL DEFAULT 0, + `ai_usage_ms` int(11) NOT NULL DEFAULT 0, + `ai_usage_redacted` int(11) NOT NULL DEFAULT 0, + `ai_usage_ok` tinyint(1) NOT NULL DEFAULT 1, + `ai_usage_error` varchar(255) DEFAULT NULL, + `ai_usage_created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`ai_usage_id`), + KEY `ai_usage_created_at` (`ai_usage_created_at`), + KEY `ai_usage_provider_id` (`ai_usage_provider_id`), + KEY `ai_usage_model_id` (`ai_usage_model_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); + + // Using AI is a permission of its own - every role keeps it, as anyone could use it before + $itflow_module = mysqli_fetch_row(mysqli_query($mysqli, "SELECT module_id FROM modules WHERE module_name = 'module_ai' LIMIT 1")); + if (!$itflow_module) { + mysqli_query($mysqli, "INSERT INTO modules SET module_name = 'module_ai', module_description = 'Using AI - rewording, summaries and drafts'"); + $itflow_module_id = intval(mysqli_insert_id($mysqli)); + mysqli_query($mysqli, "INSERT INTO user_role_permissions (user_role_id, module_id, user_role_permission_level) + SELECT role_id, $itflow_module_id, 1 FROM user_roles WHERE role_is_admin = 0"); + } + + unset($itflow_column, $itflow_host, $itflow_kind, $itflow_module, $itflow_module_id); diff --git a/admin/includes/side_nav.php b/admin/includes/side_nav.php index a8e0445b4..c7024785d 100644 --- a/admin/includes/side_nav.php +++ b/admin/includes/side_nav.php @@ -102,7 +102,7 @@