Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01cc719c5b
|
||
|
|
1080a8b1ba | ||
|
|
38bb123140
|
||
|
|
bb62e4a7a0 | ||
|
|
e54de89a49
|
||
|
|
fbe2241fc4 |
No files matched your search
@@ -31,14 +31,16 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant Admin app, the retention process, and the `assistant.admin` custom permission checker.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
- `home/`: the app home widget (`AssistantHomeData` loads, `AssistantHomeView` renders flow-layout HTML).
|
||||
- `model/`: entities for the `assistant*` tables.
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/AssistantRetentionStep`.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
- `qbit-assistant-provider-anthropic`: `AnthropicModelProvider` and `AnthropicMapping` (anthropic-java streaming).
|
||||
|
||||
## Invariants (do not break)
|
||||
@@ -56,6 +58,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- Parallel tool results go back in ONE user message, in call order.
|
||||
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
|
||||
- Unknown tools and over-limit calls also get error results.
|
||||
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
|
||||
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
|
||||
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
|
||||
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
|
||||
|
||||
@@ -8,6 +8,42 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
|
||||
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
|
||||
- the developer-panel contract (QRun-IO/qqq#1076).
|
||||
|
||||
### Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13)
|
||||
- **Every data tool runs a spec'd `/qqq/v1` operation with the user's own session** (`UiApiGateway`, loopback HTTP with the `sessionUUID` cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. `navigate` and `search_help` (no records) stay on the core kernel. `LoopbackUiApiGateway` is a stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128).
|
||||
- Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**.
|
||||
|
||||
### Added (0.1.0-RC.6)
|
||||
- **Data access** (`assistant_data_access`, nav table "Data access"): NONE / READ / READ_WRITE per table or process. Default: NONE for sensitive tables (AI exposure NONE; cannot be raised), READ for other tables, NONE for processes. It only narrows the UI API.
|
||||
- **Pending actions** (`assistant_pending_action`): with confirmation on (the default), a write only proposes; `POST /qqq/v1/assistant/actions/{id}/confirm` runs it through the UI API with the confirming user's session, once; `.../reject`; `GET .../actions/{id}`. Child list on conversations.
|
||||
- **Policy columns** on `assistant_policy`: `writesRequireConfirmation`, `allowedTools`, `maxRowsPerCall`, `redactPii`.
|
||||
- **Tool-call log columns** on `assistant_tool_call`: `uiApiOperation`, `httpStatus`, `auditHref`, `pendingActionId`; tool inputs are stored with secrets redacted.
|
||||
- Config: `uiApiBaseUrl`, `withUiApiGateway`, `pendingActionTtlSeconds`. `/assistant/config` reports `features.writes` (some table is READ_WRITE) and `features.pendingActions`.
|
||||
|
||||
### Fixed (0.1.0-RC.5)
|
||||
- **Every turn failed with "The AI service could not answer this request."** Anthropic refused the request with 400 `invalid_request_error` "The compiled grammar is too large ... reduce the number of strict tools": all ten read tools were sent with `strict: true`, and the API compiles every strict schema of a request into one grammar. Tools are now **not strict by default**. The kernel already validates every tool input and answers `INVALID_INPUT`. A host can opt individual tools in with `withStrictToolNames(...)`.
|
||||
- If a provider still refuses the strict schemas, the turn **retries once without strict tools** and logs a warning (`ModelProviderException.isStrictSchemaRejected`).
|
||||
- Provider errors now carry the **API's own error type, a sanitized message** (one line, capped, key-like tokens redacted) and the **request id**:
|
||||
- they are logged at WARN for 4xx;
|
||||
- they are recorded in the `assistant_usage` row's request id;
|
||||
- they are shown to `assistant.admin` holders in the panel and in Test connection.
|
||||
Before this, a 400 was logged as "INVALID_REQUEST httpStatus=400" with no reason.
|
||||
- A turn that hits a JVM `Error` (for example a linkage problem) now ends as `ERROR` with a log line, instead of being swallowed by the executor and ending as an empty `COMPLETE` answer.
|
||||
- `AnthropicRequestContractTest` checks the exact request against the Messages API constraints and a conservative strict-tool budget.
|
||||
|
||||
### Added (0.1.0-RC.4)
|
||||
- **Provider settings** (`assistant_provider`): type (registered `AssistantProviderType`s; `AnthropicProviderType` in the Anthropic module), model, enabled, default, base URL override, monthly cost cap, max output tokens per request, max concurrent requests, and the last connection test. The default enabled row is the active provider; with no rows the provider registered in code is used, so existing hosts keep working.
|
||||
- **Key handling:** the key is never stored. A row names the environment variable that holds it (`apiKeyEnvName`, allow-listed); `apiKeyStatus` is computed on read (`Set • …abcd`) and dropped on save. A pasted key, an unrelated variable or an unlisted base URL is refused. New config: `withProviderType`, `allowedApiKeyEnvNames`, `allowedBaseUrls`, `withSecretResolver`, `providerTestTimeoutSeconds`.
|
||||
- **Test connection**, a record action on providers (standard streamed ETL Review and Result lines): one short live call, result saved on the row and recorded as a `TEST` usage row.
|
||||
- **Per-call usage** (`assistant_usage`): one row per model call (turn steps and tests) with provider, model, status, error kind, HTTP status, latency, tokens and cost. Child list on conversations and providers.
|
||||
- **Model prices** (`assistant_model_price`), editable, used for every call's cost; the reference DDL seeds the Anthropic list prices of 2026-10. The built-in `pricing` defaults now cover every current Claude model.
|
||||
- **The app home** (`assistantHome` widget): health, providers, usage by day, costs against caps, by user, top tools and recent failures, from real rows with empty states.
|
||||
- `ModelResponse.model`: the model that answered (set by the Anthropic adapter).
|
||||
|
||||
### Changed (0.1.0-RC.4)
|
||||
- The app is labelled **Assistant** (name `assistantAdmin` unchanged). Its nav lists the home and the main tables only (Providers, Conversations, Model prices, Policy); other tables are child lists. `assistantRetention` is a table action on conversations instead of a nav entry.
|
||||
- Every `assistant_*` table has AI exposure `NONE`, so the assistant never reads its own settings or audit tables (before, only the `assistant.admin` permission kept them out).
|
||||
- `AssistantMetaDataBuilder.buildAdminApp(tables)` no longer takes the retention process.
|
||||
|
||||
### Changed (0.1.0-RC.3)
|
||||
- **Snake_case names** (QRun-IO naming rule, 2026-10-12). Metadata only; the physical tables were already `assistant_*`:
|
||||
- tables `assistantConversation` ... `assistantUsageDaily` are now `assistant_conversation`, `assistant_message`, `assistant_turn`, `assistant_turn_event`, `assistant_tool_call`, `assistant_feedback`, `assistant_policy` and `assistant_usage_daily`; the conversation possible-value source follows the table name
|
||||
|
||||
@@ -11,10 +11,10 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
- budgets, rate limits and the kill switch
|
||||
- the model-provider SPI.
|
||||
|
||||
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **AI tool kernel in QQQ core** (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`).
|
||||
The panel UI lives in `qqq-frontend-next`. Data access goes only through the **QQQ UI API** (`/qqq/v1`, spec'd operations, the user's own session) via `uiapi/UiApiGateway` and `tools/UiApiToolbox`; only `navigate` and `search_help` (no records) use the core AI tool kernel.
|
||||
|
||||
- Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. The owner decisions D1 to D6 at its top are binding.
|
||||
- Phase 1 is read and navigate only: no write tools and no approval cards.
|
||||
- Writes (create/update/delete) are offered only for tables an admin set to READ_WRITE in `assistant_data_access`, and by default wait for the user's confirmation (pending action card).
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -31,14 +31,16 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant Admin app, the retention process, and the `assistant.admin` custom permission checker.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
- `home/`: the app home widget (`AssistantHomeData` loads, `AssistantHomeView` renders flow-layout HTML).
|
||||
- `model/`: entities for the `assistant_*` tables.
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/AssistantRetentionStep`.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
- `qbit-assistant-provider-anthropic`: `AnthropicModelProvider` and `AnthropicMapping` (anthropic-java streaming).
|
||||
|
||||
## Invariants (do not break)
|
||||
@@ -49,13 +51,16 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
|
||||
- **Data access:**
|
||||
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
|
||||
- Tools run only through `AiToolKernel.execute`.
|
||||
- Data tools run only through `UiApiToolbox` → `UiApiGateway` (spec'd `/qqq/v1` operations with the user's `sessionUUID`); never core data actions for user data. Only `navigate` and `search_help` use `AiToolKernel.execute`.
|
||||
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows; a table whose AI exposure is NONE stays NONE.
|
||||
- A write runs only after the owner confirms its pending action (unless the policy turns confirmation off), with the confirming request's session, once.
|
||||
- Panel API queries also filter `userId = me`, so an admin's all-access key never mixes other users' conversations into the panel.
|
||||
- **Caching:** the request prefix stays byte-stable. The order is tools (kernel-sorted), then system block 1 (rules + primer + app digest, LONG/1h), then system block 2 (user digest + date, SHORT/5m), then messages. Exactly one SHORT hint goes on the newest message. Don't put timestamps or ids in the system prompt.
|
||||
- **Tool results:**
|
||||
- Parallel tool results go back in ONE user message, in call order.
|
||||
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
|
||||
- Unknown tools and over-limit calls also get error results.
|
||||
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
|
||||
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
|
||||
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
|
||||
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
|
||||
|
||||
@@ -37,14 +37,15 @@ The data access itself is done by the AI tool kernel in QQQ core (`com.kingsrook
|
||||
AssistantQBitConfig config = new AssistantQBitConfig()
|
||||
.withBackendName("rdbms") // where the assistant_* tables live
|
||||
.withTableMetaDataCustomizer(snakeCaseCustomizer) // optional: backend names for your schema
|
||||
.withModelProvider(AnthropicModelProvider.fromEnvironment()); // key from ANTHROPIC_API_KEY
|
||||
.withModelProvider(AnthropicModelProvider.fromEnvironment()) // fallback while no provider row exists
|
||||
.withProviderType(new AnthropicProviderType()); // offered on the Providers settings screen
|
||||
|
||||
new AssistantQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
```
|
||||
|
||||
`produce` does the following:
|
||||
- Registers the QBit.
|
||||
- Adds the tables, the `assistant_user_id` security key type, the `assistantRetention` process and the **Assistant Admin** app.
|
||||
- Adds the tables, the `assistant_user_id` security key type, the `assistantRetention` and **Test connection** processes, and the **Assistant** app with its home.
|
||||
- Builds the runtime.
|
||||
- Registers the `/qqq/v1/assistant/*` routes when Javalin is on the classpath.
|
||||
- Registers the record Developer-screen panel when QQQ core has the developer-panel contract.
|
||||
@@ -53,12 +54,48 @@ Create the tables in your database: see [Tables](#tables). Then grant the permis
|
||||
|
||||
### The API key
|
||||
|
||||
The key never goes in config, tables or logs. There are two ways to supply it:
|
||||
- `AnthropicModelProvider.fromEnvironment()` reads `ANTHROPIC_API_KEY`.
|
||||
- `AnthropicModelProvider.builder().apiKey(valueFromYourSecretStore).build()` takes the key from your own secret store. It also accepts the reference form `"${env.NAME}"`.
|
||||
The key never goes in config, tables or logs. It lives in the application's secret store and reaches the server as an environment variable (for kof22: Infisical, then an ExternalSecret, then the pod env).
|
||||
|
||||
- **Provider settings rows** (recommended) name the variable: `assistant_provider.apiKeyEnvName`, default the type's own (`ANTHROPIC_API_KEY`). The server reads the variable when it builds the client.
|
||||
- Without a row, the provider registered in code is used: `AnthropicModelProvider.fromEnvironment()` reads `ANTHROPIC_API_KEY`, and `AnthropicModelProvider.builder().apiKey(valueFromYourSecretStore).build()` takes the key from your own secret store (it also accepts `"${env.NAME}"`).
|
||||
|
||||
The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type.
|
||||
|
||||
## Provider settings (the Providers table)
|
||||
|
||||
Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| Name, Type, Model | The type is one of the registered provider types (`withProviderType`; Anthropic now, others plug in the same way). The model list comes from the type and the price table. An empty model uses the policy's main model. |
|
||||
| Enabled, Default | The assistant uses the default enabled provider, else the first enabled one. Marking one default unmarks the others. With no enabled row, the provider registered in code is used. |
|
||||
| API Key Variable | The **name** of the environment variable that holds the key, never the key. Only allowed names are accepted: each registered type's default plus `allowedApiKeyEnvNames`. A pasted key (or `DATABASE_PASSWORD`) is refused and nothing is stored. |
|
||||
| API Key | Read-only, computed on every read: `Set • …abcd (from ANTHROPIC_API_KEY)` or `Not set (...)`. It shows the last four characters at most. No read path (screen, API, query, export) returns the key. |
|
||||
| Base URL Override | Empty uses the provider's own API. Any other URL must be listed in `allowedBaseUrls`, so a row cannot send the key to a host of an admin's choosing. |
|
||||
| Monthly Cost Cap (USD) | When this provider's cost this month (UTC) reaches the cap, new requests are refused (429 `BUDGET`) and a running answer stops before its next model call. |
|
||||
| Max Output Tokens Per Request, Max Concurrent Requests | Caps one model call's output, and how many calls run at once on this server. |
|
||||
| Last test | Status, time, latency, the model that answered, and the error text, from **Test connection**. |
|
||||
|
||||
**Why a reference and not an encrypted column.** QQQ core has no encrypted or secret field type. The key-provider and cipher work in the QQQ 4.1.0-RC.6 batch (QRun-IO/qqq#1123, #1107) encrypts backup artifacts, not table columns, and is not released. The design spec (§2.8) also says keys never go in tables. A reference keeps the key in the secret store that already holds it, so rotating it there is the "replace key" step, and the database never holds one.
|
||||
|
||||
**Test connection** is a record action on a provider (select one or more). It is a standard streamed ETL: Review lists what will be called and anything wrong with the row (missing key, disallowed variable or URL) before any call. Result has one line per provider: `connected to claude-sonnet-5-5 in 812 ms`, or the error with its kind and HTTP status. It makes one short live call (a 1-sentence prompt, at most 32 output tokens, effort low), saves the result on the row, and records the call in `assistant_usage` with purpose `TEST`.
|
||||
|
||||
## The app home
|
||||
|
||||
The **Assistant** app's home is the one place for the assistant's health, usage and cost (no other dashboard repeats it). All of it comes from the tables below, read as the viewing admin; an empty table gives an empty state, never sample data.
|
||||
|
||||
- **Health:** is it answering (kill switch, default provider, key present, last test), the 7-day error rate of model calls, cost and requests this month against the instance's monthly limit.
|
||||
- **Providers:** one row per provider: type, model, masked key status, last test, cost this month against its cap, limits, and any problem with its settings.
|
||||
- **Usage, last 30 days:** bars of model requests per day, totals (conversations, requests, tokens in and out, cost) and the last 7 days in words.
|
||||
- **Costs this month:** month to date against the policy limit and each provider cap, then by model. Cost is tokens times the model's price from **Model prices**.
|
||||
- **By user, this month**, **Top tools** (calls, failures, average time) and **Recent failures** (model calls and tool calls, linked).
|
||||
|
||||
The nav lists the home and the main tables: **Providers**, **Conversations**, **Model prices** and **Policy**. Messages, turns, tool calls, feedback and model calls are child lists on a conversation; model calls are also listed on their provider. Retention and Test connection are table and record actions, never nav entries.
|
||||
|
||||
## Model prices
|
||||
|
||||
`assistant_model_price` holds USD per million tokens per provider type and model: input, output, cache read, and cache writes (5 minute and 1 hour). Empty cache prices default to 0.1x, 1.25x and 2x the input price. The reference DDL seeds the Anthropic list prices of 2026-10. Edit a row to change what later calls cost; past rows keep the cost they were recorded with. A model with no row uses the `pricing` config map, else costs 0.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
@@ -82,6 +119,13 @@ The provider's `toString` never contains the key. Its errors carry only the HTTP
|
||||
| `hiddenWidgetNames` | none | Widgets that are never rendered, even when allowed. |
|
||||
| `customRedactionPatterns` | none | Extra regular expressions the kernel redacts from tool results. |
|
||||
| `killSwitchPollMillis` | 2000 | How often a running turn re-checks the kill switch while it waits on the model. |
|
||||
| `providerTypes` | none | `withProviderType(...)`: the types the Providers screen offers (`AnthropicProviderType`). |
|
||||
| `allowedApiKeyEnvNames` | none | Extra environment variable names a provider row may name as its key. Each type's default is always allowed. |
|
||||
| `allowedBaseUrls` | none | Base URL overrides a provider row may use. The type's own URL is always allowed. |
|
||||
| `secretResolver` | `System::getenv` | How a key variable name becomes the key (a host secret store, or tests). |
|
||||
| `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. |
|
||||
| `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. |
|
||||
| `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. |
|
||||
|
||||
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
|
||||
|
||||
@@ -96,7 +140,7 @@ When a turn sees the switch on, it cancels every turn running on that replica. I
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `assistant.hasAccess` | Use the panel. It grants **no data access**: the assistant reads only what the user can read anyway. |
|
||||
| `assistant.admin` | The Assistant Admin app, its tables and the retention process. |
|
||||
| `assistant.admin` | The Assistant app (home, settings and audit tables), Test connection and the retention process. |
|
||||
|
||||
There is no assistant-specific data permission. Everything the kernel reads is checked against the user's normal table permissions and record locks. The QBit never uses a system session (an ArchUnit test enforces this).
|
||||
|
||||
@@ -106,7 +150,7 @@ When the QBit is enabled, it registers the supplemental instance meta-data `assi
|
||||
|
||||
Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistant_user_id`:
|
||||
- The panel routes add the user's own id to their session, so users only ever see their own rows.
|
||||
- Admins see everyone's rows in the Assistant Admin app through the key type's all-access key, `assistant_user_id_all_access`. Grant that key to `assistant.admin` holders from your authentication customizer:
|
||||
- Admins see everyone's rows in the Assistant app through the key type's all-access key, `assistant_user_id_all_access`. Grant that key to `assistant.admin` holders from your authentication customizer:
|
||||
|
||||
```java
|
||||
// in your QAuthenticationModuleCustomizerInterface.customizeSession(...)
|
||||
@@ -174,7 +218,7 @@ The stream ends after `message.end`. Thinking is never streamed.
|
||||
|
||||
## Tables
|
||||
|
||||
All of these are standard, visible QQQ tables in the Assistant Admin app:
|
||||
All of these are standard, visible QQQ tables in the Assistant app. The model never reads any of them (AI exposure `NONE`).
|
||||
|
||||
| Table | Holds |
|
||||
|---|---|
|
||||
@@ -186,6 +230,9 @@ All of these are standard, visible QQQ tables in the Assistant Admin app:
|
||||
| `assistant_feedback` | Feedback on answers. |
|
||||
| `assistant_policy` | The kill switch and overrides. |
|
||||
| `assistant_usage_daily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. |
|
||||
| `assistant_provider` | Provider settings: type, model, key variable (never the key), limits, last connection test. |
|
||||
| `assistant_model_price` | USD per million tokens per model, used for every call's cost. |
|
||||
| `assistant_usage` | One row per model call (a turn step or a connection test): user, conversation, turn, provider, model, status, latency, tokens, cost. Locked to its user like the other audit tables. |
|
||||
|
||||
Table names follow the QRun-IO naming rule: lowercase snake_case with the `assistant_` prefix, the same as the physical tables in `docs/schema-postgres.sql` (the reference DDL). Field names are camelCase. Map them to your columns with a table customizer. Upgrading from 0.1.0-RC.1, which used camelCase table names and the `assistantUserId` key type: see [UPGRADE.md](UPGRADE.md).
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## 0.1.0-RC.3 to 0.1.0-RC.4: provider settings, prices, per-call usage, app home
|
||||
|
||||
Three new tables. Create them before you deploy (see `docs/schema-postgres.sql`, which also seeds the Anthropic prices):
|
||||
- `assistant_provider` (with the `api_key_status` column: it is filled on read and never written, but the backend selects it)
|
||||
- `assistant_model_price`
|
||||
- `assistant_usage`
|
||||
|
||||
Then, to keep answering exactly as before:
|
||||
1. Register the provider type: `config.withProviderType(new AnthropicProviderType())`. Keep `withModelProvider(AnthropicModelProvider.fromEnvironment())` as the fallback.
|
||||
2. Add one provider row (or seed it in your migration): type `anthropic`, model = your current main model, enabled, default, `apiKeyEnvName` = `ANTHROPIC_API_KEY` (or leave it empty for the default). No key value is ever written.
|
||||
3. Run **Test connection** on it.
|
||||
|
||||
Until a row exists, the provider registered in code answers, as in RC.3. Other changes: the app label is **Assistant**; the nav no longer lists the retention process (it is a table action on conversations) or the audit tables (they are child lists). Grants on `assistant.admin` cover everything new.
|
||||
|
||||
## 0.1.0-RC.1 to 0.1.0-RC.3: snake_case names
|
||||
|
||||
The rename shipped in 0.1.0-RC.3. 0.1.0-RC.2 on Maven Central is an accidental republish of the RC.1 code (see CHANGELOG.md), so upgrading from RC.2 is the same as upgrading from RC.1.
|
||||
@@ -93,3 +107,14 @@ QQQ stores table names, permission names and key names outside this qbit. Update
|
||||
### Order
|
||||
|
||||
Deploy RC.3 and run the statements above in the same maintenance window. Until the update runs, saved views and reports on the assistant tables do not open, and admins who get the all-access key from their own customizer only see their own rows.
|
||||
|
||||
## 0.1.0-RC.6: tools through the UI API
|
||||
|
||||
Database (see `docs/schema-postgres.sql`):
|
||||
- new tables `assistant_data_access` and `assistant_pending_action`;
|
||||
- new columns `assistant_tool_call.ui_api_operation`, `http_status`, `audit_href`, `pending_action_id`;
|
||||
- new columns `assistant_policy.writes_require_confirmation`, `allowed_tools`, `max_rows_per_call`, `redact_pii`.
|
||||
|
||||
Runtime: the tools call this server's own `/qqq/v1` on `127.0.0.1` and the port the first assistant request arrived on. If the server is not reachable that way (a different bind address, a TLS-only listener), set `withUiApiBaseUrl(...)`. Grant `assistant.admin` holders the new tables through the qbit's existing admin permission (no new permission names).
|
||||
|
||||
Behavior: nothing is writable until an admin adds a READ_WRITE data access row; before that the assistant reads exactly what it did before, now through the UI API.
|
||||
@@ -0,0 +1,213 @@
|
||||
# qbit-assistant: settings, usage and app home (design)
|
||||
|
||||
The assistant's overall design is the QQQ spec `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md` (owner decisions D1 to D7). This note covers what 0.1.0-RC.4 adds on top of it: provider settings, per-call usage, prices and the app home.
|
||||
|
||||
Owner request (2026-10-13): *"it needs a good homepage for the app for it, and it needs a settings screen where you setup the API keys, provider, test connectivity etc. and the app homepage should show health, usage, costs, etc."*
|
||||
|
||||
## Data
|
||||
|
||||
All of it is in standard QQQ tables with full record screens (D6), prefixed `assistant_`, secured by `assistant.admin` and, for user-owned rows, the `assistant_user_id` record lock. Every `assistant_*` table has AI exposure `NONE`.
|
||||
|
||||
| Table | Written by | Purpose |
|
||||
|---|---|---|
|
||||
| `assistant_provider` | admins | Which provider and model answer, the key reference, limits, the last connection test. |
|
||||
| `assistant_model_price` | admins (seeded) | USD per million tokens per model. |
|
||||
| `assistant_usage` | the qbit only | One row per model call: turn steps (`TURN`) and connection tests (`TEST`). |
|
||||
|
||||
`assistant_turn` keeps the per-turn totals and `assistant_usage_daily` the budget rollups, as before. `assistant_usage` adds the per-call detail the home needs: the provider, the failures and latency.
|
||||
|
||||
## API keys
|
||||
|
||||
Decision: **reference, not storage.** A provider row holds the NAME of an environment variable (`apiKeyEnvName`); `ProviderService` resolves it through `config.getSecretResolver()` (default `System::getenv`) when it builds a client.
|
||||
|
||||
Why:
|
||||
- QQQ core has no encrypted or secret field type. The key-provider SPI and Tink cipher in the RC.6 batch (qqq#1123, #1107) encrypt streamed backup artifacts, not columns, and are not released.
|
||||
- Spec §2.8: keys are `${env.*}` secrets and are never stored in tables.
|
||||
- Deployments already hold the key in a secret store (kof22: Infisical, then an ExternalSecret, then the pod env). Rotating it there is the "replace key" step; the database never has a copy to leak through a backup, an export or the API.
|
||||
|
||||
Guards:
|
||||
- Only allow-listed names: each registered type's default (`ANTHROPIC_API_KEY`) plus `allowedApiKeyEnvNames`. Otherwise an admin could aim a row at `DATABASE_PASSWORD` and send it to a provider.
|
||||
- A base URL override must be in `allowedBaseUrls`. Otherwise an admin could send the real key to their own host.
|
||||
- The variable name must look like one (`[A-Z_][A-Z0-9_]*`), so a key pasted into the field is refused before it is stored. The error never echoes the value.
|
||||
- `apiKeyStatus` is a real, read-only column filled by the post-query customizer: `Set • …abcd (from VAR)` (no characters for keys under 12) or `Not set`. Saves drop it, so it is always null in the database. (A virtual field cannot sit in a base section in QQQ 4.1, hence the column.)
|
||||
- Clients are cached by row settings and a SHA-256 of the key. Keys are never logged, and neither `toString` nor the errors contain one.
|
||||
|
||||
## Choosing the provider
|
||||
|
||||
`ProviderService.resolve(policyModel)`:
|
||||
1. The enabled row marked default (marking one default unmarks the others), else the first enabled row.
|
||||
2. With no enabled row, the provider registered in code (`withModelProvider`) and the policy's main model: hosts that never add a row keep working.
|
||||
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently.
|
||||
|
||||
The model is the row's, else the policy's main model. The rows and prices are cached for `policyCacheSeconds` and dropped on any change.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Monthly cost cap (USD):** checked when a turn starts (429 `BUDGET`) and before each model call of a running turn, from the provider's `assistant_usage` cost since the 1st (UTC).
|
||||
- **Max output tokens per request:** lowers the request's `max_tokens`.
|
||||
- **Max concurrent requests:** a per-provider semaphore around each model call on this replica.
|
||||
|
||||
These sit next to the existing policy budgets (per user per day, per instance per month), which still apply.
|
||||
|
||||
## Test connection
|
||||
|
||||
A record action on `assistant_provider`, as a standard streamed ETL with Review and Result summary lines and no custom form.
|
||||
- **Review** lists the providers, the masked key status and every settings problem, without calling anything.
|
||||
- **Result** makes one call per provider: the prompt "Connection test. Reply with the single word OK.", at most 32 output tokens, effort low. It reports `connected to <model> in <n> ms`, or a hint, the kind, the HTTP status and the provider's own credential-free message.
|
||||
|
||||
The result goes on the row (`lastTest*`) and into `assistant_usage` (`TEST`), so a test's cost is counted like any other call.
|
||||
|
||||
## Cost
|
||||
|
||||
`cost = input × in + output × out + cacheRead × read + cacheWrite5m × w5 + cacheWrite1h × w1`, in micro-dollars, rounded up (`ModelPricing.costMicros`).
|
||||
|
||||
Prices come from the `assistant_model_price` row for (type, model). Empty cache prices are 0.1x, 1.25x and 2x the input price. A model without a row uses the `pricing` config map, else costs 0. A price change affects later calls only; each usage row keeps the cost it was recorded with.
|
||||
|
||||
## App home
|
||||
|
||||
One HTML widget (`assistantHome`) on the Assistant app. It is the only place this data is summarized: no other dashboard repeats it, and the developer panel stays per-record.
|
||||
|
||||
`AssistantHomeData` reads the tables as the viewing admin, at most `homeMaxRows` rows each, for the last 30 days (UTC) and the current month, and says when totals are partial. `AssistantHomeView` renders it in flow and grid layout only:
|
||||
- no tables, fixed widths or inner scroll areas
|
||||
- inherited text colors, so light and dark both work
|
||||
- a status row, one row per provider, then cards for usage by day, costs, users, tools and failures.
|
||||
|
||||
Every section has an empty state; nothing is invented.
|
||||
|
||||
The nav is the home plus the main tables: Providers, Conversations, Model prices, Policy. Messages, turns, tool calls, feedback and model calls are child lists on a conversation, and model calls also on a provider. Processes are actions on their tables.
|
||||
|
||||
## Strict tools (0.1.0-RC.5)
|
||||
|
||||
**Incident (kof22, 2026-10-13).** Every turn failed on its first model call with 400 `invalid_request_error`: "The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools."
|
||||
- The request sent all ten read tools with `strict: true`.
|
||||
- Anthropic compiles every strict schema in a request into one grammar.
|
||||
- The request was inside every documented limit: 20 strict tools, 24 optional parameters, 16 union parameters. The real limit is not published.
|
||||
- The log line only said `INVALID_REQUEST httpStatus=400`, so the reason was invisible.
|
||||
|
||||
**Decision.**
|
||||
- **No tool is strict by default.** `AssistantQBitConfig.strictToolNames` opts tools in.
|
||||
- Strict mode only guaranteed schema-valid input. The kernel already validates every input and returns `INVALID_INPUT` results, and the UI-API tools (below) go through the same validation as the UI.
|
||||
- If a provider still refuses strict schemas (`ModelProviderException.isStrictSchemaRejected`), the turn retries once with no strict tools and logs a WARN.
|
||||
|
||||
**Budget.**
|
||||
- `AnthropicRequestContractTest` fails the build when the strict tools of the default request exceed 3 tools, 6 optional parameters or 2 union parameters. These budgets are far below the shape that failed.
|
||||
- It also checks the documented request rules:
|
||||
- adaptive thinking without `budget_tokens`, and no sampling parameters;
|
||||
- a valid effort, and no forced `tool_choice`;
|
||||
- at most 4 cache breakpoints, with longer TTLs first;
|
||||
- a user-first, user-last message order;
|
||||
- strict-schema keywords.
|
||||
|
||||
**Real-API check (dev).** `ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am` runs the live golden evals. They send exactly this request shape.
|
||||
|
||||
**Diagnostics.** On a 4xx the provider's error type, sanitized message and request id are:
|
||||
- logged at WARN;
|
||||
- recorded as the `assistant_usage` request id;
|
||||
- shown to `assistant.admin` holders, in the panel's error and in Test connection.
|
||||
|
||||
## Owner decisions (2026-10-13)
|
||||
|
||||
Owner: *"the agent should have to go through the same API that the UIUX does. that way it is held to the same security boundaries etc"*, plus the ability to configure the assistant and verify its security and data access.
|
||||
|
||||
1. **Tools via the UI API.** Every tool that reads or writes data, or runs a process, report or widget, runs a spec'd `/qqq/v1` operation with the calling user's own session. That means the UI's authentication, permission checks, record locks, validation, audit and error mapping. There are no direct core data actions and no elevated sessions.
|
||||
2. **Data access is configured and verifiable in the UI.**
|
||||
- Each table and process has an AI data access setting: NONE, READ or READ_WRITE. Sensitive tables default to NONE.
|
||||
- Admins can see what the assistant could do for a given user, and why.
|
||||
3. **Writes need confirmation.** A write tool only *proposes* the change, as a pending action card. Nothing changes until the user confirms in the panel, unless the policy turns confirmation off.
|
||||
4. All other open questions in this section were decided as recommended (shared rule: "all recommendations accepted").
|
||||
|
||||
## Tools through the UI API (0.1.0-RC.6)
|
||||
|
||||
### Transport: loopback HTTP with the user's session
|
||||
|
||||
`UiApiGateway.invoke(UiApiCall)` makes an HTTP request to this server's own `/qqq/v1`, carrying the user's `sessionUUID` cookie and `X-QQQ-Invoked-By: assistant`.
|
||||
- Only operations defined by `MiddlewareVersionV1`'s endpoint specs can be called. Anything else, including the assistant's own raw routes, is refused before a request is made.
|
||||
- A call without a session is also refused.
|
||||
|
||||
**Why loopback rather than calling the spec handlers in process.** The spec's `buildInput(Context)`, session setup, the version's `preExecute`, Javalin filters, permission checks and error mapping all hang off the Javalin request pipeline.
|
||||
- Calling the executors directly would skip some of these and drift over time.
|
||||
- Building a synthetic Javalin `Context` relies on internal API.
|
||||
- A request to the server's own port runs the identical pipeline by construction. It costs one local hop, which is milliseconds next to a model call.
|
||||
|
||||
This matches core's design (`docs/UI-API-EXTENSIONS.md` §5, `UiApiInvoker`, QRun-IO/qqq#1128).
|
||||
- `LoopbackUiApiGateway` is the stand-in until a QQQ release has the invoker. Then an adapter over `UiApiInvoker.forServer(server)` replaces it (or a host injects one with `withUiApiGateway`), and nothing else changes.
|
||||
- The base URL is `uiApiBaseUrl` if configured. Otherwise it is `127.0.0.1` plus the local port the first assistant request arrived on, so in a multi-replica deployment the call stays on the replica that holds the session's process state.
|
||||
|
||||
### The tools
|
||||
|
||||
| tool | `/qqq/v1` operation |
|
||||
|---|---|
|
||||
| `list_tables` | `GET /metaData` (tables with the user's read/insert/edit/delete flags) |
|
||||
| `describe_table` | `GET /metaData/table/{tableName}` |
|
||||
| `query_records` | `POST /table/{tableName}/query` (plus `.../count` when there are more rows) |
|
||||
| `count_records` | `POST /table/{tableName}/count` |
|
||||
| `get_record` | `GET /table/{tableName}/{primaryKey}` |
|
||||
| `search_records` | `POST /search` |
|
||||
| `render_widget` | `POST /widget/{widgetName}` |
|
||||
| `get_record_history` | `GET /table/{tableName}/{primaryKey}`, then `POST /table/{tableName}/query` on `audit_table`, `audit` and `audit_detail` |
|
||||
| `create_record` / `update_record` / `delete_record` | `POST /table/{tableName}` / `PATCH /table/{tableName}/{primaryKey}` / `DELETE /table/{tableName}/{primaryKey}` |
|
||||
|
||||
`navigate` and `search_help` stay on the core kernel because they read no records:
|
||||
- `navigate` only builds an `/app` link, and the page enforces permissions when it opens.
|
||||
- `search_help` reads help text from metadata.
|
||||
|
||||
Running processes and reports through the assistant (`/processes/...`, `/reports/...`) is not offered yet. Process data access rows are stored so that the screens can already show and set them.
|
||||
|
||||
### Layers (each one can only narrow the one before)
|
||||
|
||||
1. **The user's own permissions and record locks**, enforced by the UI API itself. A user who cannot do something in the UI gets the identical status and message through the assistant: the tool result is `DENIED`/`NOT_FOUND`/`INVALID_INPUT` with `"<operation> answered HTTP <status>: <message>"`.
|
||||
2. **Data access** (`assistant_data_access`, editable by `assistant.admin`). A row sets NONE, READ or READ_WRITE per table (`TABLE`) or process (`PROCESS`). Without a row:
|
||||
- tables whose AI exposure is NONE (the `assistant_*` tables, sessions, OAuth, and any table whose metadata says so) are NONE, and a row cannot raise them;
|
||||
- other tables are READ, so the assistant never writes unless an admin allows it;
|
||||
- processes are NONE.
|
||||
The write tools are offered only when some table is READ_WRITE.
|
||||
3. **Policy** (`assistant_policy`):
|
||||
- `writesRequireConfirmation` (default true);
|
||||
- `allowedTools` (empty allows all);
|
||||
- `maxRowsPerCall` (default 50, ceiling 200);
|
||||
- `redactPii` (default true: secrets, card numbers and SSNs are redacted, with the core `AiRedactor`).
|
||||
4. **AI field exposure** from table metadata:
|
||||
- NONE and METADATA_ONLY values never reach the model;
|
||||
- MASKED values are masked;
|
||||
- only VALUES fields can be filtered or sorted on.
|
||||
|
||||
### Writes and confirmation
|
||||
|
||||
With confirmation on, a write tool validates the table's data access and the fields. It then inserts an `assistant_pending_action` row (owner-locked, `PENDING`, expiring after `pendingActionTtlSeconds`, default 15 minutes) and returns a `pending_action` render, which the panel shows as a card. The model is told the change is not done.
|
||||
- `POST /qqq/v1/assistant/actions/{id}/confirm` claims the action (`RUNNING`, so a double click cannot run it twice). It then runs the stored operation through the UI API **with the confirming request's session** and records the outcome as `EXECUTED` or `FAILED`.
|
||||
- `POST /qqq/v1/assistant/actions/{id}/reject` runs nothing.
|
||||
- `GET /qqq/v1/assistant/actions/{id}` reads the action's state.
|
||||
- Only the user who proposed an action can see or decide it.
|
||||
|
||||
### Tool-call log
|
||||
|
||||
Each `assistant_tool_call` row records:
|
||||
- the user and the tool;
|
||||
- the parameters, with secrets redacted;
|
||||
- the UI API operation and the HTTP status the UI would have seen;
|
||||
- the rows touched and the outcome;
|
||||
- the pending action, if there is one;
|
||||
- an in-app link to the written record, whose page shows its audit entries.
|
||||
|
||||
A confirmed write adds its own row. The rows are a child list on conversations, and pending actions are another one.
|
||||
|
||||
### Tests
|
||||
|
||||
`UiApiToolsSecurityTest` runs the fixture app on a real QQQ application server. For each case it makes the UI's own request (a direct `/qqq/v1` call with the user's cookie) next to the assistant's tool call:
|
||||
- read denied: the same 403 and message;
|
||||
- write denied without the insert permission, even with READ_WRITE and no confirmation;
|
||||
- data access only narrows: NONE, READ, and sensitive tables that cannot be raised;
|
||||
- a confirmed write runs once, through the UI API, and is logged;
|
||||
- only the owner can decide an action;
|
||||
- only spec'd operations can be called, and never without a session;
|
||||
- the policy narrows the tools and caps rows.
|
||||
|
||||
The golden and red-team evals now run through the UI API too.
|
||||
|
||||
## Next phases
|
||||
|
||||
- **(c) Data access screens:**
|
||||
- the Data Access table, in the nav;
|
||||
- an "Explain access" action (for a user, and a table or a prompt, which operations the assistant could call and which it would be denied, with the reason);
|
||||
- a recent-denials widget on the app home.
|
||||
- **(d) Spec'd assistant endpoints:** move `/qqq/v1/assistant/...` (conversations, turns, events, feedback, cancel, config, actions) onto `AbstractEndpointSpec`s through the qbit spec extension point (`MiddlewareSpecExtension`, established prefix `/assistant`, QRun-IO/qqq#1128). The SSE turn and events streams stay raw routes until the spec layer can describe a stream.
|
||||
@@ -98,6 +98,10 @@ CREATE TABLE assistant_tool_call
|
||||
result_summary VARCHAR(500),
|
||||
result_json TEXT,
|
||||
redactions_json TEXT,
|
||||
ui_api_operation VARCHAR(255),
|
||||
http_status INTEGER,
|
||||
audit_href VARCHAR(500),
|
||||
pending_action_id INTEGER,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX assistant_tool_call_user_id ON assistant_tool_call (user_id);
|
||||
@@ -134,6 +138,10 @@ CREATE TABLE assistant_policy
|
||||
instance_monthly_tokens_hard BIGINT,
|
||||
instance_monthly_cost_micros_soft BIGINT,
|
||||
instance_monthly_cost_micros_hard BIGINT,
|
||||
writes_require_confirmation BOOLEAN,
|
||||
allowed_tools VARCHAR(1000),
|
||||
max_rows_per_call INTEGER,
|
||||
redact_pii BOOLEAN,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
@@ -155,3 +163,126 @@ CREATE TABLE assistant_usage_daily
|
||||
);
|
||||
CREATE INDEX assistant_usage_daily_user_id ON assistant_usage_daily (user_id);
|
||||
CREATE INDEX assistant_usage_daily_date ON assistant_usage_daily (usage_date, scope);
|
||||
|
||||
-- 0.1.0-RC.4: provider settings, model prices and per-call usage.
|
||||
-- api_key_status is filled when a row is read (never written); the API key itself
|
||||
-- is never stored: api_key_env_name holds only the NAME of an environment variable.
|
||||
|
||||
CREATE TABLE assistant_provider
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
provider_type VARCHAR(50) NOT NULL,
|
||||
model VARCHAR(100),
|
||||
enabled BOOLEAN,
|
||||
is_default BOOLEAN,
|
||||
base_url VARCHAR(255),
|
||||
api_key_env_name VARCHAR(100),
|
||||
api_key_status VARCHAR(200),
|
||||
monthly_cost_cap_usd NUMERIC(12, 2),
|
||||
max_output_tokens_per_request INTEGER,
|
||||
max_concurrent_requests INTEGER,
|
||||
last_test_at TIMESTAMP WITH TIME ZONE,
|
||||
last_test_status VARCHAR(16),
|
||||
last_test_latency_ms INTEGER,
|
||||
last_test_model VARCHAR(100),
|
||||
last_test_error VARCHAR(500),
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE assistant_model_price
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider_type VARCHAR(50) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
input_usd_per_m_tok NUMERIC(12, 4) NOT NULL,
|
||||
output_usd_per_m_tok NUMERIC(12, 4) NOT NULL,
|
||||
cache_read_usd_per_m_tok NUMERIC(12, 4),
|
||||
cache_write5m_usd_per_m_tok NUMERIC(12, 4),
|
||||
cache_write1h_usd_per_m_tok NUMERIC(12, 4),
|
||||
notes VARCHAR(255),
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (provider_type, model)
|
||||
);
|
||||
|
||||
CREATE TABLE assistant_usage
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
conversation_id INTEGER,
|
||||
turn_id INTEGER,
|
||||
provider_id INTEGER,
|
||||
provider_type VARCHAR(50),
|
||||
model VARCHAR(100),
|
||||
purpose VARCHAR(16),
|
||||
status VARCHAR(16),
|
||||
error_kind VARCHAR(32),
|
||||
http_status INTEGER,
|
||||
latency_ms INTEGER,
|
||||
input_tokens BIGINT,
|
||||
output_tokens BIGINT,
|
||||
cache_read_tokens BIGINT,
|
||||
cache_write_tokens BIGINT,
|
||||
cost_micros BIGINT,
|
||||
request_id VARCHAR(100),
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX assistant_usage_create_date ON assistant_usage (create_date);
|
||||
CREATE INDEX assistant_usage_provider ON assistant_usage (provider_id, create_date);
|
||||
CREATE INDEX assistant_usage_conversation ON assistant_usage (conversation_id);
|
||||
CREATE INDEX assistant_usage_user_id ON assistant_usage (user_id);
|
||||
|
||||
-- Anthropic list prices, USD per million tokens (cached 2026-10-06). Cache writes:
|
||||
-- 1.25x input (5 minute) and 2x input (1 hour). Re-check before budgeting.
|
||||
INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok, cache_read_usd_per_m_tok, cache_write5m_usd_per_m_tok, cache_write1h_usd_per_m_tok, notes, create_date, modify_date)
|
||||
VALUES
|
||||
('anthropic', 'claude-sonnet-5-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-opus-5-5', 4.00, 20.00, 0.20, 5.00, 8.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-haiku-5-5', 0.10, 0.50, 0.01, 0.125, 0.20, 'Anthropic list price, 2026-10 (prompts up to 100K tokens)', NOW(), NOW()),
|
||||
('anthropic', 'claude-fable-5-1', 10.00, 50.00, 0.25, 12.50, 20.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-opus-5', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-sonnet-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-opus-4-8', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75, 6.00, 'Anthropic list price, 2026-10', NOW(), NOW()),
|
||||
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10', NOW(), NOW());
|
||||
|
||||
-- 0.1.0-RC.6: the assistant's data access per table / process, and writes waiting
|
||||
-- for the user's confirmation. Existing databases: add the tool-call and policy
|
||||
-- columns above with ALTER TABLE ... ADD COLUMN IF NOT EXISTS.
|
||||
CREATE TABLE assistant_data_access
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
object_type VARCHAR(16) NOT NULL,
|
||||
object_name VARCHAR(128) NOT NULL,
|
||||
mode VARCHAR(16) NOT NULL,
|
||||
notes VARCHAR(500),
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT assistant_data_access_object UNIQUE (object_type, object_name)
|
||||
);
|
||||
|
||||
CREATE TABLE assistant_pending_action
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER,
|
||||
turn_id INTEGER,
|
||||
user_id VARCHAR(255),
|
||||
tool_call_id VARCHAR(128),
|
||||
tool_name VARCHAR(64),
|
||||
ui_api_operation VARCHAR(255),
|
||||
table_name VARCHAR(128),
|
||||
record_id VARCHAR(128),
|
||||
params_json TEXT,
|
||||
summary VARCHAR(500),
|
||||
status VARCHAR(16),
|
||||
http_status INTEGER,
|
||||
result_summary VARCHAR(500),
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
decided_at TIMESTAMP WITH TIME ZONE,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX assistant_pending_action_user_id ON assistant_pending_action (user_id);
|
||||
CREATE INDEX assistant_pending_action_conversation_id ON assistant_pending_action (conversation_id);
|
||||
@@ -0,0 +1,12 @@
|
||||
# Assistant app home and provider settings screenshots
|
||||
|
||||
Taken 2026-10-13 against the local `AssistantDemoServer` (qbit-assistant-provider-anthropic test sources), never production.
|
||||
- Memory backend and the Next UI 1.0.0-RC.16 jar.
|
||||
- The Anthropic API is the recorded test server: real HTTP through the real adapter, engine, Test connection and usage recording, with canned answers and no key.
|
||||
|
||||
Light and dark, 390 and 1440 px:
|
||||
- `home-*`: the Assistant app home.
|
||||
- `provider-*`: the provider settings screen, with the masked key and the model-call child list.
|
||||
- `provider-not-set-*`: a provider whose key variable is not set.
|
||||
- `provider-edit-*`: the edit form.
|
||||
- `prices-*`: the model price table.
|
||||
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -264,6 +264,7 @@ public class AnthropicMapping
|
||||
.withStopReason(message.stopReason().map(s -> s.asString()).orElse(ModelResponse.STOP_END_TURN))
|
||||
.withUsage(toUsage(message.usage()))
|
||||
.withRequestId(message.id())
|
||||
.withModel(message.model().asString())
|
||||
.withProviderState(message.toParam()));
|
||||
}
|
||||
|
||||
@@ -312,8 +313,51 @@ public class AnthropicMapping
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map an SDK error to a provider error. The message holds the status and
|
||||
** the API error type only - never request or response bodies.
|
||||
** Whether a 400 message says the strict tool schemas are the problem:
|
||||
** "The compiled grammar is too large ... reduce the number of strict tools"
|
||||
** or "Schema is too complex for compilation".
|
||||
*******************************************************************************/
|
||||
static boolean isStrictSchemaMessage(String message)
|
||||
{
|
||||
if(message == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String lower = message.toLowerCase(java.util.Locale.ROOT);
|
||||
return (lower.contains("grammar") || lower.contains("too complex for compilation") || lower.contains("strict tool"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The API's own error message ({"error": {"message": ...}}), or null. The
|
||||
** caller sanitizes it; Anthropic's messages name parameters, not content.
|
||||
*******************************************************************************/
|
||||
static String errorMessage(AnthropicServiceException service)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, Object> body = service.body() == null ? null : service.body().convert(new TypeReference<Map<String, Object>>() {});
|
||||
if(body != null && body.get("error") instanceof Map<?, ?> error && error.get("message") instanceof String message)
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
//////////////////////////////////////////////
|
||||
// not JSON / not the documented error shape //
|
||||
//////////////////////////////////////////////
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map an SDK error to a provider error. The message holds the status, the
|
||||
** API error type and the API's own (sanitized) error message - never request
|
||||
** bodies, prompts or keys.
|
||||
*******************************************************************************/
|
||||
public static ModelProviderException toProviderException(Throwable throwable)
|
||||
{
|
||||
@@ -347,7 +391,11 @@ public class AnthropicMapping
|
||||
{
|
||||
kind = "overloaded_error".equals(type) ? ModelProviderException.Kind.OVERLOADED : ModelProviderException.Kind.OTHER;
|
||||
}
|
||||
return (new ModelProviderException(kind, status >= 400 ? status : null, "Anthropic API error" + (status >= 400 ? " (HTTP " + status + ")" : "") + (type == null ? "" : " " + type)));
|
||||
String apiMessage = errorMessage(service);
|
||||
String requestId = service.headers() == null ? null : service.headers().values("request-id").stream().findFirst().orElse(null);
|
||||
ModelProviderException exception = new ModelProviderException(kind, status >= 400 ? status : null, "Anthropic API error" + (status >= 400 ? " (HTTP " + status + ")" : "") + (type == null ? "" : " " + type)
|
||||
+ (apiMessage == null ? "" : ": " + ModelProviderException.sanitizeProviderMessage(apiMessage, ModelProviderException.MAX_PROVIDER_MESSAGE_CHARS)));
|
||||
return (exception.withProviderDetails(type, apiMessage, requestId).withStrictSchemaRejected(status == 400 && isStrictSchemaMessage(apiMessage)));
|
||||
}
|
||||
if(throwable instanceof AnthropicIoException)
|
||||
{
|
||||
|
||||
@@ -297,7 +297,21 @@ public class AnthropicModelProvider implements AssistantModelProvider
|
||||
if(!handle.isCancelled())
|
||||
{
|
||||
ModelProviderException exception = AnthropicMapping.toProviderException(t).withPartialUsage(seen.get());
|
||||
LOG.info("Anthropic stream failed", logPair("kind", exception.getKind()), logPair("httpStatus", exception.getHttpStatus()), logPair("model", request.getModel()));
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a 4xx is a request or account problem an operator must see: //
|
||||
// log the API's error type, its (sanitized) message and the //
|
||||
// request id - never the prompt //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
if(exception.getHttpStatus() != null && exception.getHttpStatus() >= 400 && exception.getHttpStatus() < 500)
|
||||
{
|
||||
LOG.warn("Anthropic stream failed", logPair("kind", exception.getKind()), logPair("httpStatus", exception.getHttpStatus()), logPair("model", request.getModel()),
|
||||
logPair("errorType", exception.getProviderErrorType()), logPair("errorMessage", exception.getProviderMessage()), logPair("requestId", exception.getRequestId()));
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.info("Anthropic stream failed", logPair("kind", exception.getKind()), logPair("httpStatus", exception.getHttpStatus()), logPair("model", request.getModel()),
|
||||
logPair("errorType", exception.getProviderErrorType()), logPair("requestId", exception.getRequestId()));
|
||||
}
|
||||
handle.fail(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.anthropic;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Anthropic (Claude) provider type for the assistant_provider settings
|
||||
** table. Register it with AssistantQBitConfig.withProviderType(new
|
||||
** AnthropicProviderType()). The key comes from the row's key variable
|
||||
** (default ANTHROPIC_API_KEY).
|
||||
*******************************************************************************/
|
||||
public class AnthropicProviderType implements AssistantProviderType
|
||||
{
|
||||
public static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
|
||||
|
||||
/*******************************************************************************
|
||||
** The current Claude models, the default (Sonnet 5.5) first.
|
||||
*******************************************************************************/
|
||||
public static final List<String> MODELS = List.of(
|
||||
"claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5", "claude-fable-5-1",
|
||||
"claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (AnthropicModelProvider.NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Anthropic (Claude)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (MODELS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return (AnthropicModelProvider.API_KEY_ENV);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return (DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider with the resolved key (passed as a literal, never logged).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
AnthropicModelProvider.Builder builder = AnthropicModelProvider.builder().apiKey(apiKey);
|
||||
if(baseUrl != null)
|
||||
{
|
||||
builder.baseUrl(baseUrl);
|
||||
}
|
||||
return (builder.build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -265,7 +265,10 @@ class AnthropicModelProviderTest
|
||||
{
|
||||
assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.AUTHENTICATION);
|
||||
assertThat(e.getHttpStatus()).isEqualTo(401);
|
||||
assertThat(e.getMessage()).doesNotContain(TEST_KEY).doesNotContain("invalid x-api-key");
|
||||
assertThat(e.getMessage()).doesNotContain(TEST_KEY).contains("authentication_error");
|
||||
assertThat(e.getProviderErrorType()).isEqualTo("authentication_error");
|
||||
assertThat(e.getProviderMessage()).isEqualTo("invalid x-api-key");
|
||||
assertThat(e.isStrictSchemaRejected()).isFalse();
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> provider.stream(request(), t -> { }).await(Duration.ofSeconds(10)))
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.anthropic;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Anthropic provider type with Test connection against a recorded HTTP
|
||||
** server (no live calls): the key from the row's variable reaches the API as
|
||||
** x-api-key, the result and the TEST usage row are saved, and a 401 becomes
|
||||
** an admin-readable error without the key.
|
||||
*******************************************************************************/
|
||||
class AnthropicProviderTypeTest
|
||||
{
|
||||
private static final String KEY = "sk-ant-test-0000000000000000-abcd";
|
||||
|
||||
private AssistantQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
if(config != null && config.getRuntime() != null)
|
||||
{
|
||||
config.getRuntime().close();
|
||||
}
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instance with the Anthropic type allowed to use the recorded server.
|
||||
*******************************************************************************/
|
||||
private QInstance instance(String baseUrl) throws Exception
|
||||
{
|
||||
config = new AssistantQBitConfig()
|
||||
.withProviderName(ScriptedModelProvider.DEFAULT_NAME)
|
||||
.withModelProvider(new ScriptedModelProvider())
|
||||
.withProviderType(new AnthropicProviderType())
|
||||
.withAllowedBaseUrls(Set.of(baseUrl))
|
||||
.withSecretResolver(Map.of(AnthropicModelProvider.API_KEY_ENV, KEY)::get);
|
||||
QInstance qInstance = AssistantTestFixture.build(config);
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantProvider insertProvider(String baseUrl) throws Exception
|
||||
{
|
||||
QRecord record = new InsertAction().execute(new InsertInput(AssistantProvider.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("name", "Claude").withValue("providerType", "anthropic").withValue("model", "claude-sonnet-5-5")
|
||||
.withValue("enabled", true).withValue("isDefault", true).withValue("baseUrl", baseUrl))).getRecords().get(0);
|
||||
assertThat(record.getErrors()).isEmpty();
|
||||
return (config.getRuntime().getStore().getProvider(record.getValueInteger("id")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A good connection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConnectionOk() throws Exception
|
||||
{
|
||||
try(RecordedAnthropicServer server = new RecordedAnthropicServer())
|
||||
{
|
||||
instance(server.baseUrl());
|
||||
server.thenStream("text-with-thinking.sse", 0);
|
||||
AssistantProvider row = insertProvider(server.baseUrl());
|
||||
assertThat(row.getApiKeyStatus()).isEqualTo("Set • …abcd (from ANTHROPIC_API_KEY)");
|
||||
|
||||
ConnectionTest.Result result = ConnectionTest.run(config.getRuntime(), row, AssistantTestFixture.ADMIN);
|
||||
assertThat(result.ok()).as(result.error()).isTrue();
|
||||
assertThat(result.model()).isEqualTo("claude-sonnet-5-5");
|
||||
assertThat(server.getRequests()).hasSize(1);
|
||||
assertThat(server.getRequests().get(0).get("apiKey")).isEqualTo(KEY);
|
||||
assertThat(server.getRequests().get(0).get("body")).contains("\"max_tokens\":" + ConnectionTest.PING_MAX_TOKENS);
|
||||
|
||||
AssistantProvider saved = config.getRuntime().getStore().getProvider(row.getId());
|
||||
assertThat(saved.getLastTestStatus()).isEqualTo("OK");
|
||||
assertThat(saved.getLastTestModel()).isEqualTo("claude-sonnet-5-5");
|
||||
|
||||
QRecord usage = new QueryAction().execute(new QueryInput(AssistantUsage.TABLE_NAME).withFilter(new QQueryFilter())).getRecords().get(0);
|
||||
assertThat(usage.getValueString("purpose")).isEqualTo("TEST");
|
||||
assertThat(usage.getValueString("status")).isEqualTo("OK");
|
||||
assertThat(usage.getValueLong("inputTokens")).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A refused key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConnectionUnauthorized() throws Exception
|
||||
{
|
||||
try(RecordedAnthropicServer server = new RecordedAnthropicServer())
|
||||
{
|
||||
instance(server.baseUrl());
|
||||
server.thenError(401, "unauthorized.json");
|
||||
AssistantProvider row = insertProvider(server.baseUrl());
|
||||
|
||||
ConnectionTest.Result result = ConnectionTest.run(config.getRuntime(), row, AssistantTestFixture.ADMIN);
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.error()).contains("API key was refused").contains("HTTP 401").doesNotContain(KEY);
|
||||
|
||||
AssistantProvider saved = config.getRuntime().getStore().getProvider(row.getId());
|
||||
assertThat(saved.getLastTestStatus()).isEqualTo("ERROR");
|
||||
assertThat(saved.getLastTestError()).doesNotContain(KEY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The type itself.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testType()
|
||||
{
|
||||
AnthropicProviderType type = new AnthropicProviderType();
|
||||
assertThat(type.getType()).isEqualTo("anthropic");
|
||||
assertThat(type.getModels().get(0)).isEqualTo("claude-sonnet-5-5");
|
||||
assertThat(type.getDefaultApiKeyEnvName()).isEqualTo("ANTHROPIC_API_KEY");
|
||||
assertThat(type.getDefaultBaseUrl()).isEqualTo("https://api.anthropic.com");
|
||||
assertThat(type.getLabel()).contains("Anthropic");
|
||||
assertThat(type.create(KEY, null).toString()).doesNotContain(KEY);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.anthropic;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.anthropic.core.ObjectMappers;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact Messages API request the turn engine sends, checked against the
|
||||
** API's documented constraints, plus a conservative budget for strict tools.
|
||||
**
|
||||
** Why the budget: Anthropic compiles every strict tool schema of a request
|
||||
** into one grammar and refuses the request when that grammar is too large
|
||||
** (400 invalid_request_error "The compiled grammar is too large ... reduce the
|
||||
** number of strict tools"). With all ten read tools strict (0.1.0-RC.3 to
|
||||
** RC.4) every kof22 turn failed that way, though the request stayed inside
|
||||
** the documented limits (20 strict tools, 24 optional parameters, 16 union
|
||||
** parameters). The real limit is not published, so the budget below is far
|
||||
** under the shape that failed (10 tools / 12 optional / 3 unions). A real-API
|
||||
** check is in docs/DESIGN.md ("Strict tools") for dev use with a key.
|
||||
*******************************************************************************/
|
||||
class AnthropicRequestContractTest
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// conservative strict-tool budget (see the class comment) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
static final int MAX_STRICT_TOOLS = 3;
|
||||
static final int MAX_STRICT_OPTIONAL_PARAMS = 6;
|
||||
static final int MAX_STRICT_UNION_PARAMS = 2;
|
||||
|
||||
private static final Set<String> EFFORTS = Set.of("low", "medium", "high", "xhigh", "max");
|
||||
private static final Set<String> UNSUPPORTED_STRICT = Set.of("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minLength", "maxLength",
|
||||
"maxItems", "uniqueItems", "oneOf", "not", "if", "then", "else", "patternProperties");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The request body for the fixture app, as the SDK serializes it.
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> requestBody(AssistantQBitConfig config) throws Exception
|
||||
{
|
||||
QContext.init(AssistantTestFixture.build(config), AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
ModelRequest request = config.getRuntime().getContextBuilder().build(QContext.getQSession(), config.getModels().getMainModel(), config.getModels().getMainEffort(),
|
||||
List.of(), "How many published posts are there?", Map.of("tableName", "blog_post"));
|
||||
String json = ObjectMappers.jsonMapper().writeValueAsString(AnthropicMapping.toParams(request)._body());
|
||||
return (JsonUtils.toObject(json, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default request meets every documented constraint and the budget.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDefaultRequestMeetsTheApiContract() throws Exception
|
||||
{
|
||||
Map<String, Object> body = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()));
|
||||
assertThat(violations(body)).isEmpty();
|
||||
assertThat(strictBudgetViolations(body)).isEmpty();
|
||||
assertThat(tools(body)).hasSizeGreaterThanOrEqualTo(8).allSatisfy(tool -> assertThat(tool.get("strict")).isNotEqualTo(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The RC.3 shape (every tool strict) is recognized as over budget, and a
|
||||
** host can still opt a few tools in.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStrictBudget() throws Exception
|
||||
{
|
||||
AssistantQBitConfig probe = new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider());
|
||||
Set<String> all = new LinkedHashSet<>();
|
||||
requestBody(probe);
|
||||
for(ModelToolDefinition tool : probe.getRuntime().getContextBuilder().tools())
|
||||
{
|
||||
all.add(tool.getName());
|
||||
}
|
||||
QContext.clear();
|
||||
|
||||
Map<String, Object> rc3 = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()).withStrictToolNames(all));
|
||||
assertThat(violations(rc3)).isEmpty();
|
||||
assertThat(strictBudgetViolations(rc3)).isNotEmpty();
|
||||
QContext.clear();
|
||||
|
||||
Map<String, Object> few = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()).withStrictToolNames(Set.of("get_record", "describe_table")));
|
||||
assertThat(violations(few)).isEmpty();
|
||||
assertThat(strictBudgetViolations(few)).isEmpty();
|
||||
assertThat(tools(few).stream().filter(t -> Boolean.TRUE.equals(t.get("strict"))).map(t -> t.get("name"))).containsExactlyInAnyOrder("get_record", "describe_table");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A 400 about the grammar is flagged as a strict-schema rejection, carries
|
||||
** the API's message and request id, and never the key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testGrammarRejectionIsRecognized() throws Exception
|
||||
{
|
||||
try(RecordedAnthropicServer server = new RecordedAnthropicServer())
|
||||
{
|
||||
server.thenError(400, "grammar-too-large.json");
|
||||
AnthropicModelProvider provider = AnthropicModelProvider.builder().apiKey("sk-ant-test-0000000000000000000000").baseUrl(server.baseUrl()).maxRetries(0).build();
|
||||
assertThatThrownBy(() -> provider.stream(AnthropicModelProviderTest.request(), t -> { }).await(Duration.ofSeconds(10)))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST);
|
||||
assertThat(e.isStrictSchemaRejected()).isTrue();
|
||||
assertThat(e.getProviderErrorType()).isEqualTo("invalid_request_error");
|
||||
assertThat(e.getProviderMessage()).startsWith("The compiled grammar is too large");
|
||||
assertThat(e.getMessage()).contains("compiled grammar").doesNotContain("sk-ant-test");
|
||||
});
|
||||
provider.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End to end: when the provider refuses strict schemas, the turn retries
|
||||
** once without strict tools and answers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTurnRetriesWithoutStrictTools() throws Exception
|
||||
{
|
||||
try(RecordedAnthropicServer server = new RecordedAnthropicServer())
|
||||
{
|
||||
server.thenError(400, "grammar-too-large.json").thenStream("text-with-thinking.sse", 0);
|
||||
AnthropicModelProvider provider = AnthropicModelProvider.builder().apiKey("sk-ant-test-0000000000000000000000").baseUrl(server.baseUrl()).maxRetries(0).build();
|
||||
AssistantQBitConfig config = new AssistantQBitConfig().withModelProvider(provider).withStrictToolNames(Set.of("get_record", "count_records", "query_records"));
|
||||
TurnRunner runner = new TurnRunner(AssistantTestFixture.build(config), config);
|
||||
|
||||
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "How many published posts are there?");
|
||||
assertThat(outcome.end().get("status")).isEqualTo("COMPLETE");
|
||||
assertThat(server.getRequests()).hasSize(2);
|
||||
assertThat(server.getRequests().get(0).get("body")).contains("\"strict\":true");
|
||||
assertThat(server.getRequests().get(1).get("body")).doesNotContain("\"strict\":true");
|
||||
provider.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Documented request constraints (Messages API, claude-sonnet-5-5 family).
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<String> violations(Map<String, Object> body)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(!(body.get("model") instanceof String model) || !model.matches("claude-[a-z]+-[0-9]+(-[0-9]+)?"))
|
||||
{
|
||||
errors.add("model id " + body.get("model"));
|
||||
}
|
||||
if(!(body.get("max_tokens") instanceof Number max) || max.intValue() < 1 || max.intValue() > 128_000)
|
||||
{
|
||||
errors.add("max_tokens " + body.get("max_tokens"));
|
||||
}
|
||||
for(String sampling : List.of("temperature", "top_p", "top_k"))
|
||||
{
|
||||
if(body.containsKey(sampling))
|
||||
{
|
||||
errors.add(sampling + " is rejected with thinking on current models");
|
||||
}
|
||||
}
|
||||
Map<String, Object> thinking = (Map<String, Object>) body.get("thinking");
|
||||
if(thinking != null && (!"adaptive".equals(thinking.get("type")) || thinking.containsKey("budget_tokens")))
|
||||
{
|
||||
errors.add("thinking must be adaptive without budget_tokens: " + thinking);
|
||||
}
|
||||
Map<String, Object> outputConfig = (Map<String, Object>) body.get("output_config");
|
||||
if(outputConfig != null && outputConfig.get("effort") != null && !EFFORTS.contains(outputConfig.get("effort")))
|
||||
{
|
||||
errors.add("effort " + outputConfig.get("effort"));
|
||||
}
|
||||
Map<String, Object> toolChoice = (Map<String, Object>) body.get("tool_choice");
|
||||
if(toolChoice != null && !"auto".equals(toolChoice.get("type")) && !"none".equals(toolChoice.get("type")))
|
||||
{
|
||||
errors.add("forced tool_choice is rejected: " + toolChoice);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// cache breakpoints: at most 4, longer TTLs before shorter ones //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
List<String> ttls = new ArrayList<>();
|
||||
collectCacheTtls(body.get("tools"), ttls);
|
||||
collectCacheTtls(body.get("system"), ttls);
|
||||
collectCacheTtls(body.get("messages"), ttls);
|
||||
if(ttls.size() > 4)
|
||||
{
|
||||
errors.add("more than 4 cache breakpoints: " + ttls);
|
||||
}
|
||||
for(int i = 1; i < ttls.size(); i++)
|
||||
{
|
||||
if("1h".equals(ttls.get(i)) && "5m".equals(ttls.get(i - 1)))
|
||||
{
|
||||
errors.add("a 1h cache entry after a 5m one: " + ttls);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) body.get("messages");
|
||||
if(messages == null || messages.isEmpty() || !"user".equals(messages.get(0).get("role")))
|
||||
{
|
||||
errors.add("messages must start with a user message");
|
||||
}
|
||||
else if(!"user".equals(messages.get(messages.size() - 1).get("role")))
|
||||
{
|
||||
errors.add("no assistant prefill on current models");
|
||||
}
|
||||
for(Map<String, Object> message : messages == null ? List.<Map<String, Object>>of() : messages)
|
||||
{
|
||||
for(Map<String, Object> block : (List<Map<String, Object>>) message.get("content"))
|
||||
{
|
||||
if("text".equals(block.get("type")) && (!(block.get("text") instanceof String text) || text.isBlank()))
|
||||
{
|
||||
errors.add("empty text block");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for(Map<String, Object> tool : tools(body))
|
||||
{
|
||||
String name = String.valueOf(tool.get("name"));
|
||||
if(!name.matches("[a-zA-Z0-9_-]{1,64}") || !names.add(name))
|
||||
{
|
||||
errors.add("tool name " + name);
|
||||
}
|
||||
Map<String, Object> schema = (Map<String, Object>) tool.get("input_schema");
|
||||
if(schema == null || !"object".equals(schema.get("type")))
|
||||
{
|
||||
errors.add(name + ": input_schema must be an object schema");
|
||||
continue;
|
||||
}
|
||||
if(Boolean.TRUE.equals(tool.get("strict")))
|
||||
{
|
||||
checkStrictSchema(name, schema, errors);
|
||||
}
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The strict-tool budget: count, optional parameters and unions across all
|
||||
** strict schemas.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<String> strictBudgetViolations(Map<String, Object> body)
|
||||
{
|
||||
int[] counts = new int[2];
|
||||
int strict = 0;
|
||||
for(Map<String, Object> tool : tools(body))
|
||||
{
|
||||
if(Boolean.TRUE.equals(tool.get("strict")))
|
||||
{
|
||||
strict++;
|
||||
countOptionalAndUnions(tool.get("input_schema"), counts);
|
||||
}
|
||||
}
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(strict > MAX_STRICT_TOOLS)
|
||||
{
|
||||
errors.add(strict + " strict tools (budget " + MAX_STRICT_TOOLS + ")");
|
||||
}
|
||||
if(counts[0] > MAX_STRICT_OPTIONAL_PARAMS)
|
||||
{
|
||||
errors.add(counts[0] + " optional parameters in strict tools (budget " + MAX_STRICT_OPTIONAL_PARAMS + ")");
|
||||
}
|
||||
if(counts[1] > MAX_STRICT_UNION_PARAMS)
|
||||
{
|
||||
errors.add(counts[1] + " union parameters in strict tools (budget " + MAX_STRICT_UNION_PARAMS + ")");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> tools(Map<String, Object> body)
|
||||
{
|
||||
return (body.get("tools") instanceof List<?> list ? (List<Map<String, Object>>) list : List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict schemas: additionalProperties false on every object, no
|
||||
** unsupported keywords, minItems 0 or 1 only, no recursive $ref.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void checkStrictSchema(String path, Object node, List<String> errors)
|
||||
{
|
||||
if(node instanceof Map<?, ?> map)
|
||||
{
|
||||
Map<String, Object> schema = (Map<String, Object>) map;
|
||||
if(("object".equals(schema.get("type")) || schema.containsKey("properties")) && !Boolean.FALSE.equals(schema.get("additionalProperties")))
|
||||
{
|
||||
errors.add(path + ": additionalProperties must be false");
|
||||
}
|
||||
for(String keyword : UNSUPPORTED_STRICT)
|
||||
{
|
||||
if(schema.containsKey(keyword))
|
||||
{
|
||||
errors.add(path + ": unsupported keyword " + keyword);
|
||||
}
|
||||
}
|
||||
if(schema.get("minItems") instanceof Number minItems && minItems.intValue() > 1)
|
||||
{
|
||||
errors.add(path + ": minItems above 1");
|
||||
}
|
||||
if(schema.get("$defs") instanceof Map<?, ?> defs)
|
||||
{
|
||||
for(Map.Entry<?, ?> def : defs.entrySet())
|
||||
{
|
||||
if(JsonUtils.toJson(def.getValue()).contains("\"#/$defs/" + def.getKey() + "\""))
|
||||
{
|
||||
errors.add(path + ": recursive $defs/" + def.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
schema.forEach((key, value) -> checkStrictSchema(path + "/" + key, value, errors));
|
||||
}
|
||||
else if(node instanceof List<?> list)
|
||||
{
|
||||
list.forEach(value -> checkStrictSchema(path, value, errors));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void countOptionalAndUnions(Object node, int[] counts)
|
||||
{
|
||||
if(node instanceof Map<?, ?> map)
|
||||
{
|
||||
Map<String, Object> schema = (Map<String, Object>) map;
|
||||
if(schema.get("properties") instanceof Map<?, ?> properties)
|
||||
{
|
||||
List<Object> required = schema.get("required") instanceof List<?> r ? (List<Object>) r : List.of();
|
||||
for(Object name : properties.keySet())
|
||||
{
|
||||
if(!required.contains(name))
|
||||
{
|
||||
counts[0]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(schema.get("type") instanceof List<?> || schema.containsKey("anyOf"))
|
||||
{
|
||||
counts[1]++;
|
||||
}
|
||||
schema.values().forEach(value -> countOptionalAndUnions(value, counts));
|
||||
}
|
||||
else if(node instanceof List<?> list)
|
||||
{
|
||||
list.forEach(value -> countOptionalAndUnions(value, counts));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void collectCacheTtls(Object node, List<String> ttls)
|
||||
{
|
||||
if(node instanceof Map<?, ?> map)
|
||||
{
|
||||
if(map.get("cache_control") instanceof Map<?, ?> cache)
|
||||
{
|
||||
ttls.add(cache.get("ttl") == null ? "5m" : String.valueOf(cache.get("ttl")));
|
||||
}
|
||||
for(Map.Entry<?, ?> entry : map.entrySet())
|
||||
{
|
||||
if(!"cache_control".equals(entry.getKey()) && !"input_schema".equals(entry.getKey()))
|
||||
{
|
||||
collectCacheTtls(entry.getValue(), ttls);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(node instanceof List<?> list)
|
||||
{
|
||||
list.forEach(value -> collectCacheTtls(value, ttls));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.anthropic.demo;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.anthropic.RecordedAnthropicServer;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Local demo of the Assistant app home and provider settings, for
|
||||
** screenshots (never against production). The Anthropic API is the
|
||||
** recorded test server, so every call is a real HTTP call through the real
|
||||
** adapter, engine, Test connection and usage recording - with canned
|
||||
** answers and no key. Everyone is an admin.
|
||||
**
|
||||
** Run with the test classpath plus the qqq-frontend-next jar; open
|
||||
** http://localhost:8099/ and the Assistant app.
|
||||
*******************************************************************************/
|
||||
public class AssistantDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
private static QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every browser session is an admin with the all-access key.
|
||||
*******************************************************************************/
|
||||
public static class DemoSessionCustomizer implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
QSession admin = AssistantTestFixture.sessionFor("admin-demo");
|
||||
qSession.setUser(admin.getUser());
|
||||
qSession.setPermissions(admin.getPermissions());
|
||||
qSession.withSecurityKeyValue(AssistantMetaDataBuilder.allAccessKeyName(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build, run real traffic against the recorded API, then serve.
|
||||
*******************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
int port = args.length > 0 ? Integer.parseInt(args[0]) : 8099;
|
||||
|
||||
RecordedAnthropicServer api = new RecordedAnthropicServer();
|
||||
AssistantQBitConfig config = new AssistantQBitConfig()
|
||||
.withProviderName(ScriptedModelProvider.DEFAULT_NAME)
|
||||
.withModelProvider(new ScriptedModelProvider())
|
||||
.withProviderType(new AnthropicProviderType())
|
||||
.withAllowedBaseUrls(Set.of(api.baseUrl()))
|
||||
.withAllowedApiKeyEnvNames(Set.of("ANTHROPIC_API_KEY_STAGING"))
|
||||
.withSecretResolver(Map.of(AnthropicModelProvider.API_KEY_ENV, "sk-ant-demo-not-a-real-key-7f3a")::get)
|
||||
.withAllowedWidgetNames(Set.of());
|
||||
instance = AssistantTestFixture.build(config);
|
||||
|
||||
QContext.init(instance, AssistantTestFixture.sessionFor("admin-1"));
|
||||
Integer production = insert(AssistantProvider.TABLE_NAME, Map.of("name", "Claude (production)", "providerType", "anthropic", "model", "claude-sonnet-5-5",
|
||||
"enabled", true, "isDefault", true, "baseUrl", api.baseUrl(), "monthlyCostCapUsd", new BigDecimal("25.00"),
|
||||
"maxOutputTokensPerRequest", 16000, "maxConcurrentRequests", 4));
|
||||
Integer staging = insert(AssistantProvider.TABLE_NAME, Map.of("name", "Claude (staging key)", "providerType", "anthropic", "model", "claude-haiku-5-5",
|
||||
"enabled", false, "isDefault", false, "apiKeyEnvName", "ANTHROPIC_API_KEY_STAGING"));
|
||||
for(Map.Entry<String, ModelPricing> price : ModelPricing.defaults().entrySet())
|
||||
{
|
||||
ModelPricing p = price.getValue();
|
||||
insert(AssistantModelPrice.TABLE_NAME, Map.of("providerType", "anthropic", "model", price.getKey(),
|
||||
"inputUsdPerMTok", BigDecimal.valueOf(p.getInputUsdPerMTok()), "outputUsdPerMTok", BigDecimal.valueOf(p.getOutputUsdPerMTok()),
|
||||
"cacheReadUsdPerMTok", BigDecimal.valueOf(p.getCacheReadUsdPerMTok()), "cacheWrite5mUsdPerMTok", BigDecimal.valueOf(p.getCacheWrite5mUsdPerMTok()),
|
||||
"cacheWrite1hUsdPerMTok", BigDecimal.valueOf(p.getCacheWrite1hUsdPerMTok()), "notes", "Anthropic list price, 2026-10"));
|
||||
}
|
||||
|
||||
api.thenStream("text-with-thinking.sse", 0);
|
||||
ConnectionTest.run(config.getRuntime(), config.getRuntime().getStore().getProvider(production), "admin-1");
|
||||
ConnectionTest.run(config.getRuntime(), config.getRuntime().getStore().getProvider(staging), "admin-1");
|
||||
QContext.clear();
|
||||
|
||||
TurnRunner runner = new TurnRunner(instance, config);
|
||||
api.thenStream("parallel-tools.sse", 0).thenStream("text-with-thinking.sse", 0);
|
||||
runner.run(AssistantTestFixture.EDITOR, "How many published posts are there, and what tables can I see?");
|
||||
api.thenStream("text-with-thinking.sse", 0);
|
||||
runner.run(AssistantTestFixture.EDITOR, "Summarize this week's page changes.");
|
||||
api.thenStream("parallel-tools.sse", 0).thenStream("text-with-thinking.sse", 0);
|
||||
runner.run(AssistantTestFixture.ANALYST, "What were the pageviews yesterday?");
|
||||
api.thenError(529, "overloaded.json").thenError(529, "overloaded.json").thenError(529, "overloaded.json");
|
||||
runner.run(AssistantTestFixture.OTHER_EDITOR, "Draft a post outline.");
|
||||
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
|
||||
instance.setHomeAppName(AssistantMetaDataBuilder.APP_NAME);
|
||||
|
||||
new QApplicationJavalinServer(new AssistantDemoServer()).withPort(port).withServeFrontendNext(true).start();
|
||||
System.out.println("Assistant demo on http://localhost:" + port + "/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer insert(String table, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
record.setValues(new LinkedHashMap<>(values));
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0);
|
||||
if(!inserted.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Demo insert failed: " + inserted.getErrors()));
|
||||
}
|
||||
return (inserted.getValueInteger("id"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"error","error":{"type":"invalid_request_error","message":"The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools."},"request_id":"req_test_grammar"}
|
||||
@@ -23,10 +23,13 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
@@ -55,6 +58,11 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
public static final String DEFAULT_SHORTCUT = "mod+j";
|
||||
public static final String DEFAULT_PROVIDER_NAME = "anthropic";
|
||||
|
||||
/*******************************************************************************
|
||||
** What an API key reference must look like: an environment variable name.
|
||||
*******************************************************************************/
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
/*******************************************************************************
|
||||
** The owner key type used before 0.1.0-RC.3 (camelCase). See UPGRADE.md.
|
||||
*******************************************************************************/
|
||||
@@ -88,11 +96,36 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
private Set<String> hiddenWidgetNames = new LinkedHashSet<>();
|
||||
private Set<String> allowedWidgetNames = new LinkedHashSet<>();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// tools sent with strict: true. Empty by default: Anthropic compiles all //
|
||||
// strict schemas of a request into one grammar and refuses the request //
|
||||
// (400 "compiled grammar is too large") with all ten read tools strict. //
|
||||
// The kernel validates every input anyway (INVALID_INPUT results). //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
private Set<String> strictToolNames = new LinkedHashSet<>();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// UI API: the data tools call /qqq/v1 on this server with the user's //
|
||||
// session. The base URL defaults to 127.0.0.1 and the port the first //
|
||||
// assistant request arrived on. A gateway can be injected (core's //
|
||||
// UiApiInvoker adapter, tests). //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
private String uiApiBaseUrl;
|
||||
private UiApiGateway uiApiGateway;
|
||||
private Integer pendingActionTtlSeconds = 900;
|
||||
private List<String> customRedactionPatterns = new ArrayList<>();
|
||||
private Integer killSwitchPollMillis = 2000;
|
||||
private Set<String> allowedApiKeyEnvNames = new LinkedHashSet<>();
|
||||
private Set<String> allowedBaseUrls = new LinkedHashSet<>();
|
||||
private Integer providerTestTimeoutSeconds = 30;
|
||||
private Integer homeMaxRows = 20000;
|
||||
private String tablePathPrefix = "/app";
|
||||
|
||||
private transient MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
private transient Map<String, AssistantModelProvider> modelProviders = new LinkedHashMap<>();
|
||||
private transient Map<String, AssistantProviderType> providerTypes = new LinkedHashMap<>();
|
||||
private transient Function<String, String> secretResolver = System::getenv;
|
||||
private transient AssistantRuntime runtime;
|
||||
|
||||
|
||||
@@ -154,6 +187,15 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "toolResultRetentionDays", toolResultRetentionDays);
|
||||
requirePositive(errors, "approvalRetentionDays", approvalRetentionDays);
|
||||
requirePositive(errors, "killSwitchPollMillis", killSwitchPollMillis);
|
||||
requirePositive(errors, "providerTestTimeoutSeconds", providerTestTimeoutSeconds);
|
||||
requirePositive(errors, "homeMaxRows", homeMaxRows);
|
||||
for(String name : allowedApiKeyEnvNames == null ? Set.<String>of() : allowedApiKeyEnvNames)
|
||||
{
|
||||
if(name == null || !ENV_NAME.matcher(name).matches())
|
||||
{
|
||||
errors.add("allowedApiKeyEnvNames: not an environment variable name: " + name);
|
||||
}
|
||||
}
|
||||
for(String pattern : customRedactionPatterns == null ? List.<String>of() : customRedactionPatterns)
|
||||
{
|
||||
try
|
||||
@@ -1105,6 +1147,131 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiBaseUrl (this server's root URL for loopback UI API calls)
|
||||
*******************************************************************************/
|
||||
public String getUiApiBaseUrl()
|
||||
{
|
||||
return (this.uiApiBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for uiApiBaseUrl
|
||||
*******************************************************************************/
|
||||
public void setUiApiBaseUrl(String uiApiBaseUrl)
|
||||
{
|
||||
this.uiApiBaseUrl = uiApiBaseUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uiApiBaseUrl
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withUiApiBaseUrl(String uiApiBaseUrl)
|
||||
{
|
||||
this.uiApiBaseUrl = uiApiBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiGateway (null: the loopback stand-in)
|
||||
*******************************************************************************/
|
||||
public UiApiGateway getUiApiGateway()
|
||||
{
|
||||
return (this.uiApiGateway);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for uiApiGateway
|
||||
*******************************************************************************/
|
||||
public void setUiApiGateway(UiApiGateway uiApiGateway)
|
||||
{
|
||||
this.uiApiGateway = uiApiGateway;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uiApiGateway
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withUiApiGateway(UiApiGateway uiApiGateway)
|
||||
{
|
||||
this.uiApiGateway = uiApiGateway;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pendingActionTtlSeconds (how long a proposed write waits for confirmation)
|
||||
*******************************************************************************/
|
||||
public Integer getPendingActionTtlSeconds()
|
||||
{
|
||||
return (this.pendingActionTtlSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for pendingActionTtlSeconds
|
||||
*******************************************************************************/
|
||||
public void setPendingActionTtlSeconds(Integer pendingActionTtlSeconds)
|
||||
{
|
||||
this.pendingActionTtlSeconds = pendingActionTtlSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pendingActionTtlSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withPendingActionTtlSeconds(Integer pendingActionTtlSeconds)
|
||||
{
|
||||
this.pendingActionTtlSeconds = pendingActionTtlSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for strictToolNames: the tools sent with strict schema adherence
|
||||
** (empty by default; see the field comment).
|
||||
*******************************************************************************/
|
||||
public Set<String> getStrictToolNames()
|
||||
{
|
||||
return (this.strictToolNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for strictToolNames
|
||||
*******************************************************************************/
|
||||
public void setStrictToolNames(Set<String> strictToolNames)
|
||||
{
|
||||
this.strictToolNames = strictToolNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for strictToolNames
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withStrictToolNames(Set<String> strictToolNames)
|
||||
{
|
||||
this.strictToolNames = strictToolNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedWidgetNames
|
||||
*******************************************************************************/
|
||||
@@ -1196,4 +1363,221 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register a provider type for the provider settings table.
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withProviderType(AssistantProviderType providerType)
|
||||
{
|
||||
if(providerType != null)
|
||||
{
|
||||
providerTypes.put(providerType.getType(), providerType);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registered provider types, by type id.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public Map<String, AssistantProviderType> getProviderTypes()
|
||||
{
|
||||
return (providerTypes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The environment variable names an assistant_provider row may use as its
|
||||
** API key reference: the configured allow-list plus each registered type's
|
||||
** default (e.g. ANTHROPIC_API_KEY). An admin can therefore never point a
|
||||
** provider at an unrelated secret such as a database password.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public Set<String> getEffectiveAllowedApiKeyEnvNames()
|
||||
{
|
||||
Set<String> names = new LinkedHashSet<>(allowedApiKeyEnvNames == null ? Set.of() : allowedApiKeyEnvNames);
|
||||
providerTypes.values().forEach(t -> names.add(t.getDefaultApiKeyEnvName()));
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a key reference (an environment variable name) to the secret.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public Function<String, String> getSecretResolver()
|
||||
{
|
||||
return (secretResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use another secret lookup than System.getenv (a host secret store; tests).
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withSecretResolver(Function<String, String> secretResolver)
|
||||
{
|
||||
this.secretResolver = secretResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedApiKeyEnvNames (extra API key environment variable names a provider row may reference)
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedApiKeyEnvNames()
|
||||
{
|
||||
return (this.allowedApiKeyEnvNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allowedApiKeyEnvNames
|
||||
*******************************************************************************/
|
||||
public void setAllowedApiKeyEnvNames(Set<String> allowedApiKeyEnvNames)
|
||||
{
|
||||
this.allowedApiKeyEnvNames = allowedApiKeyEnvNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedApiKeyEnvNames
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withAllowedApiKeyEnvNames(Set<String> allowedApiKeyEnvNames)
|
||||
{
|
||||
this.allowedApiKeyEnvNames = allowedApiKeyEnvNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedBaseUrls (base URL overrides a provider row may use; empty allows only the type's own URL)
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedBaseUrls()
|
||||
{
|
||||
return (this.allowedBaseUrls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allowedBaseUrls
|
||||
*******************************************************************************/
|
||||
public void setAllowedBaseUrls(Set<String> allowedBaseUrls)
|
||||
{
|
||||
this.allowedBaseUrls = allowedBaseUrls;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedBaseUrls
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withAllowedBaseUrls(Set<String> allowedBaseUrls)
|
||||
{
|
||||
this.allowedBaseUrls = allowedBaseUrls;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerTestTimeoutSeconds (how long Test connection waits for the model)
|
||||
*******************************************************************************/
|
||||
public Integer getProviderTestTimeoutSeconds()
|
||||
{
|
||||
return (this.providerTestTimeoutSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerTestTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public void setProviderTestTimeoutSeconds(Integer providerTestTimeoutSeconds)
|
||||
{
|
||||
this.providerTestTimeoutSeconds = providerTestTimeoutSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerTestTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withProviderTestTimeoutSeconds(Integer providerTestTimeoutSeconds)
|
||||
{
|
||||
this.providerTestTimeoutSeconds = providerTestTimeoutSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for homeMaxRows (rows per table the app home reads for one period)
|
||||
*******************************************************************************/
|
||||
public Integer getHomeMaxRows()
|
||||
{
|
||||
return (this.homeMaxRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for homeMaxRows
|
||||
*******************************************************************************/
|
||||
public void setHomeMaxRows(Integer homeMaxRows)
|
||||
{
|
||||
this.homeMaxRows = homeMaxRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for homeMaxRows
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withHomeMaxRows(Integer homeMaxRows)
|
||||
{
|
||||
this.homeMaxRows = homeMaxRows;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tablePathPrefix (the front of the links the app home makes to tables and records)
|
||||
*******************************************************************************/
|
||||
public String getTablePathPrefix()
|
||||
{
|
||||
return (this.tablePathPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tablePathPrefix
|
||||
*******************************************************************************/
|
||||
public void setTablePathPrefix(String tablePathPrefix)
|
||||
{
|
||||
this.tablePathPrefix = tablePathPrefix;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tablePathPrefix
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withTablePathPrefix(String tablePathPrefix)
|
||||
{
|
||||
this.tablePathPrefix = tablePathPrefix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -152,13 +154,20 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
}
|
||||
qInstance.addPossibleValueSource(builder.buildConversationPossibleValueSource());
|
||||
AssistantRecordScreens.buildPossibleValueSources().forEach(qInstance::addPossibleValueSource);
|
||||
AssistantRecordScreens.buildSettingsPossibleValueSources(qBitConfig).forEach(qInstance::addPossibleValueSource);
|
||||
AssistantRecordScreens.addConversationChildLists(qInstance);
|
||||
|
||||
QProcessMetaData retention = builder.buildRetentionProcess();
|
||||
retention.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(retention);
|
||||
|
||||
QAppMetaData app = builder.buildAdminApp(tables, retention);
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
|
||||
qInstance.addWidget(AssistantHome.widget());
|
||||
|
||||
QAppMetaData app = builder.buildAdminApp(tables);
|
||||
qInstance.addApp(app);
|
||||
|
||||
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
|
||||
@@ -201,7 +210,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
|
||||
LOG.info("Assistant QBit produced", logPair("version", VERSION), logPair("backendName", qBitConfig.getBackendName()), logPair("routes", routes), logPair("developerPanel", developerPanel),
|
||||
logPair("enabled", qBitConfig.isEffectivelyEnabled()), logPair("providerName", qBitConfig.getProviderName()),
|
||||
logPair("providerRegistered", qBitConfig.getActiveModelProvider() != null), logPair("mainModel", qBitConfig.getModels().getMainModel()));
|
||||
logPair("providerRegistered", qBitConfig.getActiveModelProvider() != null), logPair("providerTypes", qBitConfig.getProviderTypes().keySet()),logPair("mainModel", qBitConfig.getModels().getMainModel()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,19 +19,27 @@ package com.kingsrook.qbits.assistant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RateLimiter;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -46,9 +54,14 @@ public class AssistantRuntime
|
||||
private final Clock clock;
|
||||
private final AssistantStore store;
|
||||
private final PolicyService policyService;
|
||||
private final ProviderService providerService;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final AiToolKernel kernel;
|
||||
private final ContextBuilder contextBuilder;
|
||||
private final DataAccessService dataAccessService;
|
||||
private final UiApiToolbox toolbox;
|
||||
private final UiApiGateway uiApiGateway;
|
||||
private volatile String localBaseUrl;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Map<Integer, RunningTurn> runningTurns = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -74,9 +87,71 @@ public class AssistantRuntime
|
||||
this.clock = clock;
|
||||
this.store = new AssistantStore();
|
||||
this.policyService = new PolicyService(config, store, clock);
|
||||
this.providerService = new ProviderService(config, store, clock);
|
||||
this.rateLimiter = new RateLimiter(clock);
|
||||
this.kernel = new AiToolKernel(qInstance, kernelSettings(config));
|
||||
this.contextBuilder = new ContextBuilder(config, kernel);
|
||||
AiToolKernelSettings settings = kernelSettings(config);
|
||||
this.kernel = new AiToolKernel(qInstance, settings);
|
||||
AiExposurePolicy exposurePolicy = new AiExposurePolicy(settings);
|
||||
this.dataAccessService = new DataAccessService(qInstance, config, store, exposurePolicy, clock);
|
||||
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
|
||||
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
|
||||
this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The server's own root URL for UI API loopback calls: the configured
|
||||
** uiApiBaseUrl, else the address the first assistant request arrived on
|
||||
** (127.0.0.1 and this server's local port).
|
||||
*******************************************************************************/
|
||||
public String uiApiBaseUrl()
|
||||
{
|
||||
return (StringUtils.hasContent(config.getUiApiBaseUrl()) ? config.getUiApiBaseUrl() : localBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remember this server's local port (from a request) for loopback calls.
|
||||
*******************************************************************************/
|
||||
public void learnLocalPort(int port)
|
||||
{
|
||||
if(port > 0 && localBaseUrl == null)
|
||||
{
|
||||
localBaseUrl = "http://127.0.0.1:" + port;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dataAccessService
|
||||
*******************************************************************************/
|
||||
public DataAccessService getDataAccessService()
|
||||
{
|
||||
return (dataAccessService);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for toolbox (the UI API data tools)
|
||||
*******************************************************************************/
|
||||
public UiApiToolbox getToolbox()
|
||||
{
|
||||
return (toolbox);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiGateway
|
||||
*******************************************************************************/
|
||||
public UiApiGateway getUiApiGateway()
|
||||
{
|
||||
return (uiApiGateway);
|
||||
}
|
||||
|
||||
|
||||
@@ -271,4 +346,14 @@ public class AssistantRuntime
|
||||
return (executor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerService
|
||||
*******************************************************************************/
|
||||
public ProviderService getProviderService()
|
||||
{
|
||||
return (providerService);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.access;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the assistant may do with a table or process, on top of (never instead
|
||||
** of) the user's own permissions: NONE, READ (read tools), READ_WRITE (read
|
||||
** and write tools; processes: run).
|
||||
*******************************************************************************/
|
||||
public enum DataAccessMode
|
||||
{
|
||||
NONE("None"),
|
||||
READ("Read"),
|
||||
READ_WRITE("Read and write");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
DataAccessMode(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether read tools may use the object.
|
||||
*******************************************************************************/
|
||||
public boolean allowsRead()
|
||||
{
|
||||
return (this != NONE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether write tools may use the object.
|
||||
*******************************************************************************/
|
||||
public boolean allowsWrite()
|
||||
{
|
||||
return (this == READ_WRITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a stored value (null or unknown: null).
|
||||
*******************************************************************************/
|
||||
public static DataAccessMode parse(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(DataAccessMode mode : values())
|
||||
{
|
||||
if(mode.name().equalsIgnoreCase(value.trim()))
|
||||
{
|
||||
return (mode);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.access;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The assistant's data access per table and process: the admin's
|
||||
** `assistant_data_access` row, else the default.
|
||||
**
|
||||
** Defaults:
|
||||
** - tables whose AI exposure is NONE (the `assistant_*` tables, sessions,
|
||||
** OAuth, and any table its metadata marks NONE): NONE;
|
||||
** - other tables: READ (the assistant never writes unless an admin says so);
|
||||
** - processes: NONE.
|
||||
**
|
||||
** The mode only narrows: every call still runs through the UI API with the
|
||||
** user's own session.
|
||||
*******************************************************************************/
|
||||
public class DataAccessService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(DataAccessService.class);
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final AssistantQBitConfig config;
|
||||
private final AssistantStore store;
|
||||
private final AiExposurePolicy exposurePolicy;
|
||||
private final Clock clock;
|
||||
|
||||
private volatile Map<String, AssistantDataAccess> rows;
|
||||
private volatile Instant loadedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public DataAccessService(QInstance qInstance, AssistantQBitConfig config, AssistantStore store, AiExposurePolicy exposurePolicy, Clock clock)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.exposurePolicy = exposurePolicy;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A decision and where it came from.
|
||||
*******************************************************************************/
|
||||
public record Decision(DataAccessMode mode, String source)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mode for a table (unknown table: NONE).
|
||||
*******************************************************************************/
|
||||
public Decision forTable(String tableName)
|
||||
{
|
||||
QTableMetaData table = tableName == null ? null : qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
return (new Decision(DataAccessMode.NONE, "unknown table"));
|
||||
}
|
||||
|
||||
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName));
|
||||
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
|
||||
AiExposure exposure = exposurePolicy.getTableExposure(table);
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a table whose AI exposure is NONE stays NONE: its metadata says //
|
||||
// its values must never reach a model, whatever the row says //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(exposure == AiExposure.NONE)
|
||||
{
|
||||
return (new Decision(DataAccessMode.NONE, set != null && set != DataAccessMode.NONE ? "sensitive table (AI exposure NONE; the setting cannot raise it)" : "sensitive table (AI exposure NONE)"));
|
||||
}
|
||||
if(set != null)
|
||||
{
|
||||
return (new Decision(set, "setting"));
|
||||
}
|
||||
return (new Decision(DataAccessMode.READ, "default"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mode for a process (unknown process: NONE; default NONE).
|
||||
*******************************************************************************/
|
||||
public Decision forProcess(String processName)
|
||||
{
|
||||
if(processName == null || qInstance.getProcess(processName) == null)
|
||||
{
|
||||
return (new Decision(DataAccessMode.NONE, "unknown process"));
|
||||
}
|
||||
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_PROCESS, processName));
|
||||
DataAccessMode set = row == null ? null : DataAccessMode.parse(row.getMode());
|
||||
return (set == null ? new Decision(DataAccessMode.NONE, "default") : new Decision(set, "setting"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cache (after a change to the table).
|
||||
*******************************************************************************/
|
||||
public void invalidate()
|
||||
{
|
||||
rows = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows, cached for policyCacheSeconds. A read failure keeps the last
|
||||
** rows; with none, every table falls back to its default.
|
||||
*******************************************************************************/
|
||||
private Map<String, AssistantDataAccess> rows()
|
||||
{
|
||||
Map<String, AssistantDataAccess> current = rows;
|
||||
long ttl = config.getPolicyCacheSeconds() == null ? 30 : config.getPolicyCacheSeconds();
|
||||
if(current != null && loadedAt != null && loadedAt.plusSeconds(ttl).isAfter(clock.instant()))
|
||||
{
|
||||
return (current);
|
||||
}
|
||||
try
|
||||
{
|
||||
Map<String, AssistantDataAccess> loaded = new HashMap<>();
|
||||
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
|
||||
{
|
||||
loaded.put(key(row.getObjectType(), row.getObjectName()), row);
|
||||
}
|
||||
rows = loaded;
|
||||
loadedAt = clock.instant();
|
||||
return (loaded);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not read the assistant data access settings; using the defaults", e);
|
||||
return (current == null ? Map.of() : current);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String key(String type, String name)
|
||||
{
|
||||
return ((type == null ? AssistantDataAccess.TYPE_TABLE : type) + ":" + name);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,24 +19,33 @@ package com.kingsrook.qbits.assistant.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
|
||||
import com.kingsrook.qbits.assistant.engine.MessageParts;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEngine;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
@@ -96,7 +105,7 @@ public class AssistantApi
|
||||
{
|
||||
reason = "NO_PERMISSION";
|
||||
}
|
||||
else if(config.getActiveModelProvider() == null)
|
||||
else if(activeProvider() == null)
|
||||
{
|
||||
reason = "NOT_CONFIGURED";
|
||||
}
|
||||
@@ -111,9 +120,9 @@ public class AssistantApi
|
||||
result.put("reason", reason);
|
||||
}
|
||||
result.put("shortcut", config.getShortcut());
|
||||
result.put("model", reason == null ? runtime.getPolicyService().current(false).mainModel() : config.getModels().getMainModel());
|
||||
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
|
||||
result.put("limits", Map.of("maxTurnSeconds", config.getMaxTurnSeconds(), "maxToolCallsPerTurn", config.getMaxToolCallsPerTurn()));
|
||||
result.put("features", Map.of("writes", false));
|
||||
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true));
|
||||
return (result);
|
||||
}
|
||||
|
||||
@@ -267,17 +276,23 @@ public class AssistantApi
|
||||
}
|
||||
Map<String, Object> pageContext = body.get("pageContext") instanceof Map<?, ?> m ? ContextBuilder.sanitizePageContext((Map<String, Object>) m) : Map.of();
|
||||
|
||||
if(config.getActiveModelProvider() == null)
|
||||
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
|
||||
ProviderService.Active active = runtime.getProviderService().resolve(policy.mainModel());
|
||||
if(active == null)
|
||||
{
|
||||
throw (new AssistantApiException(503, "NOT_CONFIGURED", "The assistant is not configured on this server."));
|
||||
}
|
||||
|
||||
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
|
||||
if(policy.killSwitch())
|
||||
{
|
||||
throw (new AssistantApiException(503, "KILL_SWITCH", "The assistant has been turned off by an administrator."));
|
||||
}
|
||||
|
||||
Long capMicros = ProviderService.capMicros(active.row());
|
||||
if(capMicros != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= capMicros)
|
||||
{
|
||||
throw (new AssistantApiException(429, "BUDGET", "The assistant has reached this provider's monthly cost cap. Ask an administrator."));
|
||||
}
|
||||
|
||||
PolicyService.BudgetStatus budget = runtime.getPolicyService().checkBudget(policy, userId, 0, 0);
|
||||
if(budget.hardExceeded())
|
||||
{
|
||||
@@ -326,7 +341,7 @@ public class AssistantApi
|
||||
.withUserId(userId)
|
||||
.withUserMessageId(userMessage.getId())
|
||||
.withStatus("RUNNING")
|
||||
.withModel(policy.mainModel())
|
||||
.withModel(active.model())
|
||||
.withStartedAt(runtime.getClock().instant())
|
||||
.withToolCallCount(0)
|
||||
.withModelCallCount(0));
|
||||
@@ -338,7 +353,7 @@ public class AssistantApi
|
||||
.withStatus("STREAMING")
|
||||
.withTurnId(turn.getId())
|
||||
.withSeq(seq + 1)
|
||||
.withModel(policy.mainModel())
|
||||
.withModel(active.model())
|
||||
.withPartsJson("[]"));
|
||||
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord().withValue("id", userMessage.getId()).withValue("turnId", turn.getId()));
|
||||
@@ -526,6 +541,164 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /actions/{id}: one of the user's pending actions.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> getAction(Integer id) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
return (toJson(expireIfDue(requireAction(id))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /actions/{id}/confirm: run the proposed write through the UI API
|
||||
** with THIS request's session (the user's own), then record the result and
|
||||
** a tool-call log row. A write is never run twice.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> confirmAction(Integer id) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
AssistantPendingAction action = expireIfDue(requireAction(id));
|
||||
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
|
||||
{
|
||||
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// claim it first, so a double click cannot run the write twice //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
Instant now = runtime.getClock().instant();
|
||||
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "RUNNING").withValue("decidedAt", now));
|
||||
AssistantPendingAction claimed = store.getPendingAction(userId, id);
|
||||
if(claimed == null || !"RUNNING".equals(claimed.getStatus()))
|
||||
{
|
||||
throw (new AssistantApiException(409, "This action is already being handled."));
|
||||
}
|
||||
|
||||
Map<String, Object> values;
|
||||
try
|
||||
{
|
||||
values = action.getParamsJson() == null ? Map.of() : JsonUtils.toObject(action.getParamsJson(), new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("The stored values of action " + id + " are not valid JSON", e));
|
||||
}
|
||||
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
|
||||
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null);
|
||||
UiApiToolbox.Outcome outcome = runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
|
||||
AiToolResult result = outcome.getResult();
|
||||
boolean ok = result.getOutcome() == AiToolOutcome.OK;
|
||||
|
||||
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord()
|
||||
.withValue("id", id)
|
||||
.withValue("status", ok ? AssistantPendingAction.STATUS_EXECUTED : AssistantPendingAction.STATUS_FAILED)
|
||||
.withValue("httpStatus", outcome.getHttpStatus())
|
||||
.withValue("resultSummary", truncate(result.getSummary(), 500)));
|
||||
|
||||
store.insert(new AssistantToolCall()
|
||||
.withTurnId(action.getTurnId())
|
||||
.withConversationId(action.getConversationId())
|
||||
.withUserId(userId)
|
||||
.withToolCallId(action.getToolCallId())
|
||||
.withToolName(action.getToolName())
|
||||
.withInputJson(truncate(new AiRedactor(List.of()).redact(action.getParamsJson() == null ? "{}" : action.getParamsJson(), new HashSet<>()), 4000))
|
||||
.withOutcome(result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name())
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
.withTableName(action.getTableName())
|
||||
.withRowCount(result.getRowCount())
|
||||
.withDurationMs(result.getDurationMs())
|
||||
.withResultSummary(truncate("Confirmed: " + result.getSummary(), 500))
|
||||
.withResultJson(result.getModelContent())
|
||||
.withUiApiOperation(outcome.getOperation())
|
||||
.withHttpStatus(outcome.getHttpStatus())
|
||||
.withAuditHref(outcome.getAuditHref())
|
||||
.withPendingActionId(id));
|
||||
|
||||
Map<String, Object> rs = toJson(store.getPendingAction(userId, id));
|
||||
rs.put("outcome", result.getOutcome() == null ? null : result.getOutcome().name());
|
||||
rs.put("message", result.getModelContent());
|
||||
if(outcome.getAuditHref() != null)
|
||||
{
|
||||
rs.put("href", outcome.getAuditHref());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /actions/{id}/reject: nothing runs.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> rejectAction(Integer id) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
AssistantPendingAction action = expireIfDue(requireAction(id));
|
||||
if(!AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()))
|
||||
{
|
||||
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
|
||||
}
|
||||
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", AssistantPendingAction.STATUS_REJECTED)
|
||||
.withValue("decidedAt", runtime.getClock().instant()).withValue("resultSummary", "Cancelled by the user; nothing changed."));
|
||||
return (toJson(store.getPendingAction(userId, id)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantPendingAction requireAction(Integer id) throws QException, AssistantApiException
|
||||
{
|
||||
AssistantPendingAction action = id == null ? null : store.getPendingAction(userId, id);
|
||||
if(action == null)
|
||||
{
|
||||
throw (new AssistantApiException(404, "Action not found."));
|
||||
}
|
||||
return (action);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark a pending action past its expiry as EXPIRED.
|
||||
*******************************************************************************/
|
||||
private AssistantPendingAction expireIfDue(AssistantPendingAction action) throws QException
|
||||
{
|
||||
if(AssistantPendingAction.STATUS_PENDING.equals(action.getStatus()) && action.getExpiresAt() != null && action.getExpiresAt().isBefore(runtime.getClock().instant()))
|
||||
{
|
||||
store.updateFields(AssistantPendingAction.TABLE_NAME, new QRecord().withValue("id", action.getId()).withValue("status", AssistantPendingAction.STATUS_EXPIRED)
|
||||
.withValue("resultSummary", "Expired before it was confirmed; nothing changed."));
|
||||
action.setStatus(AssistantPendingAction.STATUS_EXPIRED);
|
||||
action.setResultSummary("Expired before it was confirmed; nothing changed.");
|
||||
}
|
||||
return (action);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> toJson(AssistantPendingAction action)
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", action.getId());
|
||||
map.put("status", action.getStatus());
|
||||
map.put("toolName", action.getToolName());
|
||||
map.put("tableName", action.getTableName());
|
||||
map.put("recordId", action.getRecordId());
|
||||
map.put("summary", action.getSummary());
|
||||
map.put("httpStatus", action.getHttpStatus());
|
||||
map.put("resultSummary", action.getResultSummary());
|
||||
map.put("expiresAt", action.getExpiresAt() == null ? null : action.getExpiresAt().toString());
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured turn time limit (replay loops are capped by it).
|
||||
*******************************************************************************/
|
||||
@@ -657,4 +830,14 @@ public class AssistantApi
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider answers would come from now, or null.
|
||||
*******************************************************************************/
|
||||
private ProviderService.Active activeProvider() throws QException
|
||||
{
|
||||
return (runtime.getProviderService().resolve(runtime.getPolicyService().current(false).mainModel()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -127,6 +127,9 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
|
||||
ctx.status(202);
|
||||
return (api.cancelTurn(pathInt(ctx, "turnId")));
|
||||
}));
|
||||
config.routes.get(BASE_PATH + "/actions/{id}", ctx -> json(ctx, false, (api, body) -> api.getAction(pathInt(ctx, "id"))));
|
||||
config.routes.post(BASE_PATH + "/actions/{id}/confirm", ctx -> json(ctx, true, (api, body) -> api.confirmAction(pathInt(ctx, "id"))));
|
||||
config.routes.post(BASE_PATH + "/actions/{id}/reject", ctx -> json(ctx, true, (api, body) -> api.rejectAction(pathInt(ctx, "id"))));
|
||||
config.routes.post(BASE_PATH + "/messages/{id}/feedback", ctx -> json(ctx, true, (api, body) ->
|
||||
{
|
||||
api.feedback(pathInt(ctx, "id"), body);
|
||||
@@ -383,6 +386,17 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
throw (new AssistantApiException(404, "The assistant is not installed."));
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the tools call /qqq/v1 on this server: remember its local port //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
runtime.learnLocalPort(context.req().getLocalPort());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.debug("Could not read the local port", e);
|
||||
}
|
||||
QSession session = ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
return (new AssistantApi(runtime, session));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelMessage;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test connection: one minimal live call to a provider row's model (a short
|
||||
** prompt, at most PING_MAX_TOKENS output tokens, low effort). The result
|
||||
** (status, latency, the model that answered, error text) is saved on the
|
||||
** row, and the call is recorded in assistant_usage with purpose TEST.
|
||||
** Error text comes from the provider exception, which never carries the key
|
||||
** or the request.
|
||||
*******************************************************************************/
|
||||
public final class ConnectionTest
|
||||
{
|
||||
public static final int PING_MAX_TOKENS = 32;
|
||||
public static final String PING_PROMPT = "Connection test. Reply with the single word OK.";
|
||||
|
||||
public static final String STATUS_OK = "OK";
|
||||
public static final String STATUS_ERROR = "ERROR";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a test found.
|
||||
*******************************************************************************/
|
||||
public record Result(String status, Integer latencyMs, String model, String error)
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean ok()
|
||||
{
|
||||
return (STATUS_OK.equals(status));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ConnectionTest()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test one provider row and save the result on it.
|
||||
*******************************************************************************/
|
||||
public static Result run(AssistantRuntime runtime, AssistantProvider row, String userId) throws QException
|
||||
{
|
||||
Result result = call(runtime, row, userId);
|
||||
runtime.getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord()
|
||||
.withValue("id", row.getId())
|
||||
.withValue("lastTestAt", runtime.getClock().instant())
|
||||
.withValue("lastTestStatus", result.status())
|
||||
.withValue("lastTestLatencyMs", result.latencyMs())
|
||||
.withValue("lastTestModel", result.model())
|
||||
.withValue("lastTestError", truncate(result.error(), 500)));
|
||||
runtime.getProviderService().invalidate();
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make the call (and record its usage).
|
||||
*******************************************************************************/
|
||||
static Result call(AssistantRuntime runtime, AssistantProvider row, String userId)
|
||||
{
|
||||
ProviderService.Active active;
|
||||
try
|
||||
{
|
||||
active = runtime.getProviderService().forRow(row, runtime.getConfig().getModels().getMainModel());
|
||||
}
|
||||
catch(ProviderService.ProviderSetupException e)
|
||||
{
|
||||
return (new Result(STATUS_ERROR, null, null, e.getMessage()));
|
||||
}
|
||||
|
||||
List<ModelMessage> messages = new ArrayList<>();
|
||||
messages.add(ModelMessage.user(PING_PROMPT));
|
||||
ModelRequest request = new ModelRequest()
|
||||
.withModel(active.model())
|
||||
.withEffort("low")
|
||||
.withMaxTokens(PING_MAX_TOKENS)
|
||||
.withMessages(messages);
|
||||
|
||||
UsageRecorder.Call usageCall = new UsageRecorder.Call(userId, null, null, active, UsageRecorder.PURPOSE_TEST);
|
||||
ModelPricing pricing = runtime.getProviderService().pricing(active.providerType(), active.model());
|
||||
long started = System.nanoTime();
|
||||
try
|
||||
{
|
||||
StreamHandle handle = active.provider().stream(request, text ->
|
||||
{
|
||||
// the reply text is not used
|
||||
});
|
||||
ModelResponse response = handle.await(Duration.ofSeconds(runtime.getConfig().getProviderTestTimeoutSeconds()));
|
||||
long latency = (System.nanoTime() - started) / 1_000_000;
|
||||
UsageRecorder.save(runtime.getStore(), UsageRecorder.row(usageCall, response, null, latency, pricing));
|
||||
if(ModelResponse.STOP_CANCELLED.equals(response.getStopReason()))
|
||||
{
|
||||
return (new Result(STATUS_ERROR, (int) latency, null, "The call was cancelled."));
|
||||
}
|
||||
return (new Result(STATUS_OK, (int) latency, response.getModel() != null ? response.getModel() : active.model(), null));
|
||||
}
|
||||
catch(ModelProviderException e)
|
||||
{
|
||||
long latency = (System.nanoTime() - started) / 1_000_000;
|
||||
UsageRecorder.save(runtime.getStore(), UsageRecorder.row(usageCall, null, e, latency, pricing));
|
||||
return (new Result(STATUS_ERROR, (int) latency, null, describe(e)));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
return (new Result(STATUS_ERROR, null, null, "The test was interrupted."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An admin-readable error: the kind, the HTTP status and the provider's
|
||||
** (credential-free) message.
|
||||
*******************************************************************************/
|
||||
static String describe(ModelProviderException e)
|
||||
{
|
||||
String hint = switch(e.getKind())
|
||||
{
|
||||
case AUTHENTICATION -> "The API key was refused. Check the key in the secret store.";
|
||||
case RATE_LIMITED -> "Rate limited by the provider. Try again shortly.";
|
||||
case OVERLOADED -> "The provider is overloaded. Try again shortly.";
|
||||
case INVALID_REQUEST -> "The provider refused the request. Check the model id.";
|
||||
case TIMEOUT -> "No answer in time.";
|
||||
case NETWORK -> "Could not reach the provider. Check the base URL and the network.";
|
||||
case SERVER -> "The provider had a server error.";
|
||||
default -> "The call failed.";
|
||||
};
|
||||
return (hint + " (" + e.getKind() + (e.getHttpStatus() == null ? "" : ", HTTP " + e.getHttpStatus()) + (e.getMessage() == null ? "" : ": " + e.getMessage()) + ")");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,10 +20,12 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.provider.CacheHint;
|
||||
@@ -32,9 +34,9 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
|
||||
import com.kingsrook.qbits.assistant.provider.SystemBlock;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKind;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@@ -51,12 +53,20 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class ContextBuilder
|
||||
{
|
||||
public static final Set<AiToolKind> PHASE1_KINDS = Set.of(AiToolKind.READ, AiToolKind.NAVIGATION);
|
||||
public static final int MAX_CONTEXT_CHARS = 512;
|
||||
public static final int MAX_SELECTED_IDS = 100;
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// tools that read no records stay on the core kernel: navigate only //
|
||||
// builds an /app link (the page enforces permission when it opens) //
|
||||
// and search_help reads help text from metadata. Every tool that //
|
||||
// reads or writes data is a UI API tool (UiApiToolbox). //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
public static final Set<String> KERNEL_TOOLS = Set.of("navigate", "search_help");
|
||||
|
||||
public static final int MAX_CONTEXT_CHARS = 512;
|
||||
public static final int MAX_SELECTED_IDS = 100;
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
private final AiToolKernel kernel;
|
||||
private final UiApiToolbox toolbox;
|
||||
private volatile String appDigest;
|
||||
|
||||
|
||||
@@ -64,44 +74,83 @@ public class ContextBuilder
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel)
|
||||
public ContextBuilder(AssistantQBitConfig config, AiToolKernel kernel, UiApiToolbox toolbox)
|
||||
{
|
||||
this.config = config;
|
||||
this.kernel = kernel;
|
||||
this.toolbox = toolbox;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The phase-1 tools (read + navigation), in the kernel's stable order.
|
||||
** The tools with the default policy.
|
||||
*******************************************************************************/
|
||||
public List<ModelToolDefinition> tools()
|
||||
{
|
||||
List<ModelToolDefinition> tools = new ArrayList<>();
|
||||
for(AiToolDefinition definition : kernel.getToolDefinitions())
|
||||
{
|
||||
if(definition.getKind() != null && PHASE1_KINDS.contains(definition.getKind()))
|
||||
{
|
||||
tools.add(new ModelToolDefinition()
|
||||
.withName(definition.getName())
|
||||
.withDescription(definition.getDescription())
|
||||
.withInputSchema(definition.getInputSchema())
|
||||
.withStrict(!Boolean.FALSE.equals(definition.getStrict())));
|
||||
}
|
||||
}
|
||||
return (tools);
|
||||
return (tools(PolicyService.ToolPolicy.defaults()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the phase-1 tools.
|
||||
** The tools a policy allows, sorted by name (a stable cache prefix): the UI
|
||||
** API data tools (write tools only when some table allows writes) plus the
|
||||
** kernel's navigate and search_help.
|
||||
*******************************************************************************/
|
||||
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
|
||||
{
|
||||
Map<String, ModelToolDefinition> byName = new TreeMap<>();
|
||||
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
|
||||
{
|
||||
byName.put(definition.getName(), definition.withStrict(isStrict(definition.getName(), true)));
|
||||
}
|
||||
for(AiToolDefinition definition : kernel.getToolDefinitions())
|
||||
{
|
||||
if(KERNEL_TOOLS.contains(definition.getName()) && (policy == null || policy.allows(definition.getName())))
|
||||
{
|
||||
byName.put(definition.getName(), new ModelToolDefinition()
|
||||
.withName(definition.getName())
|
||||
.withDescription(definition.getDescription())
|
||||
.withInputSchema(definition.getInputSchema())
|
||||
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(byName.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict only when the host names the tool in strictToolNames: the provider
|
||||
** compiles every strict schema of a request into one grammar, which it
|
||||
** refuses when it grows too large.
|
||||
*******************************************************************************/
|
||||
boolean isStrict(String toolName, boolean strictCapable)
|
||||
{
|
||||
return (strictCapable && config.getStrictToolNames() != null && config.getStrictToolNames().contains(toolName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the tools a policy allows.
|
||||
*******************************************************************************/
|
||||
public Set<String> toolNames(PolicyService.ToolPolicy policy)
|
||||
{
|
||||
Set<String> names = new HashSet<>();
|
||||
tools(policy).forEach(t -> names.add(t.getName()));
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the tools with the default policy.
|
||||
*******************************************************************************/
|
||||
public Set<String> toolNames()
|
||||
{
|
||||
Set<String> names = new java.util.HashSet<>();
|
||||
tools().forEach(t -> names.add(t.getName()));
|
||||
return (names);
|
||||
return (toolNames(PolicyService.ToolPolicy.defaults()));
|
||||
}
|
||||
|
||||
|
||||
@@ -126,12 +175,22 @@ public class ContextBuilder
|
||||
** Build the first request of a turn.
|
||||
*******************************************************************************/
|
||||
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext)
|
||||
{
|
||||
return (build(session, model, effort, history, userText, pageContext, PolicyService.ToolPolicy.defaults()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The request, with the tools the policy allows.
|
||||
*******************************************************************************/
|
||||
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy)
|
||||
{
|
||||
ModelRequest request = new ModelRequest()
|
||||
.withModel(model)
|
||||
.withEffort(effort)
|
||||
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
|
||||
.withTools(tools());
|
||||
.withTools(tools(toolPolicy));
|
||||
|
||||
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
|
||||
request.getSystem().add(new SystemBlock(userBlock(session), CacheHint.SHORT));
|
||||
|
||||
@@ -66,6 +66,15 @@ public class ModelPricing implements Serializable
|
||||
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
|
||||
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
|
||||
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
|
||||
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
|
||||
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
|
||||
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
|
||||
map.put("claude-opus-4-8", of(5.0, 25.0, 0.1));
|
||||
map.put("claude-opus-4-7", of(5.0, 25.0, 0.1));
|
||||
map.put("claude-opus-4-6", of(5.0, 25.0, 0.1));
|
||||
map.put("claude-sonnet-5", of(2.0, 10.0, 0.1));
|
||||
map.put("claude-sonnet-4-6", of(3.0, 15.0, 0.1));
|
||||
map.put("claude-haiku-4-5", of(1.0, 5.0, 0.1));
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantBudgets;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
@@ -53,8 +55,79 @@ public class PolicyService
|
||||
/*******************************************************************************
|
||||
** The resolved policy values.
|
||||
*******************************************************************************/
|
||||
public record EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets)
|
||||
public record EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets, ToolPolicy tools)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Without tool settings (the defaults).
|
||||
*******************************************************************************/
|
||||
public EffectivePolicy(boolean killSwitch, String killSwitchReason, String mainModel, String mainEffort, AssistantBudgets budgets)
|
||||
{
|
||||
this(killSwitch, killSwitchReason, mainModel, mainEffort, budgets, ToolPolicy.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy row's tool settings, with their defaults.
|
||||
**
|
||||
** @param writesRequireConfirmation every write waits for the user's
|
||||
** confirmation card (default true)
|
||||
** @param allowedTools tool names allowed; empty allows every tool
|
||||
** @param maxRowsPerCall most rows one tool call returns to the model
|
||||
** @param redactPii redact secrets and personal identifiers from tool
|
||||
** results (default true)
|
||||
*******************************************************************************/
|
||||
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
|
||||
{
|
||||
public static final int DEFAULT_MAX_ROWS_PER_CALL = 50;
|
||||
public static final int MAX_ROWS_PER_CALL_CEILING = 200;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The defaults.
|
||||
*******************************************************************************/
|
||||
public static ToolPolicy defaults()
|
||||
{
|
||||
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a tool is allowed by name.
|
||||
*******************************************************************************/
|
||||
public boolean allows(String toolName)
|
||||
{
|
||||
return (allowedTools == null || allowedTools.isEmpty() || allowedTools.contains(toolName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From a policy row (null: the defaults).
|
||||
*******************************************************************************/
|
||||
public static ToolPolicy of(AssistantPolicy row)
|
||||
{
|
||||
if(row == null)
|
||||
{
|
||||
return (defaults());
|
||||
}
|
||||
Set<String> allowed = new LinkedHashSet<>();
|
||||
if(StringUtils.hasContent(row.getAllowedTools()))
|
||||
{
|
||||
for(String name : row.getAllowedTools().split("[,\\s]+"))
|
||||
{
|
||||
if(StringUtils.hasContent(name))
|
||||
{
|
||||
allowed.add(name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall());
|
||||
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +181,7 @@ public class PolicyService
|
||||
String mainModel = row != null && StringUtils.hasContent(row.getMainModel()) ? row.getMainModel() : config.getModels().getMainModel();
|
||||
String mainEffort = row != null && StringUtils.hasContent(row.getMainEffort()) ? row.getMainEffort() : config.getModels().getMainEffort();
|
||||
|
||||
return (new EffectivePolicy(row != null && Boolean.TRUE.equals(row.getKillSwitch()), row == null ? null : row.getKillSwitchReason(), mainModel, mainEffort, budgets));
|
||||
return (new EffectivePolicy(row != null && Boolean.TRUE.equals(row.getKillSwitch()), row == null ? null : row.getKillSwitchReason(), mainModel, mainEffort, budgets, ToolPolicy.of(row)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Which provider and model answer, from the assistant_provider settings rows,
|
||||
** and what a call costs, from the assistant_model_price rows.
|
||||
**
|
||||
** - The active provider is the enabled row marked default (lowest id), else
|
||||
** the lowest enabled row. With no enabled rows, the provider registered in
|
||||
** the QBit config (withModelProvider / providerName) is used, so hosts
|
||||
** that have not created a row keep working.
|
||||
** - A row's API key is never stored: apiKeyEnvName names an environment
|
||||
** variable (resolved through config.getSecretResolver()). Only names in
|
||||
** the allow-list may be used, and a base URL override must be allowed in
|
||||
** the config, so an admin cannot send another secret, or the key, to a
|
||||
** host of their choosing.
|
||||
** - Built providers are cached by row settings and a SHA-256 fingerprint of
|
||||
** the key, so a rotated key builds a new client. Keys are never logged.
|
||||
*******************************************************************************/
|
||||
public class ProviderService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ProviderService.class);
|
||||
|
||||
public static final String KEY_STATUS_FIELD = "apiKeyStatus";
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
private final AssistantStore store;
|
||||
private final Clock clock;
|
||||
|
||||
private final Map<String, AssistantModelProvider> built = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, Semaphore> concurrency = new ConcurrentHashMap<>();
|
||||
|
||||
private volatile List<AssistantProvider> cachedRows;
|
||||
private volatile Map<String, ModelPricing> cachedPrices;
|
||||
private volatile Instant cachedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider a turn uses. row is null when the config provider is used.
|
||||
*******************************************************************************/
|
||||
public record Active(AssistantProvider row, String providerType, String model, AssistantModelProvider provider)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The row id, or null.
|
||||
*******************************************************************************/
|
||||
public Integer providerId()
|
||||
{
|
||||
return (row == null ? null : row.getId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider row that cannot be used (the message is safe to show).
|
||||
*******************************************************************************/
|
||||
public static class ProviderSetupException extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ProviderSetupException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ProviderService(AssistantQBitConfig config, AssistantStore store, Clock clock)
|
||||
{
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The active provider for a model choice from the policy (used when the row
|
||||
** has no model), or null when nothing usable is configured.
|
||||
*******************************************************************************/
|
||||
public Active resolve(String policyModel) throws QException
|
||||
{
|
||||
AssistantProvider row = activeRow();
|
||||
if(row == null)
|
||||
{
|
||||
AssistantModelProvider provider = config.getActiveModelProvider();
|
||||
return (provider == null ? null : new Active(null, provider.getName(), policyModel, provider));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return (forRow(row, policyModel));
|
||||
}
|
||||
catch(ProviderSetupException e)
|
||||
{
|
||||
LOG.warn("The default assistant provider cannot be used", e, logPair("providerId", row.getId()));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default enabled provider row, or null.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider activeRow() throws QException
|
||||
{
|
||||
AssistantProvider first = null;
|
||||
for(AssistantProvider row : rows())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getIsDefault()))
|
||||
{
|
||||
return (row);
|
||||
}
|
||||
if(first == null)
|
||||
{
|
||||
first = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (first);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build (or reuse) the provider of one row. Throws a ProviderSetupException
|
||||
** with a message for the admin when the row cannot be used.
|
||||
*******************************************************************************/
|
||||
public Active forRow(AssistantProvider row, String fallbackModel) throws ProviderSetupException
|
||||
{
|
||||
List<String> errors = validate(row);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new ProviderSetupException(String.join(" ", errors)));
|
||||
}
|
||||
|
||||
AssistantProviderType type = config.getProviderTypes().get(row.getProviderType());
|
||||
String key = resolveKey(row);
|
||||
if(!StringUtils.hasContent(key))
|
||||
{
|
||||
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
|
||||
}
|
||||
|
||||
String model = StringUtils.hasContent(row.getModel()) ? row.getModel() : (StringUtils.hasContent(fallbackModel) ? fallbackModel : first(type.getModels()));
|
||||
String baseUrl = StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null;
|
||||
String cacheKey = row.getId() + "|" + row.getProviderType() + "|" + baseUrl + "|" + fingerprint(key);
|
||||
AssistantModelProvider provider = built.computeIfAbsent(cacheKey, k -> type.create(key, baseUrl));
|
||||
return (new Active(row, row.getProviderType(), model, provider));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What is wrong with a row's settings (empty when it can be used, apart
|
||||
** from the key being present). Messages never contain a key.
|
||||
*******************************************************************************/
|
||||
public List<String> validate(AssistantProvider row)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
AssistantProviderType type = row.getProviderType() == null ? null : config.getProviderTypes().get(row.getProviderType());
|
||||
if(type == null)
|
||||
{
|
||||
errors.add("Provider type " + row.getProviderType() + " is not installed on this server.");
|
||||
return (errors);
|
||||
}
|
||||
|
||||
String envName = keyEnvName(row);
|
||||
if(!AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
|
||||
{
|
||||
errors.add("API key variable must be the NAME of an environment variable (capital letters, digits and _), not the key itself.");
|
||||
}
|
||||
else if(!config.getEffectiveAllowedApiKeyEnvNames().contains(envName))
|
||||
{
|
||||
errors.add("API key variable " + envName + " is not allowed on this server. Allowed: " + String.join(", ", config.getEffectiveAllowedApiKeyEnvNames()) + ".");
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(row.getBaseUrl()))
|
||||
{
|
||||
String url = row.getBaseUrl().trim();
|
||||
if(!url.equals(type.getDefaultBaseUrl()) && (config.getAllowedBaseUrls() == null || !config.getAllowedBaseUrls().contains(url)))
|
||||
{
|
||||
errors.add("Base URL " + url + " is not allowed on this server; leave it empty to use " + type.getDefaultBaseUrl() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
if(row.getMonthlyCostCapUsd() != null && row.getMonthlyCostCapUsd().signum() < 0)
|
||||
{
|
||||
errors.add("Monthly cost cap must not be negative.");
|
||||
}
|
||||
if(row.getMaxOutputTokensPerRequest() != null && row.getMaxOutputTokensPerRequest() < 1)
|
||||
{
|
||||
errors.add("Max output tokens per request must be at least 1.");
|
||||
}
|
||||
if(row.getMaxConcurrentRequests() != null && row.getMaxConcurrentRequests() < 1)
|
||||
{
|
||||
errors.add("Max concurrent requests must be at least 1.");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The masked key status shown on the settings screen: whether the key is
|
||||
** set and its last four characters, never more.
|
||||
*******************************************************************************/
|
||||
public String keyStatus(AssistantProvider row)
|
||||
{
|
||||
String envName = keyEnvName(row);
|
||||
if(!AssistantQBitConfig.ENV_NAME.matcher(envName).matches() || !config.getEffectiveAllowedApiKeyEnvNames().contains(envName))
|
||||
{
|
||||
return ("Not usable: the reference is not an allowed environment variable name");
|
||||
}
|
||||
String key = resolveKey(row);
|
||||
if(!StringUtils.hasContent(key))
|
||||
{
|
||||
return ("Not set (" + envName + " is empty)");
|
||||
}
|
||||
return (mask(key) + " (from " + envName + ")");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Set • …abcd" for a key (only the last four characters, and none for a
|
||||
** key shorter than 12).
|
||||
*******************************************************************************/
|
||||
public static String mask(String key)
|
||||
{
|
||||
if(key == null || key.isEmpty())
|
||||
{
|
||||
return ("Not set");
|
||||
}
|
||||
String trimmed = key.trim();
|
||||
return (trimmed.length() < 12 ? "Set" : "Set • …" + trimmed.substring(trimmed.length() - 4));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The key variable of a row (its own, else the type's default).
|
||||
*******************************************************************************/
|
||||
public String keyEnvName(AssistantProvider row)
|
||||
{
|
||||
if(StringUtils.hasContent(row.getApiKeyEnvName()))
|
||||
{
|
||||
return (row.getApiKeyEnvName().trim());
|
||||
}
|
||||
AssistantProviderType type = row.getProviderType() == null ? null : config.getProviderTypes().get(row.getProviderType());
|
||||
return (type == null ? "" : type.getDefaultApiKeyEnvName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The key of a row (only for allowed variable names), or null.
|
||||
*******************************************************************************/
|
||||
private String resolveKey(AssistantProvider row)
|
||||
{
|
||||
String envName = keyEnvName(row);
|
||||
if(!AssistantQBitConfig.ENV_NAME.matcher(envName).matches() || !config.getEffectiveAllowedApiKeyEnvNames().contains(envName))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
String value = config.getSecretResolver() == null ? null : config.getSecretResolver().apply(envName);
|
||||
return (value == null ? null : value.trim());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not resolve an assistant API key reference", logPair("envName", envName));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The concurrency gate of a provider row (null: unlimited).
|
||||
*******************************************************************************/
|
||||
public Semaphore gate(AssistantProvider row)
|
||||
{
|
||||
if(row == null || row.getMaxConcurrentRequests() == null || row.getMaxConcurrentRequests() < 1)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
int limit = row.getMaxConcurrentRequests();
|
||||
return (concurrency.compute(row.getId(), (id, existing) -> limitOf(existing) == limit ? existing : new LimitedSemaphore(limit)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int limitOf(Semaphore semaphore)
|
||||
{
|
||||
return (semaphore instanceof LimitedSemaphore limited ? limited.limit : -1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A semaphore that remembers its size (so a changed limit makes a new one).
|
||||
*******************************************************************************/
|
||||
private static class LimitedSemaphore extends Semaphore
|
||||
{
|
||||
private final int limit;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
LimitedSemaphore(int limit)
|
||||
{
|
||||
super(limit, true);
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The monthly cost cap of a row in micro-dollars, or null.
|
||||
*******************************************************************************/
|
||||
public static Long capMicros(AssistantProvider row)
|
||||
{
|
||||
if(row == null || row.getMonthlyCostCapUsd() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (row.getMonthlyCostCapUsd().multiply(BigDecimal.valueOf(1_000_000)).setScale(0, RoundingMode.DOWN).longValueExact());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a provider has cost since the start of the month (UTC), in
|
||||
** micro-dollars, from the usage rows the session can see.
|
||||
*******************************************************************************/
|
||||
public long monthToDateCostMicros(Integer providerId) throws QException
|
||||
{
|
||||
if(providerId == null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
Instant monthStart = LocalDate.now(clock.withZone(ZoneOffset.UTC)).withDayOfMonth(1).atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
return (store.sumUsageCostSince(providerId, monthStart));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The price of a model: the assistant_model_price row for the type and
|
||||
** model, else the config price table, else zero.
|
||||
*******************************************************************************/
|
||||
public ModelPricing pricing(String providerType, String model)
|
||||
{
|
||||
try
|
||||
{
|
||||
ModelPricing row = prices().get(priceKey(providerType, model));
|
||||
if(row != null)
|
||||
{
|
||||
return (row);
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.warn("Could not read the assistant price table; using the configured prices", e);
|
||||
}
|
||||
ModelPricing configured = config.getPricing() == null || model == null ? null : config.getPricing().get(model);
|
||||
return (configured == null ? new ModelPricing() : configured);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A price row as ModelPricing (cache prices default to the standard
|
||||
** multipliers of the input price when empty).
|
||||
*******************************************************************************/
|
||||
public static ModelPricing toPricing(AssistantModelPrice price)
|
||||
{
|
||||
double input = d(price.getInputUsdPerMTok());
|
||||
return (new ModelPricing()
|
||||
.withInputUsdPerMTok(input)
|
||||
.withOutputUsdPerMTok(d(price.getOutputUsdPerMTok()))
|
||||
.withCacheReadUsdPerMTok(price.getCacheReadUsdPerMTok() == null ? input * 0.1 : d(price.getCacheReadUsdPerMTok()))
|
||||
.withCacheWrite5mUsdPerMTok(price.getCacheWrite5mUsdPerMTok() == null ? input * 1.25 : d(price.getCacheWrite5mUsdPerMTok()))
|
||||
.withCacheWrite1hUsdPerMTok(price.getCacheWrite1hUsdPerMTok() == null ? input * 2.0 : d(price.getCacheWrite1hUsdPerMTok())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cached rows and prices (after a settings change).
|
||||
*******************************************************************************/
|
||||
public void invalidate()
|
||||
{
|
||||
cachedAt = null;
|
||||
cachedRows = null;
|
||||
cachedPrices = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider rows (cached for policyCacheSeconds).
|
||||
*******************************************************************************/
|
||||
public List<AssistantProvider> rows() throws QException
|
||||
{
|
||||
refresh();
|
||||
return (cachedRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, ModelPricing> prices() throws QException
|
||||
{
|
||||
refresh();
|
||||
return (cachedPrices);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private synchronized void refresh() throws QException
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
if(cachedAt != null && cachedRows != null && cachedPrices != null && cachedAt.plusSeconds(config.getPolicyCacheSeconds()).isAfter(now))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<AssistantProvider> rows = store.listProviders();
|
||||
Map<String, ModelPricing> prices = new ConcurrentHashMap<>();
|
||||
for(AssistantModelPrice price : store.listPrices())
|
||||
{
|
||||
if(price.getModel() != null && price.getInputUsdPerMTok() != null && price.getOutputUsdPerMTok() != null)
|
||||
{
|
||||
prices.put(priceKey(price.getProviderType(), price.getModel()), toPricing(price));
|
||||
}
|
||||
}
|
||||
cachedRows = rows;
|
||||
cachedPrices = prices;
|
||||
cachedAt = now;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String priceKey(String providerType, String model)
|
||||
{
|
||||
return ((providerType == null ? "" : providerType) + "|" + model);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of a key (hex), for the client cache key only.
|
||||
*******************************************************************************/
|
||||
static String fingerprint(String key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is not available", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String first(List<String> list)
|
||||
{
|
||||
return (list == null || list.isEmpty() ? null : list.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static double d(BigDecimal value)
|
||||
{
|
||||
return (value == null ? 0 : value.doubleValue());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -35,6 +36,7 @@ import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
@@ -49,10 +51,12 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -89,6 +93,8 @@ public class TurnEngine implements Runnable
|
||||
private static final QLogger LOG = QLogger.getLogger(TurnEngine.class);
|
||||
|
||||
private static final int MAX_INPUT_JSON_CHARS = 4000;
|
||||
|
||||
private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of());
|
||||
private static final int MAX_RESULT_JSON_CHARS = 64_000;
|
||||
private static final int TEXT_FLUSH_CHARS = 48;
|
||||
private static final long TEXT_FLUSH_MILLIS = 120;
|
||||
@@ -123,6 +129,11 @@ public class TurnEngine implements Runnable
|
||||
private int modelCallCount = 0;
|
||||
private long costMicros = 0;
|
||||
|
||||
private ProviderService.Active active;
|
||||
private String model;
|
||||
private Long capMicros;
|
||||
private long monthCostAtStart = 0;
|
||||
|
||||
private volatile boolean finished = false;
|
||||
|
||||
|
||||
@@ -177,8 +188,20 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
persistPendingEvents();
|
||||
|
||||
AssistantModelProvider provider = config.getActiveModelProvider();
|
||||
ModelCapabilities caps = provider.getCapabilities(policy.mainModel());
|
||||
model = policy.mainModel();
|
||||
active = runtime.getProviderService().resolve(policy.mainModel());
|
||||
if(active == null)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "No usable assistant provider is configured."));
|
||||
}
|
||||
model = active.model();
|
||||
capMicros = ProviderService.capMicros(active.row());
|
||||
if(capMicros != null)
|
||||
{
|
||||
monthCostAtStart = runtime.getProviderService().monthToDateCostMicros(active.providerId());
|
||||
}
|
||||
AssistantModelProvider provider = active.provider();
|
||||
ModelCapabilities caps = provider.getCapabilities(model);
|
||||
|
||||
List<AssistantMessage> history = new ArrayList<>();
|
||||
for(AssistantMessage message : store.listMessages(userId, conversation.getId(), userMessage.getId(), config.getHistoryMessageLimit()))
|
||||
@@ -189,12 +212,17 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
}
|
||||
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, policy.mainModel(), policy.mainEffort(), history, userText, pageContext);
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, policy.tools());
|
||||
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
|
||||
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
|
||||
{
|
||||
request.setMaxTokens(maxPerRequest);
|
||||
}
|
||||
if(caps != null && Boolean.FALSE.equals(caps.getParallelToolCalls()))
|
||||
{
|
||||
request.setParallelToolCalls(false);
|
||||
}
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames();
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
|
||||
|
||||
boolean toolLimitReached = false;
|
||||
while(true)
|
||||
@@ -209,7 +237,15 @@ public class TurnEngine implements Runnable
|
||||
break;
|
||||
}
|
||||
|
||||
ModelResponse response = callModel(provider, request);
|
||||
if(capMicros != null && monthCostAtStart + costMicros >= capMicros)
|
||||
{
|
||||
status = "ERROR";
|
||||
errorCode = "BUDGET";
|
||||
errorText = "The assistant has reached this provider's monthly cost cap. Ask an administrator.";
|
||||
break;
|
||||
}
|
||||
|
||||
ModelResponse response = callModelWithStrictFallback(provider, request);
|
||||
stopReason = response.getStopReason();
|
||||
|
||||
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
|
||||
@@ -297,14 +333,22 @@ public class TurnEngine implements Runnable
|
||||
if(e.getPartialUsage() != null)
|
||||
{
|
||||
usage.add(e.getPartialUsage());
|
||||
costMicros += pricing(policy.mainModel()).costMicros(e.getPartialUsage());
|
||||
costMicros += pricing().costMicros(e.getPartialUsage());
|
||||
}
|
||||
status = "ERROR";
|
||||
errorCode = e.getKind() == ModelProviderException.Kind.TIMEOUT ? "TIMEOUT" : "PROVIDER";
|
||||
errorText = e.getKind() == ModelProviderException.Kind.TIMEOUT
|
||||
? "The assistant took too long to answer."
|
||||
: (e.isRetryable() ? "The AI service is busy or unavailable right now. Try again in a moment." : "The AI service could not answer this request.");
|
||||
LOG.warn("Assistant model call failed", logPair("turnId", turn.getId()), logPair("kind", e.getKind()), logPair("httpStatus", e.getHttpStatus()));
|
||||
if(!e.isRetryable() && e.getProviderMessage() != null && session.hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// administrators see the provider's own (sanitized) reason //
|
||||
//////////////////////////////////////////////////////////////
|
||||
errorText += " The provider said: " + UsageRecorder.errorText(e);
|
||||
}
|
||||
LOG.warn("Assistant model call failed", logPair("turnId", turn.getId()), logPair("kind", e.getKind()), logPair("httpStatus", e.getHttpStatus()),
|
||||
logPair("errorType", e.getProviderErrorType()), logPair("errorMessage", e.getProviderMessage()), logPair("requestId", e.getRequestId()));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
@@ -312,8 +356,12 @@ public class TurnEngine implements Runnable
|
||||
status = "CANCELLED";
|
||||
stopReason = ModelResponse.STOP_CANCELLED;
|
||||
}
|
||||
catch(Exception e)
|
||||
catch(Exception | Error e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// Errors too (a missing class, a linkage problem): otherwise the //
|
||||
// executor swallows them and the turn ends "COMPLETE" and empty //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
status = "ERROR";
|
||||
errorCode = "INTERNAL";
|
||||
errorText = "Something went wrong while answering. Try again.";
|
||||
@@ -440,6 +488,33 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A model call; when the provider refuses the request because of its strict
|
||||
** tool schemas, retry once (and for the rest of the turn) with no strict
|
||||
** tools. The kernel validates every tool input either way.
|
||||
*******************************************************************************/
|
||||
private ModelResponse callModelWithStrictFallback(AssistantModelProvider provider, ModelRequest request) throws ModelProviderException, InterruptedException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (callModel(provider, request));
|
||||
}
|
||||
catch(ModelProviderException e)
|
||||
{
|
||||
boolean anyStrict = request.getTools().stream().anyMatch(tool -> Boolean.TRUE.equals(tool.getStrict()));
|
||||
if(!e.isStrictSchemaRejected() || !anyStrict)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
LOG.warn("Assistant provider refused the strict tool schemas; retrying without strict tools", logPair("turnId", turn.getId()), logPair("model", model),
|
||||
logPair("errorMessage", e.getProviderMessage()), logPair("requestId", e.getRequestId()));
|
||||
request.getTools().forEach(tool -> tool.setStrict(false));
|
||||
return (callModel(provider, request));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One streamed model call.
|
||||
*******************************************************************************/
|
||||
@@ -455,24 +530,51 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
};
|
||||
|
||||
StreamHandle handle = provider.stream(request.copy(), listener);
|
||||
running.setCurrentHandle(handle);
|
||||
ModelResponse response;
|
||||
Semaphore gate = runtime.getProviderService().gate(active.row());
|
||||
if(gate != null)
|
||||
{
|
||||
Duration wait = Duration.between(runtime.getClock().instant(), deadline);
|
||||
if(!gate.tryAcquire(Math.max(1, wait.toMillis()), TimeUnit.MILLISECONDS))
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, null, "The provider's concurrency limit was reached."));
|
||||
}
|
||||
}
|
||||
|
||||
long started = System.nanoTime();
|
||||
ModelResponse response = null;
|
||||
try
|
||||
{
|
||||
Duration remaining = Duration.between(runtime.getClock().instant(), deadline);
|
||||
response = handle.await(remaining.isNegative() ? Duration.ofMillis(1) : remaining);
|
||||
StreamHandle handle = provider.stream(request.copy(), listener);
|
||||
running.setCurrentHandle(handle);
|
||||
try
|
||||
{
|
||||
Duration remaining = Duration.between(runtime.getClock().instant(), deadline);
|
||||
response = handle.await(remaining.isNegative() ? Duration.ofMillis(1) : remaining);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running.setCurrentHandle(null);
|
||||
flushText(true);
|
||||
}
|
||||
}
|
||||
catch(ModelProviderException e)
|
||||
{
|
||||
recordUsage(null, e, started);
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running.setCurrentHandle(null);
|
||||
flushText(true);
|
||||
if(gate != null)
|
||||
{
|
||||
gate.release();
|
||||
}
|
||||
}
|
||||
recordUsage(response, null, started);
|
||||
|
||||
if(response.getUsage() != null)
|
||||
{
|
||||
usage.add(response.getUsage());
|
||||
costMicros += pricing(policy.mainModel()).costMicros(response.getUsage());
|
||||
costMicros += pricing().costMicros(response.getUsage());
|
||||
}
|
||||
if(response.getRequestId() != null)
|
||||
{
|
||||
@@ -492,6 +594,7 @@ public class TurnEngine implements Runnable
|
||||
int allowed = Math.max(0, config.getMaxToolCallsPerTurn() - toolCallCount);
|
||||
boolean limitReached = false;
|
||||
AiToolResult[] results = new AiToolResult[toolUses.size()];
|
||||
UiApiToolbox.Outcome[] outcomes = new UiApiToolbox.Outcome[toolUses.size()];
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
Semaphore permits = new Semaphore(Math.max(1, config.getMaxParallelToolCalls()));
|
||||
|
||||
@@ -524,7 +627,29 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
if(!running.isCancelled())
|
||||
{
|
||||
results[index] = runtime.getKernel().execute(session, call);
|
||||
if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
|
||||
{
|
||||
///////////////////////////////////////////////////
|
||||
// navigate / search_help: no records are read //
|
||||
///////////////////////////////////////////////////
|
||||
results[index] = runtime.getKernel().execute(session, call);
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// data tools: a /qqq/v1 call with the user's session //
|
||||
///////////////////////////////////////////////////////////
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
try
|
||||
{
|
||||
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
|
||||
results[index] = outcomes[index].getResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -580,7 +705,7 @@ public class TurnEngine implements Runnable
|
||||
.withSummary(running.isCancelled() ? "Not run (stopped)" : "Timed out")
|
||||
.withModelContent(running.isCancelled() ? "Not run: the user stopped the answer." : "The tool did not finish in time.");
|
||||
}
|
||||
recordToolResult(use, result);
|
||||
recordToolResult(use, result, outcomes[i]);
|
||||
|
||||
String content = result.getModelContent() == null ? "" : result.getModelContent();
|
||||
modelResults.add(ModelPart.toolResult(use.getToolUseId(), content, result.getOutcome() != null && ERROR_OUTCOMES.contains(result.getOutcome())));
|
||||
@@ -594,7 +719,7 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Audit row, events and message parts of one tool result.
|
||||
*******************************************************************************/
|
||||
private void recordToolResult(ModelPart use, AiToolResult result)
|
||||
private void recordToolResult(ModelPart use, AiToolResult result, UiApiToolbox.Outcome extras)
|
||||
{
|
||||
String outcome = result.getOutcome() == null ? AiToolOutcome.ERROR.name() : result.getOutcome().name();
|
||||
String summary = StringUtils.hasContent(result.getSummary()) ? result.getSummary() : ToolLabels.done(use.getToolName());
|
||||
@@ -637,7 +762,7 @@ public class TurnEngine implements Runnable
|
||||
.withUserId(userId)
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.withInputJson(truncate(JsonUtils.toJson(use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
.withTableName(result.getTableName())
|
||||
@@ -647,7 +772,11 @@ public class TurnEngine implements Runnable
|
||||
.withDurationMs(result.getDurationMs())
|
||||
.withResultSummary(summary)
|
||||
.withResultJson(truncate(result.getModelContent(), MAX_RESULT_JSON_CHARS))
|
||||
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions())));
|
||||
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
|
||||
.withUiApiOperation(extras == null ? null : extras.getOperation())
|
||||
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
|
||||
.withAuditHref(extras == null ? null : extras.getAuditHref())
|
||||
.withPendingActionId(extras == null ? null : extras.getPendingActionId()));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
@@ -657,6 +786,63 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a data tool runs: the user's own session (by its UUID, for the UI
|
||||
** API), the gateway, the policy's tool settings, and the pending-action
|
||||
** store for writes that need confirmation.
|
||||
*******************************************************************************/
|
||||
private UiApiToolbox.Context toolContext()
|
||||
{
|
||||
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The browser's timezone the session carries, if any.
|
||||
*******************************************************************************/
|
||||
private String userTimezone()
|
||||
{
|
||||
return (session.getValue(QSession.VALUE_KEY_USER_TIMEZONE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a write the user must confirm; nothing runs until they do.
|
||||
*******************************************************************************/
|
||||
private Integer proposeWrite(UiApiToolbox.ProposedWrite write) throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
AssistantPendingAction action = store.insert(new AssistantPendingAction()
|
||||
.withConversationId(conversation.getId())
|
||||
.withTurnId(turn.getId())
|
||||
.withUserId(userId)
|
||||
.withToolCallId(write.toolCallId())
|
||||
.withToolName(write.toolName())
|
||||
.withUiApiOperation(write.operation())
|
||||
.withTableName(write.tableName())
|
||||
.withRecordId(write.recordId())
|
||||
.withParamsJson(JsonUtils.toJson(write.values()))
|
||||
.withSummary(write.summary())
|
||||
.withStatus(AssistantPendingAction.STATUS_PENDING)
|
||||
.withExpiresAt(now.plusSeconds(config.getPendingActionTtlSeconds() == null ? 900 : config.getPendingActionTtlSeconds())));
|
||||
return (action.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tool input as JSON with secrets redacted (for the tool-call log).
|
||||
*******************************************************************************/
|
||||
private String redactInput(Map<String, Object> input)
|
||||
{
|
||||
String json = JsonUtils.toJson(input);
|
||||
return (INPUT_REDACTOR.redact(json, new HashSet<>()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only same-origin application links are passed on.
|
||||
*******************************************************************************/
|
||||
@@ -682,7 +868,7 @@ public class TurnEngine implements Runnable
|
||||
parts.addNotice("error", errorText);
|
||||
}
|
||||
|
||||
String model = policy == null ? null : policy.mainModel();
|
||||
String model = this.model != null ? this.model : (policy == null ? null : policy.mainModel());
|
||||
try
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
@@ -866,10 +1052,21 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private ModelPricing pricing(String model)
|
||||
private ModelPricing pricing()
|
||||
{
|
||||
ModelPricing pricing = config.getPricing() == null ? null : config.getPricing().get(model);
|
||||
return (pricing == null ? new ModelPricing() : pricing);
|
||||
return (runtime.getProviderService().pricing(active == null ? null : active.providerType(), model));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One assistant_usage row for a model call of this turn.
|
||||
*******************************************************************************/
|
||||
private void recordUsage(ModelResponse response, ModelProviderException error, long startedNanos)
|
||||
{
|
||||
long latencyMs = (System.nanoTime() - startedNanos) / 1_000_000;
|
||||
UsageRecorder.save(store, UsageRecorder.row(new UsageRecorder.Call(userId, conversation.getId(), turn.getId(), active, UsageRecorder.PURPOSE_TURN),
|
||||
response, error, latencyMs, pricing()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes one assistant_usage row per model call (a turn step or a
|
||||
** connection test), with the tokens, the cost from the price table, the
|
||||
** latency and how the call ended. A failed write is logged and never fails
|
||||
** the call it describes.
|
||||
*******************************************************************************/
|
||||
public final class UsageRecorder
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(UsageRecorder.class);
|
||||
|
||||
public static final String PURPOSE_TURN = "TURN";
|
||||
public static final String PURPOSE_TEST = "TEST";
|
||||
|
||||
public static final String STATUS_OK = "OK";
|
||||
public static final String STATUS_ERROR = "ERROR";
|
||||
public static final String STATUS_CANCELLED = "CANCELLED";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who and what a call was for.
|
||||
*******************************************************************************/
|
||||
public record Call(String userId, Integer conversationId, Integer turnId, ProviderService.Active active, String purpose)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private UsageRecorder()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the row of a finished call (response or error, one of them set).
|
||||
*******************************************************************************/
|
||||
public static AssistantUsage row(Call call, ModelResponse response, ModelProviderException error, long latencyMs, ModelPricing pricing)
|
||||
{
|
||||
ModelUsage usage = response != null ? response.getUsage() : (error == null ? null : error.getPartialUsage());
|
||||
String status = error != null ? STATUS_ERROR
|
||||
: (ModelResponse.STOP_CANCELLED.equals(response == null ? null : response.getStopReason()) ? STATUS_CANCELLED : STATUS_OK);
|
||||
|
||||
return (new AssistantUsage()
|
||||
.withUserId(call.userId())
|
||||
.withConversationId(call.conversationId())
|
||||
.withTurnId(call.turnId())
|
||||
.withProviderId(call.active() == null ? null : call.active().providerId())
|
||||
.withProviderType(call.active() == null ? null : call.active().providerType())
|
||||
.withModel(response != null && response.getModel() != null ? response.getModel() : (call.active() == null ? null : call.active().model()))
|
||||
.withPurpose(call.purpose())
|
||||
.withStatus(status)
|
||||
.withErrorKind(error == null ? null : String.valueOf(error.getKind()))
|
||||
.withHttpStatus(error == null ? null : error.getHttpStatus())
|
||||
.withLatencyMs((int) Math.min(Integer.MAX_VALUE, Math.max(0, latencyMs)))
|
||||
.withInputTokens(usage == null ? 0L : nz(usage.getInputTokens()))
|
||||
.withOutputTokens(usage == null ? 0L : nz(usage.getOutputTokens()))
|
||||
.withCacheReadTokens(usage == null ? 0L : nz(usage.getCacheReadTokens()))
|
||||
.withCacheWriteTokens(usage == null ? 0L : nz(usage.getCacheWriteTokens()))
|
||||
.withCostMicros(pricing == null ? 0L : pricing.costMicros(usage))
|
||||
.withRequestId(response != null ? truncate(response.getRequestId(), 100) : (error == null ? null : truncate(error.getRequestId(), 100))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What an administrator needs to act on a failed call: the provider's error
|
||||
** type and its own (sanitized) message when it sent one, else the summary.
|
||||
*******************************************************************************/
|
||||
public static String errorText(ModelProviderException error)
|
||||
{
|
||||
if(error.getProviderMessage() != null)
|
||||
{
|
||||
return ((error.getProviderErrorType() == null ? "" : error.getProviderErrorType() + ": ") + error.getProviderMessage());
|
||||
}
|
||||
return (error.getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a row (under the session in QContext); never throws.
|
||||
*******************************************************************************/
|
||||
public static void save(AssistantStore store, AssistantUsage row)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.insert(row);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not record assistant model-call usage", e, logPair("turnId", row.getTurnId()), logPair("purpose", row.getPurpose()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static long nz(Long value)
|
||||
{
|
||||
return (value == null ? 0 : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Assistant app home: one full-width HTML widget, the single owner of the
|
||||
** assistant's health, usage and cost data (no other dashboard repeats it).
|
||||
*******************************************************************************/
|
||||
public final class AssistantHome
|
||||
{
|
||||
public static final String WIDGET_NAME = "assistantHome";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHome()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widget.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData widget()
|
||||
{
|
||||
return (new QWidgetMetaData()
|
||||
.withName(WIDGET_NAME)
|
||||
.withLabel("Overview")
|
||||
.withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(Renderer.class))
|
||||
.withGridColumns(12)
|
||||
.withIsCard(false)
|
||||
.withPermissionRules(AssistantMetaDataBuilder.adminRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the home for the viewing admin.
|
||||
*******************************************************************************/
|
||||
public static class Renderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, "<p>The assistant is not running on this server.</p>")));
|
||||
}
|
||||
AssistantHomeData data = AssistantHomeData.load(runtime);
|
||||
return (new RenderWidgetOutput(new RawHTML(null, AssistantHomeView.render(data, runtime.getConfig().getTablePathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything the Assistant home shows, computed from the qbit's own tables
|
||||
** (assistant_provider, assistant_usage, assistant_conversation,
|
||||
** assistant_tool_call, assistant_policy) as the viewing admin. Days are UTC.
|
||||
** No sample data: an empty table gives an empty section.
|
||||
*******************************************************************************/
|
||||
public class AssistantHomeData
|
||||
{
|
||||
public static final int DAYS = 30;
|
||||
public static final int TOP = 10;
|
||||
public static final int RECENT = 10;
|
||||
|
||||
private static final Set<String> TOOL_FAILURES = Set.of("DENIED", "NOT_FOUND", "INVALID_INPUT", "ERROR");
|
||||
|
||||
private final List<ProviderHealth> providers = new ArrayList<>();
|
||||
private final Map<LocalDate, DayUsage> days = new LinkedHashMap<>();
|
||||
private final List<Totals> byUser = new ArrayList<>();
|
||||
private final List<Totals> byModel = new ArrayList<>();
|
||||
private final List<ToolTotals> byTool = new ArrayList<>();
|
||||
private final List<QRecord> failedCalls = new ArrayList<>();
|
||||
private final List<QRecord> failedTools = new ArrayList<>();
|
||||
|
||||
private LocalDate today;
|
||||
private long monthCostMicros;
|
||||
private long monthRequests;
|
||||
private Long instanceMonthlyCapMicros;
|
||||
private boolean killSwitch;
|
||||
private int calls7d;
|
||||
private int errors7d;
|
||||
private boolean truncated;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One provider's health.
|
||||
*******************************************************************************/
|
||||
public record ProviderHealth(AssistantProvider row, String typeLabel, String keyStatus, boolean keyPresent, List<String> problems, boolean active, long monthCostMicros, Long capMicros)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One day's usage.
|
||||
*******************************************************************************/
|
||||
public static class DayUsage
|
||||
{
|
||||
public int conversations;
|
||||
public int requests;
|
||||
public int errors;
|
||||
public long tokensIn;
|
||||
public long tokensOut;
|
||||
public long costMicros;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Totals for one key (a user, a model).
|
||||
*******************************************************************************/
|
||||
public static class Totals
|
||||
{
|
||||
public String key;
|
||||
public int requests;
|
||||
public long tokensIn;
|
||||
public long tokensOut;
|
||||
public long costMicros;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Totals for one tool.
|
||||
*******************************************************************************/
|
||||
public static class ToolTotals
|
||||
{
|
||||
public String name;
|
||||
public int calls;
|
||||
public int failures;
|
||||
public long totalMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load it all for the session in QContext.
|
||||
*******************************************************************************/
|
||||
public static AssistantHomeData load(AssistantRuntime runtime) throws QException
|
||||
{
|
||||
AssistantHomeData data = new AssistantHomeData();
|
||||
data.today = LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC));
|
||||
LocalDate firstDay = data.today.minusDays(DAYS - 1);
|
||||
LocalDate monthStart = data.today.withDayOfMonth(1);
|
||||
LocalDate from = firstDay.isBefore(monthStart) ? firstDay : monthStart;
|
||||
Instant fromAt = from.atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
Instant monthAt = monthStart.atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
Instant weekAt = runtime.getClock().instant().minusSeconds(7L * 24 * 3600);
|
||||
int max = runtime.getConfig().getHomeMaxRows();
|
||||
|
||||
for(LocalDate d = firstDay; !d.isAfter(data.today); d = d.plusDays(1))
|
||||
{
|
||||
data.days.put(d, new DayUsage());
|
||||
}
|
||||
|
||||
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(false);
|
||||
data.killSwitch = policy.killSwitch();
|
||||
data.instanceMonthlyCapMicros = policy.budgets().getInstanceMonthlyCostMicrosHard();
|
||||
|
||||
////////////////////////////////////////
|
||||
// usage rows: by day, user, model //
|
||||
////////////////////////////////////////
|
||||
List<QRecord> usage = runtime.getStore().queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, fromAt)
|
||||
.withOrderBy(new QFilterOrderBy("id", false)), max);
|
||||
data.truncated |= usage.size() >= max;
|
||||
|
||||
Map<String, Totals> users = new LinkedHashMap<>();
|
||||
Map<String, Totals> models = new LinkedHashMap<>();
|
||||
Map<Integer, Long> providerMonth = new LinkedHashMap<>();
|
||||
for(QRecord row : usage)
|
||||
{
|
||||
Instant at = row.getValueInstant("createDate");
|
||||
long in = nz(row.getValueLong("inputTokens")) + nz(row.getValueLong("cacheReadTokens")) + nz(row.getValueLong("cacheWriteTokens"));
|
||||
long out = nz(row.getValueLong("outputTokens"));
|
||||
long cost = nz(row.getValueLong("costMicros"));
|
||||
boolean err = UsageRecorder.STATUS_ERROR.equals(row.getValueString("status"));
|
||||
boolean turn = UsageRecorder.PURPOSE_TURN.equals(row.getValueString("purpose"));
|
||||
if(at == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DayUsage day = data.days.get(LocalDate.ofInstant(at, ZoneOffset.UTC));
|
||||
if(day != null)
|
||||
{
|
||||
day.requests++;
|
||||
day.errors += err ? 1 : 0;
|
||||
day.tokensIn += in;
|
||||
day.tokensOut += out;
|
||||
day.costMicros += cost;
|
||||
}
|
||||
|
||||
if(!at.isBefore(weekAt) && turn)
|
||||
{
|
||||
data.calls7d++;
|
||||
data.errors7d += err ? 1 : 0;
|
||||
}
|
||||
|
||||
if(err && data.failedCalls.size() < RECENT)
|
||||
{
|
||||
data.failedCalls.add(row);
|
||||
}
|
||||
|
||||
if(!at.isBefore(monthAt))
|
||||
{
|
||||
data.monthCostMicros += cost;
|
||||
data.monthRequests++;
|
||||
add(users, row.getValueString("userId") == null ? "(none)" : row.getValueString("userId"), in, out, cost);
|
||||
add(models, row.getValueString("model") == null ? "(unknown)" : row.getValueString("model"), in, out, cost);
|
||||
Integer providerId = row.getValueInteger("providerId");
|
||||
if(providerId != null)
|
||||
{
|
||||
providerMonth.merge(providerId, cost, Long::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
data.byUser.addAll(top(users));
|
||||
data.byModel.addAll(top(models));
|
||||
|
||||
////////////////////////////////////////
|
||||
// conversations started per day //
|
||||
////////////////////////////////////////
|
||||
Instant dayOneAt = firstDay.atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
List<QRecord> conversations = runtime.getStore().queryRecords(AssistantConversation.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, dayOneAt), max);
|
||||
data.truncated |= conversations.size() >= max;
|
||||
for(QRecord row : conversations)
|
||||
{
|
||||
Instant at = row.getValueInstant("createDate");
|
||||
DayUsage day = at == null ? null : data.days.get(LocalDate.ofInstant(at, ZoneOffset.UTC));
|
||||
if(day != null)
|
||||
{
|
||||
day.conversations++;
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
// tools //
|
||||
////////////////////////////////////////
|
||||
List<QRecord> tools = runtime.getStore().queryRecords(AssistantToolCall.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, dayOneAt)
|
||||
.withOrderBy(new QFilterOrderBy("id", false)), max);
|
||||
data.truncated |= tools.size() >= max;
|
||||
Map<String, ToolTotals> toolMap = new LinkedHashMap<>();
|
||||
for(QRecord row : tools)
|
||||
{
|
||||
String name = row.getValueString("toolName") == null ? "(unknown)" : row.getValueString("toolName");
|
||||
ToolTotals totals = toolMap.computeIfAbsent(name, k ->
|
||||
{
|
||||
ToolTotals t = new ToolTotals();
|
||||
t.name = k;
|
||||
return (t);
|
||||
});
|
||||
totals.calls++;
|
||||
totals.totalMs += nz(row.getValueLong("durationMs"));
|
||||
if(TOOL_FAILURES.contains(row.getValueString("outcome")))
|
||||
{
|
||||
totals.failures++;
|
||||
if(data.failedTools.size() < RECENT)
|
||||
{
|
||||
data.failedTools.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
toolMap.values().stream()
|
||||
.sorted(Comparator.comparingInt((ToolTotals t) -> t.calls).reversed().thenComparing(t -> t.name))
|
||||
.limit(TOP)
|
||||
.forEach(data.byTool::add);
|
||||
|
||||
////////////////////////////////////////
|
||||
// providers //
|
||||
////////////////////////////////////////
|
||||
ProviderService service = runtime.getProviderService();
|
||||
AssistantProvider active = service.activeRow();
|
||||
for(AssistantProvider row : runtime.getStore().listProviders())
|
||||
{
|
||||
String status = service.keyStatus(row);
|
||||
AssistantProviderType type = row.getProviderType() == null ? null : runtime.getConfig().getProviderTypes().get(row.getProviderType());
|
||||
data.providers.add(new ProviderHealth(row, type == null ? row.getProviderType() : type.getLabel(), status, status.startsWith("Set"), service.validate(row), active != null && active.getId().equals(row.getId()),
|
||||
providerMonth.getOrDefault(row.getId(), 0L), ProviderService.capMicros(row)));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void add(Map<String, Totals> map, String key, long in, long out, long cost)
|
||||
{
|
||||
Totals totals = map.computeIfAbsent(key, k ->
|
||||
{
|
||||
Totals t = new Totals();
|
||||
t.key = k;
|
||||
return (t);
|
||||
});
|
||||
totals.requests++;
|
||||
totals.tokensIn += in;
|
||||
totals.tokensOut += out;
|
||||
totals.costMicros += cost;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The top entries by cost, then requests.
|
||||
*******************************************************************************/
|
||||
private static List<Totals> top(Map<String, Totals> map)
|
||||
{
|
||||
return (map.values().stream()
|
||||
.sorted(Comparator.comparingLong((Totals t) -> t.costMicros).reversed().thenComparing(Comparator.comparingInt((Totals t) -> t.requests).reversed()).thenComparing(t -> t.key))
|
||||
.limit(TOP)
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static long nz(Long value)
|
||||
{
|
||||
return (value == null ? 0 : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The error rate of turn model calls in the last 7 days (0-100), or null.
|
||||
*******************************************************************************/
|
||||
public Integer errorRatePercent7d()
|
||||
{
|
||||
return (calls7d == 0 ? null : (int) Math.round(errors7d * 100.0 / calls7d));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providers
|
||||
*******************************************************************************/
|
||||
public List<ProviderHealth> getProviders()
|
||||
{
|
||||
return (providers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for days (oldest first)
|
||||
*******************************************************************************/
|
||||
public Map<LocalDate, DayUsage> getDays()
|
||||
{
|
||||
return (days);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for byUser
|
||||
*******************************************************************************/
|
||||
public List<Totals> getByUser()
|
||||
{
|
||||
return (byUser);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for byModel
|
||||
*******************************************************************************/
|
||||
public List<Totals> getByModel()
|
||||
{
|
||||
return (byModel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for byTool
|
||||
*******************************************************************************/
|
||||
public List<ToolTotals> getByTool()
|
||||
{
|
||||
return (byTool);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for failedCalls (newest first)
|
||||
*******************************************************************************/
|
||||
public List<QRecord> getFailedCalls()
|
||||
{
|
||||
return (failedCalls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for failedTools (newest first)
|
||||
*******************************************************************************/
|
||||
public List<QRecord> getFailedTools()
|
||||
{
|
||||
return (failedTools);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for today
|
||||
*******************************************************************************/
|
||||
public LocalDate getToday()
|
||||
{
|
||||
return (today);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for monthCostMicros
|
||||
*******************************************************************************/
|
||||
public long getMonthCostMicros()
|
||||
{
|
||||
return (monthCostMicros);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for monthRequests
|
||||
*******************************************************************************/
|
||||
public long getMonthRequests()
|
||||
{
|
||||
return (monthRequests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for instanceMonthlyCapMicros
|
||||
*******************************************************************************/
|
||||
public Long getInstanceMonthlyCapMicros()
|
||||
{
|
||||
return (instanceMonthlyCapMicros);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for killSwitch
|
||||
*******************************************************************************/
|
||||
public boolean isKillSwitch()
|
||||
{
|
||||
return (killSwitch);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for calls7d
|
||||
*******************************************************************************/
|
||||
public int getCalls7d()
|
||||
{
|
||||
return (calls7d);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errors7d
|
||||
*******************************************************************************/
|
||||
public int getErrors7d()
|
||||
{
|
||||
return (errors7d);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated (a table had more rows than the home reads)
|
||||
*******************************************************************************/
|
||||
public boolean isTruncated()
|
||||
{
|
||||
return (truncated);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the Assistant home: a status row, one row per provider across the
|
||||
** full width, then cards for usage by day, costs this month, usage by user,
|
||||
** top tools and recent failures. Every card has an honest empty state.
|
||||
** Layout is flow and grid only (no tables, no fixed widths, no inner
|
||||
** scrolling): rows wrap their cells, cards stack to one column on narrow
|
||||
** screens, long names wrap, and colors are inherited (only status dots and
|
||||
** bars use a fixed color), so it reads at every width in light and dark mode.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHomeView
|
||||
{
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM d", Locale.US);
|
||||
|
||||
private static final String ROOT = "display:grid;gap:1rem;min-width:0;";
|
||||
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;align-items:start;min-width:0;";
|
||||
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
|
||||
private static final String ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;";
|
||||
private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;";
|
||||
private static final String LIST = "display:grid;grid-template-columns:minmax(6rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
|
||||
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;";
|
||||
private static final String LINE = "display:flex;flex-wrap:wrap;justify-content:space-between;gap:.15rem 1rem;padding:.35rem 0;border-bottom:1px solid rgba(127,127,127,.2);font-size:.9rem;";
|
||||
private static final String MUTED = "opacity:.75;";
|
||||
private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;";
|
||||
private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;";
|
||||
private static final String H = "margin:0 0 .75rem;font-size:1rem;font-weight:600;";
|
||||
|
||||
private static final String GREEN = "#2e9d5b";
|
||||
private static final String AMBER = "#c98a12";
|
||||
private static final String RED = "#d0453a";
|
||||
private static final String GREY = "#8a8a8a";
|
||||
private static final String BAR = "#4a7fd4";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHomeView()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole home; prefix is the front of table and record links.
|
||||
*******************************************************************************/
|
||||
public static String render(AssistantHomeData data, String prefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"assistant-home\" style=\"" + ROOT + "\">");
|
||||
status(html, data, prefix);
|
||||
providers(html, data, prefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
usageByDay(html, data, prefix);
|
||||
costs(html, data, prefix);
|
||||
byUser(html, data);
|
||||
tools(html, data, prefix);
|
||||
failures(html, data, prefix);
|
||||
html.append("</div>");
|
||||
if(data.isTruncated())
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("font-size:.85rem;margin:0;\">Some totals are partial: a table had more rows in this period than the home reads.</p>");
|
||||
}
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status row: is the assistant answering, with what, at what cost.
|
||||
*******************************************************************************/
|
||||
private static void status(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Health");
|
||||
AssistantHomeData.ProviderHealth active = data.getProviders().stream().filter(AssistantHomeData.ProviderHealth::active).findFirst().orElse(null);
|
||||
|
||||
html.append("<div style=\"display:flex;flex-wrap:wrap;gap:.5rem .75rem;align-items:center;\">");
|
||||
if(data.isKillSwitch())
|
||||
{
|
||||
html.append(chip(RED, "Turned off by the kill switch"));
|
||||
}
|
||||
else if(data.getProviders().isEmpty())
|
||||
{
|
||||
html.append(chip(GREY, "No provider configured here (the application's built-in provider is used, if any)"));
|
||||
}
|
||||
else if(active == null)
|
||||
{
|
||||
html.append(chip(RED, "No provider is turned on"));
|
||||
}
|
||||
else if(!active.keyPresent() || !active.problems().isEmpty())
|
||||
{
|
||||
html.append(chip(RED, "Default provider cannot be used"));
|
||||
}
|
||||
else if("ERROR".equals(active.row().getLastTestStatus()))
|
||||
{
|
||||
html.append(chip(AMBER, "Last connection test failed"));
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append(chip(GREEN, "Answering"));
|
||||
}
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Provider", active == null ? "None" : active.row().getName() + " (" + nvl(active.row().getModel(), "default model") + ")");
|
||||
cell(html, "API key", active == null ? "Not applicable" : active.keyStatus());
|
||||
cell(html, "Last test", active == null ? "Not applicable" : lastTest(active.row()));
|
||||
Integer rate = data.errorRatePercent7d();
|
||||
cell(html, "Error rate, 7 days", rate == null ? "No requests yet" : rate + "% of " + number(data.getCalls7d()) + " requests");
|
||||
cell(html, "Cost this month", money(data.getMonthCostMicros()) + (data.getInstanceMonthlyCapMicros() == null ? "" : " of " + money(data.getInstanceMonthlyCapMicros()) + " limit"));
|
||||
cell(html, "Requests this month", number(data.getMonthRequests()));
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One row per provider.
|
||||
*******************************************************************************/
|
||||
private static void providers(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Providers");
|
||||
if(data.getProviders().isEmpty())
|
||||
{
|
||||
empty(html, "No providers yet. Add one from Providers, New: pick the type and model, and name the environment variable that holds the API key.");
|
||||
}
|
||||
for(AssistantHomeData.ProviderHealth p : data.getProviders())
|
||||
{
|
||||
AssistantProvider row = p.row();
|
||||
html.append("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
|
||||
html.append("<strong>").append(recordLink(prefix, AssistantProvider.TABLE_NAME, row.getId(), row.getName())).append("</strong>");
|
||||
if(p.active())
|
||||
{
|
||||
html.append(chip(GREEN, "In use"));
|
||||
}
|
||||
else if(Boolean.TRUE.equals(row.getIsDefault()))
|
||||
{
|
||||
html.append(chip(GREY, "Default"));
|
||||
}
|
||||
if(!Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
html.append(chip(GREY, "Off"));
|
||||
}
|
||||
if(row.getLastTestStatus() != null)
|
||||
{
|
||||
html.append(chip("OK".equals(row.getLastTestStatus()) ? GREEN : RED, "OK".equals(row.getLastTestStatus()) ? "Last test passed" : "Last test failed"));
|
||||
}
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Type", nvl(p.typeLabel(), "Not set"));
|
||||
cell(html, "Model", nvl(row.getModel(), "Default model"));
|
||||
cell(html, "API key", p.keyStatus());
|
||||
cell(html, "Last test", lastTest(row));
|
||||
cell(html, "Cost this month", money(p.monthCostMicros()) + (p.capMicros() == null ? ", no cap" : " of " + money(p.capMicros()) + " cap"));
|
||||
List<String> limits = new ArrayList<>();
|
||||
if(row.getMaxOutputTokensPerRequest() != null)
|
||||
{
|
||||
limits.add(number(row.getMaxOutputTokensPerRequest()) + " output tokens per request");
|
||||
}
|
||||
if(row.getMaxConcurrentRequests() != null)
|
||||
{
|
||||
limits.add(row.getMaxConcurrentRequests() + " at a time");
|
||||
}
|
||||
cell(html, "Limits", limits.isEmpty() ? "None" : String.join(", ", limits));
|
||||
html.append("</div>");
|
||||
if(!p.problems().isEmpty())
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("margin-top:.6rem;\">");
|
||||
p.problems().forEach(problem -> html.append("<li>").append(dot(RED)).append(esc(problem)).append("</li>"));
|
||||
html.append("</ul>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
html.append("<div style=\"display:flex;flex-wrap:wrap;gap:.5rem 1rem;\">");
|
||||
link(html, prefix, AssistantProvider.TABLE_NAME, "Providers");
|
||||
link(html, prefix, AssistantModelPrice.TABLE_NAME, "Model prices");
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Usage by day: bars of requests, then the recent days in words.
|
||||
*******************************************************************************/
|
||||
private static void usageByDay(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Usage, last " + AssistantHomeData.DAYS + " days");
|
||||
Map<LocalDate, AssistantHomeData.DayUsage> days = data.getDays();
|
||||
long conversations = 0;
|
||||
long requests = 0;
|
||||
long tokensIn = 0;
|
||||
long tokensOut = 0;
|
||||
long cost = 0;
|
||||
long max = 1;
|
||||
for(AssistantHomeData.DayUsage day : days.values())
|
||||
{
|
||||
conversations += day.conversations;
|
||||
requests += day.requests;
|
||||
tokensIn += day.tokensIn;
|
||||
tokensOut += day.tokensOut;
|
||||
cost += day.costMicros;
|
||||
max = Math.max(max, day.requests);
|
||||
}
|
||||
|
||||
if(requests == 0 && conversations == 0)
|
||||
{
|
||||
empty(html, "No assistant use in the last " + AssistantHomeData.DAYS + " days.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<div role=\"img\" aria-label=\"Model requests per day, last ").append(days.size()).append(" days\" style=\"display:flex;align-items:flex-end;gap:2px;height:3rem;margin:0 0 .25rem;\">");
|
||||
for(Map.Entry<LocalDate, AssistantHomeData.DayUsage> entry : days.entrySet())
|
||||
{
|
||||
AssistantHomeData.DayUsage day = entry.getValue();
|
||||
int pct = (int) Math.max(day.requests == 0 ? 0 : 4, Math.round(day.requests * 100.0 / max));
|
||||
html.append("<span title=\"").append(esc(DAY.format(entry.getKey()) + ": " + day.requests + " requests")).append("\" style=\"flex:1 1 0;min-width:0;height:").append(pct)
|
||||
.append("%;background:").append(day.errors > 0 ? AMBER : BAR).append(";border-radius:2px 2px 0 0;\"></span>");
|
||||
}
|
||||
html.append("</div><div style=\"display:flex;justify-content:space-between;font-size:.75rem;margin:0 0 .75rem;").append(MUTED).append("\"><span>")
|
||||
.append(esc(DAY.format(days.keySet().iterator().next()))).append("</span><span>").append(esc(DAY.format(data.getToday()))).append("</span></div>");
|
||||
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Conversations", number(conversations));
|
||||
row(html, "Requests", number(requests));
|
||||
row(html, "Tokens in", number(tokensIn));
|
||||
row(html, "Tokens out", number(tokensOut));
|
||||
row(html, "Cost", money(cost));
|
||||
html.append("</dl>");
|
||||
|
||||
List<LocalDate> recent = new ArrayList<>(days.keySet());
|
||||
recent = recent.subList(Math.max(0, recent.size() - 7), recent.size());
|
||||
html.append("<div style=\"margin:0 0 .75rem;\">");
|
||||
for(int i = recent.size() - 1; i >= 0; i--)
|
||||
{
|
||||
AssistantHomeData.DayUsage day = days.get(recent.get(i));
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(DAY.format(recent.get(i)))).append("</span><span style=\"").append(MUTED).append("\">")
|
||||
.append(esc(day.conversations + " conv, " + day.requests + " req, " + compact(day.tokensIn) + " in, " + compact(day.tokensOut) + " out, " + money(day.costMicros)))
|
||||
.append("</span></div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
html.append("<div style=\"display:flex;flex-wrap:wrap;gap:.5rem 1rem;\">");
|
||||
link(html, prefix, AssistantUsage.TABLE_NAME, "All model calls");
|
||||
link(html, prefix, AssistantConversation.TABLE_NAME, "Conversations");
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Costs this month: total against caps, then by model.
|
||||
*******************************************************************************/
|
||||
private static void costs(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Costs this month");
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Month to date", money(data.getMonthCostMicros()));
|
||||
if(data.getInstanceMonthlyCapMicros() != null)
|
||||
{
|
||||
row(html, "Monthly limit", money(data.getInstanceMonthlyCapMicros()) + " (" + percent(data.getMonthCostMicros(), data.getInstanceMonthlyCapMicros()) + " used)");
|
||||
}
|
||||
for(AssistantHomeData.ProviderHealth p : data.getProviders())
|
||||
{
|
||||
if(p.capMicros() != null)
|
||||
{
|
||||
row(html, p.row().getName() + " cap", money(p.capMicros()) + " (" + percent(p.monthCostMicros(), p.capMicros()) + " used)");
|
||||
}
|
||||
}
|
||||
html.append("</dl>");
|
||||
if(data.getByModel().isEmpty())
|
||||
{
|
||||
empty(html, "No model calls this month.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<div style=\"margin:0 0 .75rem;\">");
|
||||
for(AssistantHomeData.Totals t : data.getByModel())
|
||||
{
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(t.key)).append("</span><span>")
|
||||
.append(esc(money(t.costMicros) + ", " + number(t.requests) + " req, " + compact(t.tokensIn) + " in, " + compact(t.tokensOut) + " out")).append("</span></div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
html.append("<p style=\"").append(MUTED).append("font-size:.85rem;margin:0 0 .5rem;\">Cost is tokens times the model's price.</p>");
|
||||
link(html, prefix, AssistantModelPrice.TABLE_NAME, "Model prices");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Usage by user this month.
|
||||
*******************************************************************************/
|
||||
private static void byUser(StringBuilder html, AssistantHomeData data)
|
||||
{
|
||||
open(html, "By user, this month");
|
||||
if(data.getByUser().isEmpty())
|
||||
{
|
||||
empty(html, "No one has used the assistant this month.");
|
||||
}
|
||||
for(AssistantHomeData.Totals t : data.getByUser())
|
||||
{
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(t.key)).append("</span><span>")
|
||||
.append(esc(money(t.costMicros) + ", " + number(t.requests) + " req, " + compact(t.tokensIn + t.tokensOut) + " tokens")).append("</span></div>");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Top tools.
|
||||
*******************************************************************************/
|
||||
private static void tools(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Top tools, last " + AssistantHomeData.DAYS + " days");
|
||||
if(data.getByTool().isEmpty())
|
||||
{
|
||||
empty(html, "No tool calls yet.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<div style=\"margin:0 0 .75rem;\">");
|
||||
for(AssistantHomeData.ToolTotals t : data.getByTool())
|
||||
{
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(t.name)).append("</span><span>")
|
||||
.append(esc(number(t.calls) + " calls" + (t.failures == 0 ? "" : ", " + number(t.failures) + " failed") + ", " + (t.totalMs / Math.max(1, t.calls)) + " ms avg"))
|
||||
.append("</span></div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
link(html, prefix, AssistantToolCall.TABLE_NAME, "All tool calls");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent failures: model calls and tool calls.
|
||||
*******************************************************************************/
|
||||
private static void failures(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Recent failures");
|
||||
if(data.getFailedCalls().isEmpty() && data.getFailedTools().isEmpty())
|
||||
{
|
||||
empty(html, "No failed model calls or tool calls in the last " + AssistantHomeData.DAYS + " days.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
for(QRecord call : data.getFailedCalls())
|
||||
{
|
||||
html.append("<li>").append(recordLink(prefix, AssistantUsage.TABLE_NAME, call.getValue("id"), "Model call #" + call.getValue("id"))).append(": ")
|
||||
.append(esc(nvl(call.getValueString("errorKind"), "Error") + (call.getValueInteger("httpStatus") == null ? "" : " (HTTP " + call.getValueInteger("httpStatus") + ")")
|
||||
+ ", " + nvl(call.getValueString("model"), "unknown model") + ", " + when(call.getValueInstant("createDate"))))
|
||||
.append("</li>");
|
||||
}
|
||||
for(QRecord tool : data.getFailedTools())
|
||||
{
|
||||
html.append("<li>").append(recordLink(prefix, AssistantToolCall.TABLE_NAME, tool.getValue("id"), nvl(tool.getValueString("toolName"), "Tool call"))).append(": ")
|
||||
.append(esc(label(tool.getValueString("outcome")) + ", " + when(tool.getValueInstant("createDate")))).append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider's last test in words.
|
||||
*******************************************************************************/
|
||||
static String lastTest(AssistantProvider row)
|
||||
{
|
||||
if(row.getLastTestAt() == null)
|
||||
{
|
||||
return ("Not tested yet: run Test connection");
|
||||
}
|
||||
if("OK".equals(row.getLastTestStatus()))
|
||||
{
|
||||
return ("Passed " + WHEN.format(row.getLastTestAt()) + ", " + nvl(row.getLastTestModel(), "model") + " in " + row.getLastTestLatencyMs() + " ms");
|
||||
}
|
||||
return ("Failed " + WHEN.format(row.getLastTestAt()) + ": " + nvl(row.getLastTestError(), "no detail"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Micro-dollars as dollars ("$0.00", "<$0.01" for a little, "$12.34").
|
||||
*******************************************************************************/
|
||||
public static String money(long micros)
|
||||
{
|
||||
if(micros > 0 && micros < 10_000)
|
||||
{
|
||||
return ("<$0.01");
|
||||
}
|
||||
NumberFormat format = NumberFormat.getNumberInstance(Locale.US);
|
||||
format.setMinimumFractionDigits(2);
|
||||
format.setMaximumFractionDigits(2);
|
||||
return ("$" + format.format(BigDecimal.valueOf(micros).movePointLeft(6).setScale(2, RoundingMode.HALF_UP)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String percent(long value, long of)
|
||||
{
|
||||
return (of <= 0 ? "n/a" : Math.round(value * 100.0 / of) + "%");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String number(long value)
|
||||
{
|
||||
return (NumberFormat.getIntegerInstance(Locale.US).format(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 1234567 as 1.2M, 4500 as 4.5k.
|
||||
*******************************************************************************/
|
||||
static String compact(long value)
|
||||
{
|
||||
if(value >= 1_000_000)
|
||||
{
|
||||
return (BigDecimal.valueOf(value).movePointLeft(6).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "M");
|
||||
}
|
||||
if(value >= 1_000)
|
||||
{
|
||||
return (BigDecimal.valueOf(value).movePointLeft(3).setScale(1, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "k");
|
||||
}
|
||||
return (String.valueOf(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String when(Instant instant)
|
||||
{
|
||||
return (instant == null ? "time unknown" : WHEN.format(instant));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SOME_STATUS to Some status.
|
||||
*******************************************************************************/
|
||||
private static String label(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("Unknown");
|
||||
}
|
||||
String lower = value.replace('_', ' ').toLowerCase(Locale.ROOT);
|
||||
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void open(StringBuilder html, String title)
|
||||
{
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"").append(H).append("\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void close(StringBuilder html)
|
||||
{
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void empty(StringBuilder html, String text)
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void cell(StringBuilder html, String label, String value)
|
||||
{
|
||||
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.8rem;\">").append(esc(label)).append("</div><div>").append(esc(value)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void row(StringBuilder html, String label, String value)
|
||||
{
|
||||
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(label)).append("</dt><dd style=\"margin:0;min-width:0;\">").append(esc(value)).append("</dd>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String chip(String color, String text)
|
||||
{
|
||||
return ("<span style=\"" + CHIP + "\">" + dot(color) + esc(text) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String dot(String color)
|
||||
{
|
||||
return ("<span aria-hidden=\"true\" style=\"display:inline-block;width:.55rem;height:.55rem;border-radius:50%;margin-right:.35rem;background:" + color + ";\"></span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String prefix, String table, String text)
|
||||
{
|
||||
html.append("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String recordLink(String prefix, String table, Object id, String text)
|
||||
{
|
||||
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String nvl(String value, String fallback)
|
||||
{
|
||||
return (value == null || value.isBlank() ? fallback : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTML-escape.
|
||||
*******************************************************************************/
|
||||
static String esc(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
StringBuilder out = new StringBuilder(value.length());
|
||||
for(char c : value.toCharArray())
|
||||
{
|
||||
switch(c)
|
||||
{
|
||||
case '&' -> out.append("&");
|
||||
case '<' -> out.append("<");
|
||||
case '>' -> out.append(">");
|
||||
case '"' -> out.append(""");
|
||||
case '\'' -> out.append("'");
|
||||
default -> out.append(c);
|
||||
}
|
||||
}
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** `assistant_data_access` rows: the object must exist (a table or process of
|
||||
** this instance) and the mode must be NONE, READ or READ_WRITE; READ_WRITE
|
||||
** on a process means "may run". After any change the runtime's cached modes
|
||||
** are dropped, so the next tool call uses the new setting.
|
||||
*******************************************************************************/
|
||||
public class AssistantDataAccessCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Check each row (merged with its old values on update).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new LinkedHashMap<>(o.getValues())));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
|
||||
String type = merged.getValueString("objectType");
|
||||
String name = merged.getValueString("objectName");
|
||||
if(!StringUtils.hasContent(type))
|
||||
{
|
||||
record.setValue("objectType", AssistantDataAccess.TYPE_TABLE);
|
||||
type = AssistantDataAccess.TYPE_TABLE;
|
||||
}
|
||||
if(!AssistantDataAccess.TYPE_TABLE.equals(type) && !AssistantDataAccess.TYPE_PROCESS.equals(type))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Type must be TABLE or PROCESS."));
|
||||
}
|
||||
else if(!StringUtils.hasContent(name))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Name the table or process."));
|
||||
}
|
||||
else if(AssistantDataAccess.TYPE_TABLE.equals(type) ? qInstance.getTable(name.trim()) == null : qInstance.getProcess(name.trim()) == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("There is no " + type.toLowerCase() + " named " + name + " in this application."));
|
||||
}
|
||||
else if(!name.equals(name.trim()))
|
||||
{
|
||||
record.setValue("objectName", name.trim());
|
||||
}
|
||||
|
||||
if(DataAccessMode.parse(merged.getValueString("mode")) == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Assistant access must be NONE, READ or READ_WRITE."));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cached modes after a save.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
invalidate();
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cached modes after a delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
invalidate();
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void invalidate()
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getDataAccessService().invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,15 +20,24 @@ package com.kingsrook.qbits.assistant.metadata;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
@@ -55,7 +64,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** Builds the QBit's metadata (spec section 4, owner decision D6): standard,
|
||||
** visible QQQ tables on the configured backend, the owner security key type,
|
||||
** the conversation possible-value source, the retention process and the
|
||||
** "Assistant Admin" app.
|
||||
** "Assistant" app (its home plus the main tables).
|
||||
**
|
||||
** Security:
|
||||
** - Every user-owned table carries userId and a record security lock on it
|
||||
@@ -84,6 +93,14 @@ public class AssistantMetaDataBuilder
|
||||
@Deprecated(since = "0.1.0-RC.3")
|
||||
public static final String LEGACY_ALL_ACCESS_SUFFIX = "AllAccess";
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables in the app's nav, in order (the main tables). The others are
|
||||
** child lists on these (messages, turns, tool calls, feedback and model
|
||||
** calls on a conversation; model calls on a provider) or reached from the
|
||||
** home. Processes are table and record actions, never nav entries.
|
||||
*******************************************************************************/
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantProvider.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
|
||||
@@ -154,12 +171,65 @@ public class AssistantMetaDataBuilder
|
||||
.withUniqueKey(new UniqueKey("scope", "userId", "usageDate"))
|
||||
.withRecordLabelFormat("%s %s").withRecordLabelFields("scope", "usageDate"));
|
||||
|
||||
tables.add(settings(base(AssistantProvider.TABLE_NAME, "Assistant Provider", "settings_input_component", AssistantProvider.class))
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
|
||||
|
||||
tables.add(settings(base(AssistantModelPrice.TABLE_NAME, "Assistant Model Price", "sell", AssistantModelPrice.class))
|
||||
.withUniqueKey(new UniqueKey("providerType", "model"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("model"));
|
||||
|
||||
tables.add(readOnly(owned(base(AssistantUsage.TABLE_NAME, "Assistant Model Call", "receipt_long", AssistantUsage.class), false))
|
||||
.withRecordLabelFormat("%s #%s").withRecordLabelFields("purpose", "id"));
|
||||
|
||||
tables.add(dataAccess(base(AssistantDataAccess.TABLE_NAME, "Assistant Data Access", "admin_panel_settings", AssistantDataAccess.class))
|
||||
.withUniqueKey(new UniqueKey("objectType", "objectName"))
|
||||
.withRecordLabelFormat("%s %s").withRecordLabelFields("objectType", "objectName"));
|
||||
|
||||
tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false))
|
||||
.withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id"));
|
||||
text(tables.get(tables.size() - 1), "paramsJson");
|
||||
|
||||
tables.forEach(AssistantRecordScreens::apply);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A settings table: the customizer checks rows before a save, masks the
|
||||
** key status after a read and drops the provider caches after a change.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData settings(QTableMetaData table)
|
||||
{
|
||||
QCodeReference customizer = new QCodeReference(AssistantSettingsCustomizer.class);
|
||||
return (table
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data access table: a change drops the runtime's cached modes.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData dataAccess(QTableMetaData table)
|
||||
{
|
||||
QCodeReference customizer = new QCodeReference(AssistantDataAccessCustomizer.class);
|
||||
return (table
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value source over conversations (for the conversationId fields).
|
||||
*******************************************************************************/
|
||||
@@ -180,6 +250,7 @@ public class AssistantMetaDataBuilder
|
||||
.withName(RETENTION_PROCESS_NAME)
|
||||
.withLabel("Assistant Retention")
|
||||
.withIcon(new QIcon("auto_delete"))
|
||||
.withTableName(AssistantConversation.TABLE_NAME)
|
||||
.withPermissionRules(adminRules())
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("purge")
|
||||
@@ -197,22 +268,19 @@ public class AssistantMetaDataBuilder
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Assistant Admin app over the tables and the retention process.
|
||||
** The Assistant app: the home widget, and the main tables in the nav.
|
||||
*******************************************************************************/
|
||||
public QAppMetaData buildAdminApp(List<QTableMetaData> tables, QProcessMetaData retention)
|
||||
public QAppMetaData buildAdminApp(List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(APP_NAME)
|
||||
.withLabel("Assistant Admin")
|
||||
.withLabel("Assistant")
|
||||
.withIcon(new QIcon("smart_toy"))
|
||||
.withPermissionRules(adminRules());
|
||||
for(QTableMetaData table : tables)
|
||||
.withPermissionRules(adminRules())
|
||||
.withWidgets(List.of(AssistantHome.WIDGET_NAME));
|
||||
for(String name : NAV_TABLES)
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
if(retention != null)
|
||||
{
|
||||
app.withChild(retention);
|
||||
tables.stream().filter(t -> t.getName().equals(name)).findFirst().ifPresent(app::withChild);
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
@@ -224,7 +292,7 @@ public class AssistantMetaDataBuilder
|
||||
** are explicit: left null, the enricher copies the host's default level
|
||||
** (often NOT_PROTECTED), and the meta-data path (app tree, table list) then
|
||||
** allows the object without ever calling the checker, so every user would
|
||||
** see the Assistant Admin app and its tables in the nav.
|
||||
** see the Assistant app and its tables in the nav.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules adminRules()
|
||||
{
|
||||
@@ -250,6 +318,11 @@ public class AssistantMetaDataBuilder
|
||||
.withPermissionRules(adminRules())
|
||||
.withFieldsFromEntity(entity);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// the assistant never reads its own tables (settings, audit) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
AiTableMetaData.ofOrWithNew(table).withExposure(AiExposure.NONE);
|
||||
|
||||
QFieldMetaData conversationId = table.getFields().get("conversationId");
|
||||
if(conversationId != null)
|
||||
{
|
||||
|
||||
@@ -22,14 +22,23 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -69,12 +78,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
public final class AssistantRecordScreens
|
||||
{
|
||||
public static final String EFFORT_PVS_NAME = "assistant_effort";
|
||||
public static final String OBJECT_TYPE_PVS_NAME = "assistant_object_type";
|
||||
public static final String DATA_ACCESS_MODE_PVS_NAME = "assistant_data_access_mode";
|
||||
public static final String DETAILS_SECTION = "details";
|
||||
static final String JSON = "json";
|
||||
static final String MICROS_HELP = "In micro-dollars: 1,000,000 is $1.00.";
|
||||
static final String CHILD_LIST_PREFIX = "conversation";
|
||||
|
||||
private static final List<String> DATES = List.of("createDate", "modifyDate");
|
||||
public static final String PROVIDER_TYPE_PVS_NAME = "assistant_provider_type";
|
||||
public static final String MODEL_PVS_NAME = "assistant_model";
|
||||
|
||||
private static final List<String> DATES = List.of("createDate", "modifyDate");
|
||||
private static final List<String> USAGE_LIST_FIELDS = List.of("purpose", "status", "model", "inputTokens", "outputTokens", "costMicros", "latencyMs", "createDate");
|
||||
|
||||
|
||||
|
||||
@@ -106,7 +121,24 @@ public final class AssistantRecordScreens
|
||||
new QPossibleValueSource().withName(EFFORT_PVS_NAME).withType(QPossibleValueSourceType.ENUM)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(efforts),
|
||||
QPossibleValueSource.newForTable(AssistantTurn.TABLE_NAME),
|
||||
QPossibleValueSource.newForTable(AssistantMessage.TABLE_NAME)));
|
||||
QPossibleValueSource.newForTable(AssistantMessage.TABLE_NAME),
|
||||
QPossibleValueSource.newForTable(AssistantPendingAction.TABLE_NAME),
|
||||
enumSource(OBJECT_TYPE_PVS_NAME, List.of(new QPossibleValue<>(AssistantDataAccess.TYPE_TABLE, "Table"), new QPossibleValue<>(AssistantDataAccess.TYPE_PROCESS, "Process"))),
|
||||
enumSource(DATA_ACCESS_MODE_PVS_NAME, List.of(
|
||||
new QPossibleValue<>(DataAccessMode.NONE.name(), DataAccessMode.NONE.getLabel()),
|
||||
new QPossibleValue<>(DataAccessMode.READ.name(), DataAccessMode.READ.getLabel()),
|
||||
new QPossibleValue<>(DataAccessMode.READ_WRITE.name(), DataAccessMode.READ_WRITE.getLabel())))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An enum possible-value source shown by label.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource enumSource(String name, List<QPossibleValue<?>> values)
|
||||
{
|
||||
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(values));
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +158,11 @@ public final class AssistantRecordScreens
|
||||
case AssistantFeedback.TABLE_NAME -> feedback(table);
|
||||
case AssistantPolicy.TABLE_NAME -> policy(table);
|
||||
case AssistantUsageDaily.TABLE_NAME -> usageDaily(table);
|
||||
case AssistantProvider.TABLE_NAME -> provider(table);
|
||||
case AssistantModelPrice.TABLE_NAME -> price(table);
|
||||
case AssistantUsage.TABLE_NAME -> usage(table);
|
||||
case AssistantDataAccess.TABLE_NAME -> dataAccess(table);
|
||||
case AssistantPendingAction.TABLE_NAME -> pendingAction(table);
|
||||
default ->
|
||||
{
|
||||
// not an assistant table
|
||||
@@ -149,8 +186,146 @@ public final class AssistantRecordScreens
|
||||
|
||||
childList(qInstance, conversation, AssistantMessage.TABLE_NAME, "Messages", "chat", List.of("seq", "role", "status", "model", "createDate"));
|
||||
childList(qInstance, conversation, AssistantTurn.TABLE_NAME, "Turns", "sync_alt", List.of("status", "model", "toolCallCount", "costMicros", "startedAt"));
|
||||
childList(qInstance, conversation, AssistantToolCall.TABLE_NAME, "Tool Calls", "build", List.of("toolName", "outcome", "rowCount", "durationMs", "createDate"));
|
||||
childList(qInstance, conversation, AssistantToolCall.TABLE_NAME, "Tool Calls", "build", List.of("toolName", "uiApiOperation", "httpStatus", "outcome", "tableName", "rowCount", "createDate"));
|
||||
childList(qInstance, conversation, AssistantPendingAction.TABLE_NAME, "Pending Actions", "pending_actions", List.of("toolName", "tableName", "recordId", "status", "httpStatus", "createDate"));
|
||||
childList(qInstance, conversation, AssistantFeedback.TABLE_NAME, "Feedback", "thumbs_up_down", List.of("rating", "reason", "createDate"));
|
||||
childList(qInstance, conversation, AssistantUsage.TABLE_NAME, "Model Calls", "receipt_long", USAGE_LIST_FIELDS);
|
||||
|
||||
QTableMetaData provider = qInstance.getTable(AssistantProvider.TABLE_NAME);
|
||||
if(provider != null)
|
||||
{
|
||||
childList(qInstance, provider, AssistantUsage.TABLE_NAME, "Model Calls", "receipt_long", USAGE_LIST_FIELDS, "providerId", "provider");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible-value sources of the settings tables: the registered provider
|
||||
** types, the models they offer (plus any priced model), and the providers.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> buildSettingsPossibleValueSources(AssistantQBitConfig config)
|
||||
{
|
||||
List<QPossibleValue<?>> types = new ArrayList<>();
|
||||
Set<String> models = new LinkedHashSet<>();
|
||||
for(AssistantProviderType type : config.getProviderTypes().values())
|
||||
{
|
||||
types.add(new QPossibleValue<>(type.getType(), type.getLabel()));
|
||||
models.addAll(type.getModels());
|
||||
}
|
||||
if(config.getPricing() != null)
|
||||
{
|
||||
models.addAll(config.getPricing().keySet());
|
||||
}
|
||||
if(types.isEmpty())
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// no type registered: offer the configured provider's name, so //
|
||||
// the field still has a value (rows of it cannot be used) //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
types.add(new QPossibleValue<>(config.getProviderName(), config.getProviderName()));
|
||||
}
|
||||
List<QPossibleValue<?>> modelValues = new ArrayList<>();
|
||||
models.forEach(m -> modelValues.add(new QPossibleValue<>(m, m)));
|
||||
|
||||
return (List.of(
|
||||
new QPossibleValueSource().withName(PROVIDER_TYPE_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(types),
|
||||
new QPossibleValueSource().withName(MODEL_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(modelValues),
|
||||
QPossibleValueSource.newForTable(AssistantProvider.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider settings screen.
|
||||
*******************************************************************************/
|
||||
private static void provider(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Type");
|
||||
pvs(table, "model", MODEL_PVS_NAME, "Model");
|
||||
table.getField("isDefault").withLabel("Default");
|
||||
table.getField("apiKeyEnvName").withLabel("API Key Variable");
|
||||
table.getField("baseUrl").withLabel("Base URL Override");
|
||||
table.getField("monthlyCostCapUsd").withLabel("Monthly Cost Cap (USD)").withDisplayFormat(DisplayFormat.CURRENCY);
|
||||
counts(table, "maxOutputTokensPerRequest", "lastTestLatencyMs");
|
||||
table.getField("lastTestLatencyMs").withLabel("Last Test Latency (ms)");
|
||||
help(table, "name", "A name for this provider, e.g. Claude (production).");
|
||||
help(table, "providerType", "Which kind of provider this is. Only the types installed on this server are offered.");
|
||||
help(table, "model", "The model answers come from. Empty uses the policy's (or the application's) main model.");
|
||||
help(table, "enabled", "Off: the assistant never uses this provider.");
|
||||
help(table, "isDefault", "The assistant uses the default enabled provider (else the first enabled one). Marking one default unmarks the others.");
|
||||
help(table, "apiKeyEnvName", "The NAME of the environment variable that holds the API key (e.g. ANTHROPIC_API_KEY), never the key itself. "
|
||||
+ "The key stays in the server's secret store; only allowed variable names are accepted. Empty uses the type's default variable. To replace the key, change it in the secret store.");
|
||||
help(table, "baseUrl", "Leave empty to use the provider's own API. Other URLs must be allowed in the server's configuration.");
|
||||
help(table, "monthlyCostCapUsd", "When this provider's cost this month (UTC) reaches the cap, new requests are refused. Empty: no cap.");
|
||||
help(table, "maxOutputTokensPerRequest", "The most output tokens one model call may produce. Empty uses the configured limit.");
|
||||
help(table, "maxConcurrentRequests", "How many model calls may run at the same time on this server. Empty: no limit.");
|
||||
table.getField(ProviderService.KEY_STATUS_FIELD).withLabel("API Key").withHelpContent(new QHelpContent("Whether the key variable is set on this server, with the key's last four characters. The key itself is never shown or returned."));
|
||||
sections(table,
|
||||
section("provider", "Provider", "settings_input_component", Tier.T1, "id", "name", "providerType", "model", "enabled", "isDefault"),
|
||||
section("connection", "Connection", "key", Tier.T2, "apiKeyEnvName", ProviderService.KEY_STATUS_FIELD, "baseUrl"),
|
||||
section("limits", "Limits", "speed", Tier.T2, "monthlyCostCapUsd", "maxOutputTokensPerRequest", "maxConcurrentRequests"),
|
||||
section("lastTest", "Last connection test", "network_check", Tier.T2, "lastTestAt", "lastTestStatus", "lastTestLatencyMs", "lastTestModel", "lastTestError"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The price table.
|
||||
*******************************************************************************/
|
||||
private static void price(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Provider Type");
|
||||
for(String field : List.of("inputUsdPerMTok", "outputUsdPerMTok", "cacheWrite5mUsdPerMTok", "cacheWrite1hUsdPerMTok"))
|
||||
{
|
||||
help(table, field, "USD per million tokens.");
|
||||
}
|
||||
table.getField("inputUsdPerMTok").withLabel("Input (USD / MTok)");
|
||||
table.getField("outputUsdPerMTok").withLabel("Output (USD / MTok)");
|
||||
table.getField("cacheReadUsdPerMTok").withLabel("Cache Read (USD / MTok)");
|
||||
table.getField("cacheWrite5mUsdPerMTok").withLabel("Cache Write 5m (USD / MTok)");
|
||||
table.getField("cacheWrite1hUsdPerMTok").withLabel("Cache Write 1h (USD / MTok)");
|
||||
help(table, "model", "The exact model id, e.g. claude-sonnet-5-5.");
|
||||
help(table, "cacheReadUsdPerMTok", "USD per million tokens. Empty: 0.1 x the input price.");
|
||||
sections(table,
|
||||
section("price", "Model", "sell", Tier.T1, "id", "providerType", "model"),
|
||||
section("prices", "Prices", "payments", Tier.T2, "inputUsdPerMTok", "outputUsdPerMTok", "cacheReadUsdPerMTok", "cacheWrite5mUsdPerMTok", "cacheWrite1hUsdPerMTok"),
|
||||
section("notes", "Notes", "notes", Tier.T3, "notes"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One model call.
|
||||
*******************************************************************************/
|
||||
private static void usage(QTableMetaData table)
|
||||
{
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
link(table, "providerId", AssistantProvider.TABLE_NAME, "Provider");
|
||||
micros(table, "costMicros");
|
||||
counts(table, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "latencyMs");
|
||||
table.getField("latencyMs").withLabel("Latency (ms)");
|
||||
help(table, "purpose", "TURN: a step of an answer. TEST: a Test connection call.");
|
||||
sections(table,
|
||||
section("call", "Model Call", "receipt_long", Tier.T1, "id", "purpose", "status", "model", "providerId", "providerType", "userId", "conversationId", "turnId"),
|
||||
section("result", "Result", "fact_check", Tier.T2, "latencyMs", "errorKind", "httpStatus", "requestId"),
|
||||
section("usage", "Tokens and cost", "query_stats", Tier.T2, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costMicros"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A field picked from a possible-value source.
|
||||
*******************************************************************************/
|
||||
private static void pvs(QTableMetaData table, String fieldName, String pvsName, String label)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
field.withPossibleValueSourceName(pvsName).withLabel(label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +396,41 @@ public final class AssistantRecordScreens
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Data access: what, the mode, notes.
|
||||
*******************************************************************************/
|
||||
private static void dataAccess(QTableMetaData table)
|
||||
{
|
||||
table.getField("objectType").withLabel("Type").withDefaultValue(AssistantDataAccess.TYPE_TABLE);
|
||||
table.getField("objectName").withLabel("Table or process name")
|
||||
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE."));
|
||||
table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name())
|
||||
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE."));
|
||||
sections(table,
|
||||
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"),
|
||||
section("notes", "Notes", "notes", Tier.T2, "notes"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pending action: the proposed write, its decision and result.
|
||||
*******************************************************************************/
|
||||
private static void pendingAction(QTableMetaData table)
|
||||
{
|
||||
json(table, "paramsJson");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
table.getField("paramsJson").withLabel("Values");
|
||||
table.getField("uiApiOperation").withLabel("UI API operation");
|
||||
table.getField("httpStatus").withLabel("HTTP status");
|
||||
sections(table,
|
||||
section("action", "Pending Action", "pending_actions", Tier.T1, "id", "summary", "status", "toolName", "tableName", "recordId", "userId", "conversationId", "turnId"),
|
||||
section("decision", "Decision and result", "fact_check", Tier.T2, "expiresAt", "decidedAt", "uiApiOperation", "httpStatus", "resultSummary"),
|
||||
section("payload", "Values", "data_object", Tier.T3, "paramsJson", "toolCallId"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -234,10 +444,14 @@ public final class AssistantRecordScreens
|
||||
table.getField("inputJson").withLabel("Input");
|
||||
table.getField("resultJson").withLabel("Result");
|
||||
table.getField("redactionsJson").withLabel("Redactions");
|
||||
table.getField("uiApiOperation").withLabel("UI API operation");
|
||||
table.getField("httpStatus").withLabel("HTTP status");
|
||||
table.getField("auditHref").withLabel("Audit");
|
||||
link(table, "pendingActionId", AssistantPendingAction.TABLE_NAME, "Pending action");
|
||||
sections(table,
|
||||
section("call", "Tool Call", "build", Tier.T1, "id", "toolName", "outcome", "conversationId", "turnId", "toolCallId", "userId"),
|
||||
section("target", "Target and permission", "policy", Tier.T2, "tableName", "processName", "widgetName", "permissionCheck"),
|
||||
section("result", "Result", "fact_check", Tier.T2, "resultSummary", "rowCount", "durationMs"),
|
||||
section("target", "Target and permission", "policy", Tier.T2, "tableName", "processName", "widgetName", "uiApiOperation", "httpStatus", "permissionCheck"),
|
||||
section("result", "Result", "fact_check", Tier.T2, "resultSummary", "rowCount", "durationMs", "pendingActionId", "auditHref"),
|
||||
section("payloads", "Payloads", "data_object", Tier.T3, "inputJson", "resultJson", "redactionsJson"));
|
||||
}
|
||||
|
||||
@@ -272,6 +486,10 @@ public final class AssistantRecordScreens
|
||||
help(table, "mainModel", "The model id answers come from; empty uses the model configured for the application.");
|
||||
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
|
||||
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
|
||||
help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions.");
|
||||
help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool.");
|
||||
help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200.");
|
||||
help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them.");
|
||||
for(String scope : List.of("userDaily", "instanceMonthly"))
|
||||
{
|
||||
String who = scope.equals("userDaily") ? "one user in a day" : "the whole instance in a month";
|
||||
@@ -285,6 +503,7 @@ public final class AssistantRecordScreens
|
||||
sections(table,
|
||||
section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"),
|
||||
section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"),
|
||||
section("tools", "Tools and data", "build", Tier.T2, "writesRequireConfirmation", "allowedTools", "maxRowsPerCall", "redactPii"),
|
||||
section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"),
|
||||
section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard"));
|
||||
}
|
||||
@@ -457,6 +676,17 @@ public final class AssistantRecordScreens
|
||||
** children on the conversation's view (read-only).
|
||||
*******************************************************************************/
|
||||
private static void childList(QInstance qInstance, QTableMetaData conversation, String childTableName, String label, String icon, List<String> fields)
|
||||
{
|
||||
childList(qInstance, conversation, childTableName, label, icon, fields, "conversationId", CHILD_LIST_PREFIX);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Join a parent to a child table on one of the child's fields and list the
|
||||
** children on the parent's view (read-only).
|
||||
*******************************************************************************/
|
||||
private static void childList(QInstance qInstance, QTableMetaData conversation, String childTableName, String label, String icon, List<String> fields, String joinField, String sectionPrefix)
|
||||
{
|
||||
if(qInstance.getTable(childTableName) == null)
|
||||
{
|
||||
@@ -468,7 +698,7 @@ public final class AssistantRecordScreens
|
||||
.withRightTable(childTableName)
|
||||
.withInferredName()
|
||||
.withType(JoinType.ONE_TO_MANY)
|
||||
.withJoinOn(new JoinOn("id", "conversationId"));
|
||||
.withJoinOn(new JoinOn("id", joinField));
|
||||
if(qInstance.getJoin(join.getName()) != null)
|
||||
{
|
||||
return;
|
||||
@@ -484,7 +714,7 @@ public final class AssistantRecordScreens
|
||||
.withOnlyIncludeFieldNames(shown)
|
||||
.getWidgetMetaData());
|
||||
|
||||
String sectionName = CHILD_LIST_PREFIX + label.replace(" ", "");
|
||||
String sectionName = sectionPrefix + label.replace(" ", "");
|
||||
conversation.addSection(new QFieldSection(sectionName, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(join.getName()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Customizer of the settings tables (assistant_provider, assistant_model_price).
|
||||
**
|
||||
** assistant_provider:
|
||||
** - Before a save, the row is checked (ProviderService.validate). Above all,
|
||||
** apiKeyEnvName must be the NAME of an allowed environment variable, so a
|
||||
** key pasted into it by mistake is refused and never stored.
|
||||
** - After a save, a row marked default makes every other row non-default.
|
||||
** - After a read, the virtual field apiKeyStatus says whether the key is set,
|
||||
** with its last four characters at most. No read path returns the key.
|
||||
**
|
||||
** Both tables: a change drops the cached provider rows and prices.
|
||||
*******************************************************************************/
|
||||
public class AssistantSettingsCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the masked key status to provider rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface queryInput, List<QRecord> records) throws QException
|
||||
{
|
||||
ProviderService service = service();
|
||||
if(service == null || !AssistantProvider.TABLE_NAME.equals(queryInput.getTableName()))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.setValue(ProviderService.KEY_STATUS_FIELD, service.keyStatus(new AssistantProvider(record)));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check provider rows before insert and update.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
ProviderService service = service();
|
||||
if(service == null || !AssistantProvider.TABLE_NAME.equals(tableName(input)))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.removeValue(ProviderService.KEY_STATUS_FIELD);
|
||||
if(isSystemUpdate(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QRecord merged = new QRecord();
|
||||
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new java.util.LinkedHashMap<>(o.getValues())));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
|
||||
for(String error : service.validate(new AssistantProvider(merged)))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(error));
|
||||
}
|
||||
if(record.getValue("enabled") == null && merged.getValue("enabled") == null)
|
||||
{
|
||||
record.setValue("enabled", true);
|
||||
}
|
||||
if(record.getValue("isDefault") == null && merged.getValue("isDefault") == null)
|
||||
{
|
||||
record.setValue("isDefault", false);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep one default provider; drop caches.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
if(AssistantProvider.TABLE_NAME.equals(tableName(input)))
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(Boolean.TRUE.equals(record.getValueBoolean("isDefault")) && record.getValue("id") != null && record.getErrors().isEmpty())
|
||||
{
|
||||
runtime.getStore().clearOtherDefaultProviders(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.getProviderService().invalidate();
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop caches after a delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
ProviderService service = service();
|
||||
if(service != null)
|
||||
{
|
||||
service.invalidate();
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An update by the qbit itself: only the last test result, or clearing the
|
||||
** default flag. These are saved even for a row whose settings are invalid.
|
||||
*******************************************************************************/
|
||||
static boolean isSystemUpdate(QRecord record)
|
||||
{
|
||||
if(record.getValue("id") == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(Map.Entry<String, Serializable> entry : record.getValues().entrySet())
|
||||
{
|
||||
String name = entry.getKey();
|
||||
boolean system = name.equals("id") || name.equals("modifyDate") || name.startsWith("lastTest")
|
||||
|| (name.equals("isDefault") && Boolean.FALSE.equals(entry.getValue()));
|
||||
if(!system)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String tableName(AbstractActionInput input)
|
||||
{
|
||||
if(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput insert)
|
||||
{
|
||||
return (insert.getTableName());
|
||||
}
|
||||
if(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput update)
|
||||
{
|
||||
return (update.getTableName());
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static ProviderService service()
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
return (runtime == null ? null : runtime.getProviderService());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The assistant's data access for one table or process: NONE (never), READ (read tools) or READ_WRITE (read and write tools). The assistant still acts with the user's own session, so this only ever narrows what the user may do. Tables and processes without a row use the default (NONE for sensitive tables, READ for other tables, NONE for processes).
|
||||
*******************************************************************************/
|
||||
public class AssistantDataAccess extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_data_access";
|
||||
public static final String TYPE_TABLE = "TABLE";
|
||||
public static final String TYPE_PROCESS = "PROCESS";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "assistant_object_type")
|
||||
private String objectType;
|
||||
|
||||
@QField(isRequired = true, maxLength = 128)
|
||||
private String objectName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "assistant_data_access_mode")
|
||||
private String mode;
|
||||
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String notes;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for objectType
|
||||
*******************************************************************************/
|
||||
public String getObjectType()
|
||||
{
|
||||
return (this.objectType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for objectType
|
||||
*******************************************************************************/
|
||||
public void setObjectType(String objectType)
|
||||
{
|
||||
this.objectType = objectType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for objectType
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withObjectType(String objectType)
|
||||
{
|
||||
this.objectType = objectType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for objectName
|
||||
*******************************************************************************/
|
||||
public String getObjectName()
|
||||
{
|
||||
return (this.objectName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for objectName
|
||||
*******************************************************************************/
|
||||
public void setObjectName(String objectName)
|
||||
{
|
||||
this.objectName = objectName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for objectName
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withObjectName(String objectName)
|
||||
{
|
||||
this.objectName = objectName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mode
|
||||
*******************************************************************************/
|
||||
public String getMode()
|
||||
{
|
||||
return (this.mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for mode
|
||||
*******************************************************************************/
|
||||
public void setMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mode
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notes
|
||||
*******************************************************************************/
|
||||
public String getNotes()
|
||||
{
|
||||
return (this.notes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notes
|
||||
*******************************************************************************/
|
||||
public void setNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notes
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The price of one model, in USD per million tokens, used to compute the cost
|
||||
** of every model call. Seeded with the Anthropic list prices; editable.
|
||||
*******************************************************************************/
|
||||
public class AssistantModelPrice extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_model_price";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 50)
|
||||
private String providerType;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String model;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private BigDecimal inputUsdPerMTok;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private BigDecimal outputUsdPerMTok;
|
||||
|
||||
@QField()
|
||||
private BigDecimal cacheReadUsdPerMTok;
|
||||
|
||||
@QField()
|
||||
private BigDecimal cacheWrite5mUsdPerMTok;
|
||||
|
||||
@QField()
|
||||
private BigDecimal cacheWrite1hUsdPerMTok;
|
||||
|
||||
@QField(maxLength = 255)
|
||||
private String notes;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerType
|
||||
*******************************************************************************/
|
||||
public String getProviderType()
|
||||
{
|
||||
return (this.providerType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerType
|
||||
*******************************************************************************/
|
||||
public void setProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerType
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for model
|
||||
*******************************************************************************/
|
||||
public String getModel()
|
||||
{
|
||||
return (this.model);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for model
|
||||
*******************************************************************************/
|
||||
public void setModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for model
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for inputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public BigDecimal getInputUsdPerMTok()
|
||||
{
|
||||
return (this.inputUsdPerMTok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for inputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public void setInputUsdPerMTok(BigDecimal inputUsdPerMTok)
|
||||
{
|
||||
this.inputUsdPerMTok = inputUsdPerMTok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for inputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withInputUsdPerMTok(BigDecimal inputUsdPerMTok)
|
||||
{
|
||||
this.inputUsdPerMTok = inputUsdPerMTok;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public BigDecimal getOutputUsdPerMTok()
|
||||
{
|
||||
return (this.outputUsdPerMTok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for outputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public void setOutputUsdPerMTok(BigDecimal outputUsdPerMTok)
|
||||
{
|
||||
this.outputUsdPerMTok = outputUsdPerMTok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outputUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withOutputUsdPerMTok(BigDecimal outputUsdPerMTok)
|
||||
{
|
||||
this.outputUsdPerMTok = outputUsdPerMTok;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cacheReadUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public BigDecimal getCacheReadUsdPerMTok()
|
||||
{
|
||||
return (this.cacheReadUsdPerMTok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cacheReadUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public void setCacheReadUsdPerMTok(BigDecimal cacheReadUsdPerMTok)
|
||||
{
|
||||
this.cacheReadUsdPerMTok = cacheReadUsdPerMTok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cacheReadUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withCacheReadUsdPerMTok(BigDecimal cacheReadUsdPerMTok)
|
||||
{
|
||||
this.cacheReadUsdPerMTok = cacheReadUsdPerMTok;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cacheWrite5mUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public BigDecimal getCacheWrite5mUsdPerMTok()
|
||||
{
|
||||
return (this.cacheWrite5mUsdPerMTok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cacheWrite5mUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public void setCacheWrite5mUsdPerMTok(BigDecimal cacheWrite5mUsdPerMTok)
|
||||
{
|
||||
this.cacheWrite5mUsdPerMTok = cacheWrite5mUsdPerMTok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cacheWrite5mUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withCacheWrite5mUsdPerMTok(BigDecimal cacheWrite5mUsdPerMTok)
|
||||
{
|
||||
this.cacheWrite5mUsdPerMTok = cacheWrite5mUsdPerMTok;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cacheWrite1hUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public BigDecimal getCacheWrite1hUsdPerMTok()
|
||||
{
|
||||
return (this.cacheWrite1hUsdPerMTok);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cacheWrite1hUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public void setCacheWrite1hUsdPerMTok(BigDecimal cacheWrite1hUsdPerMTok)
|
||||
{
|
||||
this.cacheWrite1hUsdPerMTok = cacheWrite1hUsdPerMTok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cacheWrite1hUsdPerMTok
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withCacheWrite1hUsdPerMTok(BigDecimal cacheWrite1hUsdPerMTok)
|
||||
{
|
||||
this.cacheWrite1hUsdPerMTok = cacheWrite1hUsdPerMTok;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notes
|
||||
*******************************************************************************/
|
||||
public String getNotes()
|
||||
{
|
||||
return (this.notes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notes
|
||||
*******************************************************************************/
|
||||
public void setNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notes
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantModelPrice withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A write the assistant proposed and the user must confirm. Confirming runs the stored UI API operation with the confirming user's own session; nothing runs before that.
|
||||
*******************************************************************************/
|
||||
public class AssistantPendingAction extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_pending_action";
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_EXECUTED = "EXECUTED";
|
||||
public static final String STATUS_FAILED = "FAILED";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
public static final String STATUS_EXPIRED = "EXPIRED";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = AssistantConversation.TABLE_NAME)
|
||||
private Integer conversationId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer turnId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
private String toolCallId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String toolName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String uiApiOperation;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
private String tableName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
private String recordId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String paramsJson;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String summary;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer httpStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String resultSummary;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant decidedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for conversationId
|
||||
*******************************************************************************/
|
||||
public Integer getConversationId()
|
||||
{
|
||||
return (this.conversationId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for conversationId
|
||||
*******************************************************************************/
|
||||
public void setConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for conversationId
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnId
|
||||
*******************************************************************************/
|
||||
public Integer getTurnId()
|
||||
{
|
||||
return (this.turnId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnId
|
||||
*******************************************************************************/
|
||||
public void setTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnId
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for toolCallId
|
||||
*******************************************************************************/
|
||||
public String getToolCallId()
|
||||
{
|
||||
return (this.toolCallId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for toolCallId
|
||||
*******************************************************************************/
|
||||
public void setToolCallId(String toolCallId)
|
||||
{
|
||||
this.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for toolCallId
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withToolCallId(String toolCallId)
|
||||
{
|
||||
this.toolCallId = toolCallId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for toolName
|
||||
*******************************************************************************/
|
||||
public String getToolName()
|
||||
{
|
||||
return (this.toolName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for toolName
|
||||
*******************************************************************************/
|
||||
public void setToolName(String toolName)
|
||||
{
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for toolName
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withToolName(String toolName)
|
||||
{
|
||||
this.toolName = toolName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiOperation
|
||||
*******************************************************************************/
|
||||
public String getUiApiOperation()
|
||||
{
|
||||
return (this.uiApiOperation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for uiApiOperation
|
||||
*******************************************************************************/
|
||||
public void setUiApiOperation(String uiApiOperation)
|
||||
{
|
||||
this.uiApiOperation = uiApiOperation;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uiApiOperation
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withUiApiOperation(String uiApiOperation)
|
||||
{
|
||||
this.uiApiOperation = uiApiOperation;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordId
|
||||
*******************************************************************************/
|
||||
public String getRecordId()
|
||||
{
|
||||
return (this.recordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordId
|
||||
*******************************************************************************/
|
||||
public void setRecordId(String recordId)
|
||||
{
|
||||
this.recordId = recordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordId
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withRecordId(String recordId)
|
||||
{
|
||||
this.recordId = recordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for paramsJson
|
||||
*******************************************************************************/
|
||||
public String getParamsJson()
|
||||
{
|
||||
return (this.paramsJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for paramsJson
|
||||
*******************************************************************************/
|
||||
public void setParamsJson(String paramsJson)
|
||||
{
|
||||
this.paramsJson = paramsJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for paramsJson
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withParamsJson(String paramsJson)
|
||||
{
|
||||
this.paramsJson = paramsJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for summary
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (this.summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for summary
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for summary
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (this.status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for httpStatus
|
||||
*******************************************************************************/
|
||||
public Integer getHttpStatus()
|
||||
{
|
||||
return (this.httpStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public void setHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultSummary
|
||||
*******************************************************************************/
|
||||
public String getResultSummary()
|
||||
{
|
||||
return (this.resultSummary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for resultSummary
|
||||
*******************************************************************************/
|
||||
public void setResultSummary(String resultSummary)
|
||||
{
|
||||
this.resultSummary = resultSummary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resultSummary
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withResultSummary(String resultSummary)
|
||||
{
|
||||
this.resultSummary = resultSummary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expiresAt
|
||||
*******************************************************************************/
|
||||
public Instant getExpiresAt()
|
||||
{
|
||||
return (this.expiresAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for expiresAt
|
||||
*******************************************************************************/
|
||||
public void setExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expiresAt
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for decidedAt
|
||||
*******************************************************************************/
|
||||
public Instant getDecidedAt()
|
||||
{
|
||||
return (this.decidedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for decidedAt
|
||||
*******************************************************************************/
|
||||
public void setDecidedAt(Instant decidedAt)
|
||||
{
|
||||
this.decidedAt = decidedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for decidedAt
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withDecidedAt(Instant decidedAt)
|
||||
{
|
||||
this.decidedAt = decidedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,6 +82,18 @@ public class AssistantPolicy extends QRecordEntity
|
||||
@QField()
|
||||
private Long instanceMonthlyCostMicrosHard;
|
||||
|
||||
@QField()
|
||||
private Boolean writesRequireConfirmation;
|
||||
|
||||
@QField(maxLength = 1000)
|
||||
private String allowedTools;
|
||||
|
||||
@QField()
|
||||
private Integer maxRowsPerCall;
|
||||
|
||||
@QField()
|
||||
private Boolean redactPii;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -603,4 +615,124 @@ public class AssistantPolicy extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writesRequireConfirmation (whether every write waits for the user to confirm a card (default true))
|
||||
*******************************************************************************/
|
||||
public Boolean getWritesRequireConfirmation()
|
||||
{
|
||||
return (this.writesRequireConfirmation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writesRequireConfirmation
|
||||
*******************************************************************************/
|
||||
public void setWritesRequireConfirmation(Boolean writesRequireConfirmation)
|
||||
{
|
||||
this.writesRequireConfirmation = writesRequireConfirmation;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writesRequireConfirmation
|
||||
*******************************************************************************/
|
||||
public AssistantPolicy withWritesRequireConfirmation(Boolean writesRequireConfirmation)
|
||||
{
|
||||
this.writesRequireConfirmation = writesRequireConfirmation;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedTools (comma-separated tool names; empty allows all)
|
||||
*******************************************************************************/
|
||||
public String getAllowedTools()
|
||||
{
|
||||
return (this.allowedTools);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allowedTools
|
||||
*******************************************************************************/
|
||||
public void setAllowedTools(String allowedTools)
|
||||
{
|
||||
this.allowedTools = allowedTools;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedTools
|
||||
*******************************************************************************/
|
||||
public AssistantPolicy withAllowedTools(String allowedTools)
|
||||
{
|
||||
this.allowedTools = allowedTools;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxRowsPerCall (most rows one tool call may return to the model)
|
||||
*******************************************************************************/
|
||||
public Integer getMaxRowsPerCall()
|
||||
{
|
||||
return (this.maxRowsPerCall);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxRowsPerCall
|
||||
*******************************************************************************/
|
||||
public void setMaxRowsPerCall(Integer maxRowsPerCall)
|
||||
{
|
||||
this.maxRowsPerCall = maxRowsPerCall;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxRowsPerCall
|
||||
*******************************************************************************/
|
||||
public AssistantPolicy withMaxRowsPerCall(Integer maxRowsPerCall)
|
||||
{
|
||||
this.maxRowsPerCall = maxRowsPerCall;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for redactPii (whether secrets and personal identifiers are redacted from tool results (default true))
|
||||
*******************************************************************************/
|
||||
public Boolean getRedactPii()
|
||||
{
|
||||
return (this.redactPii);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for redactPii
|
||||
*******************************************************************************/
|
||||
public void setRedactPii(Boolean redactPii)
|
||||
{
|
||||
this.redactPii = redactPii;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for redactPii
|
||||
*******************************************************************************/
|
||||
public AssistantPolicy withRedactPii(Boolean redactPii)
|
||||
{
|
||||
this.redactPii = redactPii;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A model provider the assistant can use: its type, model, connection,
|
||||
** limits and the result of its last connection test (the provider settings
|
||||
** screen). The API key itself is never stored: apiKeyEnvName names the
|
||||
** environment variable (or host secret) that holds it, resolved at runtime.
|
||||
** The read-only apiKeyStatus field (filled on read, never stored) shows whether it is set and its
|
||||
** last four characters.
|
||||
*******************************************************************************/
|
||||
public class AssistantProvider extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_provider";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField(isRequired = true, maxLength = 50)
|
||||
private String providerType;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String model;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField()
|
||||
private Boolean isDefault;
|
||||
|
||||
@QField(maxLength = 255)
|
||||
private String baseUrl;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String apiKeyEnvName;
|
||||
|
||||
/*******************************************************************************
|
||||
** Computed when a row is read (whether the key is set, and its last four
|
||||
** characters); never written - saves drop it.
|
||||
*******************************************************************************/
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
private String apiKeyStatus;
|
||||
|
||||
@QField()
|
||||
private BigDecimal monthlyCostCapUsd;
|
||||
|
||||
@QField()
|
||||
private Integer maxOutputTokensPerRequest;
|
||||
|
||||
@QField()
|
||||
private Integer maxConcurrentRequests;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant lastTestAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String lastTestStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer lastTestLatencyMs;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String lastTestModel;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500)
|
||||
private String lastTestError;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerType
|
||||
*******************************************************************************/
|
||||
public String getProviderType()
|
||||
{
|
||||
return (this.providerType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerType
|
||||
*******************************************************************************/
|
||||
public void setProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerType
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for model
|
||||
*******************************************************************************/
|
||||
public String getModel()
|
||||
{
|
||||
return (this.model);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for model
|
||||
*******************************************************************************/
|
||||
public void setModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for model
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for isDefault
|
||||
*******************************************************************************/
|
||||
public Boolean getIsDefault()
|
||||
{
|
||||
return (this.isDefault);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for isDefault
|
||||
*******************************************************************************/
|
||||
public void setIsDefault(Boolean isDefault)
|
||||
{
|
||||
this.isDefault = isDefault;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for isDefault
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withIsDefault(Boolean isDefault)
|
||||
{
|
||||
this.isDefault = isDefault;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for baseUrl
|
||||
*******************************************************************************/
|
||||
public String getBaseUrl()
|
||||
{
|
||||
return (this.baseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for baseUrl
|
||||
*******************************************************************************/
|
||||
public void setBaseUrl(String baseUrl)
|
||||
{
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for baseUrl
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withBaseUrl(String baseUrl)
|
||||
{
|
||||
this.baseUrl = baseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public String getApiKeyEnvName()
|
||||
{
|
||||
return (this.apiKeyEnvName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public void setApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for monthlyCostCapUsd
|
||||
*******************************************************************************/
|
||||
public BigDecimal getMonthlyCostCapUsd()
|
||||
{
|
||||
return (this.monthlyCostCapUsd);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for monthlyCostCapUsd
|
||||
*******************************************************************************/
|
||||
public void setMonthlyCostCapUsd(BigDecimal monthlyCostCapUsd)
|
||||
{
|
||||
this.monthlyCostCapUsd = monthlyCostCapUsd;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for monthlyCostCapUsd
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withMonthlyCostCapUsd(BigDecimal monthlyCostCapUsd)
|
||||
{
|
||||
this.monthlyCostCapUsd = monthlyCostCapUsd;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxOutputTokensPerRequest
|
||||
*******************************************************************************/
|
||||
public Integer getMaxOutputTokensPerRequest()
|
||||
{
|
||||
return (this.maxOutputTokensPerRequest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxOutputTokensPerRequest
|
||||
*******************************************************************************/
|
||||
public void setMaxOutputTokensPerRequest(Integer maxOutputTokensPerRequest)
|
||||
{
|
||||
this.maxOutputTokensPerRequest = maxOutputTokensPerRequest;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxOutputTokensPerRequest
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withMaxOutputTokensPerRequest(Integer maxOutputTokensPerRequest)
|
||||
{
|
||||
this.maxOutputTokensPerRequest = maxOutputTokensPerRequest;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxConcurrentRequests
|
||||
*******************************************************************************/
|
||||
public Integer getMaxConcurrentRequests()
|
||||
{
|
||||
return (this.maxConcurrentRequests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxConcurrentRequests
|
||||
*******************************************************************************/
|
||||
public void setMaxConcurrentRequests(Integer maxConcurrentRequests)
|
||||
{
|
||||
this.maxConcurrentRequests = maxConcurrentRequests;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxConcurrentRequests
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withMaxConcurrentRequests(Integer maxConcurrentRequests)
|
||||
{
|
||||
this.maxConcurrentRequests = maxConcurrentRequests;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTestAt
|
||||
*******************************************************************************/
|
||||
public Instant getLastTestAt()
|
||||
{
|
||||
return (this.lastTestAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTestAt
|
||||
*******************************************************************************/
|
||||
public void setLastTestAt(Instant lastTestAt)
|
||||
{
|
||||
this.lastTestAt = lastTestAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTestAt
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withLastTestAt(Instant lastTestAt)
|
||||
{
|
||||
this.lastTestAt = lastTestAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTestStatus
|
||||
*******************************************************************************/
|
||||
public String getLastTestStatus()
|
||||
{
|
||||
return (this.lastTestStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTestStatus
|
||||
*******************************************************************************/
|
||||
public void setLastTestStatus(String lastTestStatus)
|
||||
{
|
||||
this.lastTestStatus = lastTestStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTestStatus
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withLastTestStatus(String lastTestStatus)
|
||||
{
|
||||
this.lastTestStatus = lastTestStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTestLatencyMs
|
||||
*******************************************************************************/
|
||||
public Integer getLastTestLatencyMs()
|
||||
{
|
||||
return (this.lastTestLatencyMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTestLatencyMs
|
||||
*******************************************************************************/
|
||||
public void setLastTestLatencyMs(Integer lastTestLatencyMs)
|
||||
{
|
||||
this.lastTestLatencyMs = lastTestLatencyMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTestLatencyMs
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withLastTestLatencyMs(Integer lastTestLatencyMs)
|
||||
{
|
||||
this.lastTestLatencyMs = lastTestLatencyMs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTestModel
|
||||
*******************************************************************************/
|
||||
public String getLastTestModel()
|
||||
{
|
||||
return (this.lastTestModel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTestModel
|
||||
*******************************************************************************/
|
||||
public void setLastTestModel(String lastTestModel)
|
||||
{
|
||||
this.lastTestModel = lastTestModel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTestModel
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withLastTestModel(String lastTestModel)
|
||||
{
|
||||
this.lastTestModel = lastTestModel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastTestError
|
||||
*******************************************************************************/
|
||||
public String getLastTestError()
|
||||
{
|
||||
return (this.lastTestError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastTestError
|
||||
*******************************************************************************/
|
||||
public void setLastTestError(String lastTestError)
|
||||
{
|
||||
this.lastTestError = lastTestError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastTestError
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withLastTestError(String lastTestError)
|
||||
{
|
||||
this.lastTestError = lastTestError;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiKeyStatus (masked key status, computed on read)
|
||||
*******************************************************************************/
|
||||
public String getApiKeyStatus()
|
||||
{
|
||||
return (this.apiKeyStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiKeyStatus
|
||||
*******************************************************************************/
|
||||
public void setApiKeyStatus(String apiKeyStatus)
|
||||
{
|
||||
this.apiKeyStatus = apiKeyStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiKeyStatus
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withApiKeyStatus(String apiKeyStatus)
|
||||
{
|
||||
this.apiKeyStatus = apiKeyStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -91,6 +91,18 @@ public class AssistantToolCall extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private String redactionsJson;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String uiApiOperation;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer httpStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500)
|
||||
private String auditHref;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME)
|
||||
private Integer pendingActionId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -671,4 +683,124 @@ public class AssistantToolCall extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiOperation (the /qqq/v1 operation the tool ran, e.g. POST /table/{tableName}/query)
|
||||
*******************************************************************************/
|
||||
public String getUiApiOperation()
|
||||
{
|
||||
return (this.uiApiOperation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for uiApiOperation
|
||||
*******************************************************************************/
|
||||
public void setUiApiOperation(String uiApiOperation)
|
||||
{
|
||||
this.uiApiOperation = uiApiOperation;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uiApiOperation
|
||||
*******************************************************************************/
|
||||
public AssistantToolCall withUiApiOperation(String uiApiOperation)
|
||||
{
|
||||
this.uiApiOperation = uiApiOperation;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for httpStatus (the operation's HTTP status, as the UI would have seen it)
|
||||
*******************************************************************************/
|
||||
public Integer getHttpStatus()
|
||||
{
|
||||
return (this.httpStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public void setHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public AssistantToolCall withHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for auditHref (an in-app link to the audit entries of the records a write touched)
|
||||
*******************************************************************************/
|
||||
public String getAuditHref()
|
||||
{
|
||||
return (this.auditHref);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for auditHref
|
||||
*******************************************************************************/
|
||||
public void setAuditHref(String auditHref)
|
||||
{
|
||||
this.auditHref = auditHref;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for auditHref
|
||||
*******************************************************************************/
|
||||
public AssistantToolCall withAuditHref(String auditHref)
|
||||
{
|
||||
this.auditHref = auditHref;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pendingActionId
|
||||
*******************************************************************************/
|
||||
public Integer getPendingActionId()
|
||||
{
|
||||
return (this.pendingActionId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for pendingActionId
|
||||
*******************************************************************************/
|
||||
public void setPendingActionId(Integer pendingActionId)
|
||||
{
|
||||
this.pendingActionId = pendingActionId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pendingActionId
|
||||
*******************************************************************************/
|
||||
public AssistantToolCall withPendingActionId(Integer pendingActionId)
|
||||
{
|
||||
this.pendingActionId = pendingActionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One model call: who, which provider and model, what for (a TURN or a
|
||||
** connection TEST), how it ended, how long it took, the tokens and the cost.
|
||||
** Written by the qbit only.
|
||||
*******************************************************************************/
|
||||
public class AssistantUsage extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_usage";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer conversationId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer turnId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer providerId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 50)
|
||||
private String providerType;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String model;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String purpose;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false, maxLength = 32)
|
||||
private String errorKind;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer httpStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer latencyMs;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long inputTokens;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long outputTokens;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long cacheReadTokens;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long cacheWriteTokens;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long costMicros;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String requestId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantUsage()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantUsage(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for conversationId
|
||||
*******************************************************************************/
|
||||
public Integer getConversationId()
|
||||
{
|
||||
return (this.conversationId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for conversationId
|
||||
*******************************************************************************/
|
||||
public void setConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for conversationId
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnId
|
||||
*******************************************************************************/
|
||||
public Integer getTurnId()
|
||||
{
|
||||
return (this.turnId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnId
|
||||
*******************************************************************************/
|
||||
public void setTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnId
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerId
|
||||
*******************************************************************************/
|
||||
public Integer getProviderId()
|
||||
{
|
||||
return (this.providerId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerId
|
||||
*******************************************************************************/
|
||||
public void setProviderId(Integer providerId)
|
||||
{
|
||||
this.providerId = providerId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerId
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withProviderId(Integer providerId)
|
||||
{
|
||||
this.providerId = providerId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerType
|
||||
*******************************************************************************/
|
||||
public String getProviderType()
|
||||
{
|
||||
return (this.providerType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerType
|
||||
*******************************************************************************/
|
||||
public void setProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerType
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withProviderType(String providerType)
|
||||
{
|
||||
this.providerType = providerType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for model
|
||||
*******************************************************************************/
|
||||
public String getModel()
|
||||
{
|
||||
return (this.model);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for model
|
||||
*******************************************************************************/
|
||||
public void setModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for model
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for purpose
|
||||
*******************************************************************************/
|
||||
public String getPurpose()
|
||||
{
|
||||
return (this.purpose);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for purpose
|
||||
*******************************************************************************/
|
||||
public void setPurpose(String purpose)
|
||||
{
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for purpose
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withPurpose(String purpose)
|
||||
{
|
||||
this.purpose = purpose;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (this.status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorKind
|
||||
*******************************************************************************/
|
||||
public String getErrorKind()
|
||||
{
|
||||
return (this.errorKind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorKind
|
||||
*******************************************************************************/
|
||||
public void setErrorKind(String errorKind)
|
||||
{
|
||||
this.errorKind = errorKind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for errorKind
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withErrorKind(String errorKind)
|
||||
{
|
||||
this.errorKind = errorKind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for httpStatus
|
||||
*******************************************************************************/
|
||||
public Integer getHttpStatus()
|
||||
{
|
||||
return (this.httpStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public void setHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for httpStatus
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withHttpStatus(Integer httpStatus)
|
||||
{
|
||||
this.httpStatus = httpStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for latencyMs
|
||||
*******************************************************************************/
|
||||
public Integer getLatencyMs()
|
||||
{
|
||||
return (this.latencyMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for latencyMs
|
||||
*******************************************************************************/
|
||||
public void setLatencyMs(Integer latencyMs)
|
||||
{
|
||||
this.latencyMs = latencyMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for latencyMs
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withLatencyMs(Integer latencyMs)
|
||||
{
|
||||
this.latencyMs = latencyMs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for inputTokens
|
||||
*******************************************************************************/
|
||||
public Long getInputTokens()
|
||||
{
|
||||
return (this.inputTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for inputTokens
|
||||
*******************************************************************************/
|
||||
public void setInputTokens(Long inputTokens)
|
||||
{
|
||||
this.inputTokens = inputTokens;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for inputTokens
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withInputTokens(Long inputTokens)
|
||||
{
|
||||
this.inputTokens = inputTokens;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outputTokens
|
||||
*******************************************************************************/
|
||||
public Long getOutputTokens()
|
||||
{
|
||||
return (this.outputTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for outputTokens
|
||||
*******************************************************************************/
|
||||
public void setOutputTokens(Long outputTokens)
|
||||
{
|
||||
this.outputTokens = outputTokens;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outputTokens
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withOutputTokens(Long outputTokens)
|
||||
{
|
||||
this.outputTokens = outputTokens;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cacheReadTokens
|
||||
*******************************************************************************/
|
||||
public Long getCacheReadTokens()
|
||||
{
|
||||
return (this.cacheReadTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cacheReadTokens
|
||||
*******************************************************************************/
|
||||
public void setCacheReadTokens(Long cacheReadTokens)
|
||||
{
|
||||
this.cacheReadTokens = cacheReadTokens;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cacheReadTokens
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withCacheReadTokens(Long cacheReadTokens)
|
||||
{
|
||||
this.cacheReadTokens = cacheReadTokens;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cacheWriteTokens
|
||||
*******************************************************************************/
|
||||
public Long getCacheWriteTokens()
|
||||
{
|
||||
return (this.cacheWriteTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cacheWriteTokens
|
||||
*******************************************************************************/
|
||||
public void setCacheWriteTokens(Long cacheWriteTokens)
|
||||
{
|
||||
this.cacheWriteTokens = cacheWriteTokens;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cacheWriteTokens
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withCacheWriteTokens(Long cacheWriteTokens)
|
||||
{
|
||||
this.cacheWriteTokens = cacheWriteTokens;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for costMicros
|
||||
*******************************************************************************/
|
||||
public Long getCostMicros()
|
||||
{
|
||||
return (this.costMicros);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for costMicros
|
||||
*******************************************************************************/
|
||||
public void setCostMicros(Long costMicros)
|
||||
{
|
||||
this.costMicros = costMicros;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for costMicros
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withCostMicros(Long costMicros)
|
||||
{
|
||||
this.costMicros = costMicros;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requestId
|
||||
*******************************************************************************/
|
||||
public String getRequestId()
|
||||
{
|
||||
return (this.requestId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for requestId
|
||||
*******************************************************************************/
|
||||
public void setRequestId(String requestId)
|
||||
{
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requestId
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withRequestId(String requestId)
|
||||
{
|
||||
this.requestId = requestId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantUsage withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,10 +28,14 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
@@ -70,6 +74,9 @@ public class AssistantStore
|
||||
public static final int MAX_CONVERSATIONS = 200;
|
||||
public static final int MAX_RUNNING_TURNS = 50;
|
||||
public static final int EVENT_PAGE_SIZE = 500;
|
||||
public static final int MAX_PROVIDERS = 100;
|
||||
public static final int MAX_PRICES = 500;
|
||||
public static final int MAX_SUM_ROWS = 100_000;
|
||||
|
||||
private static final Map<String, Object> USAGE_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -320,6 +327,16 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One of the user's own pending actions, or null.
|
||||
*******************************************************************************/
|
||||
public AssistantPendingAction getPendingAction(String userId, Integer id) throws QException
|
||||
{
|
||||
return (first(query(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantPendingAction.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's turns still running (any replica).
|
||||
*******************************************************************************/
|
||||
@@ -610,4 +627,83 @@ public class AssistantStore
|
||||
return (value == null ? 0 : value);
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// provider settings, prices and per-call usage //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/*******************************************************************************
|
||||
** All provider rows, by id.
|
||||
*******************************************************************************/
|
||||
public List<AssistantProvider> listProviders() throws QException
|
||||
{
|
||||
return (query(AssistantProvider.TABLE_NAME, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)).withLimit(MAX_PROVIDERS), AssistantProvider.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One provider row, or null.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider getProvider(Integer id) throws QException
|
||||
{
|
||||
return (first(query(AssistantProvider.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantProvider.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make every provider row other than `keepId` non-default.
|
||||
*******************************************************************************/
|
||||
public void clearOtherDefaultProviders(Integer keepId) throws QException
|
||||
{
|
||||
List<AssistantProvider> others = query(AssistantProvider.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("isDefault", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("id", QCriteriaOperator.NOT_EQUALS, keepId), AssistantProvider.class);
|
||||
for(AssistantProvider other : others)
|
||||
{
|
||||
updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", other.getId()).withValue("isDefault", false));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All price rows.
|
||||
*******************************************************************************/
|
||||
public List<AssistantModelPrice> listPrices() throws QException
|
||||
{
|
||||
return (query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)).withLimit(MAX_PRICES), AssistantModelPrice.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cost (micro-dollars) of a provider's calls since a time.
|
||||
*******************************************************************************/
|
||||
public long sumUsageCostSince(Integer providerId, Instant since) throws QException
|
||||
{
|
||||
long total = 0;
|
||||
for(QRecord record : queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("providerId", QCriteriaOperator.EQUALS, providerId)
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since), MAX_SUM_ROWS))
|
||||
{
|
||||
Long cost = record.getValueLong("costMicros");
|
||||
total += cost == null ? 0 : cost;
|
||||
}
|
||||
return (total);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw rows of one of the qbit's tables (the app home's reads), at most
|
||||
** `limit`.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> queryRecords(String tableName, QQueryFilter filter, int limit) throws QException
|
||||
{
|
||||
List<QRecord> records = new QueryAction().execute(new QueryInput(tableName).withFilter(filter.withLimit(limit))).getRecords();
|
||||
return (records == null ? List.of() : records);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test connection: a record action on assistant_provider. A standard
|
||||
** streamed ETL: Review says what will be called (and what is wrong with a
|
||||
** row before any call); Result has one line per provider with the status,
|
||||
** latency and model reached, or the error. The result is also saved on the
|
||||
** provider row and the call is recorded in assistant_usage (purpose TEST).
|
||||
*******************************************************************************/
|
||||
public final class TestConnectionProcess
|
||||
{
|
||||
public static final String NAME = "assistantProviderTestConnection";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TestConnectionProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantProvider.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantProvider.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"This makes one short live call to each selected provider's model (a few tokens) and saves the result on the provider.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Test connection");
|
||||
process.setIcon(new QIcon("network_check"));
|
||||
process.setTableName(AssistantProvider.TABLE_NAME);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with future (Review) and past (Result) wording.
|
||||
*******************************************************************************/
|
||||
static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A one-record line with the same text on both screens.
|
||||
*******************************************************************************/
|
||||
static ProcessSummaryLine single(Status status, String text, Serializable primaryKey)
|
||||
{
|
||||
ProcessSummaryLine line = line(status, text, text, text, text);
|
||||
line.incrementCountAndAddPrimaryKey(primaryKey);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: nothing is called yet.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine willTest = line(Status.OK, "provider will be tested with a live call", "providers will be tested with a live call",
|
||||
"provider was tested", "providers were tested");
|
||||
private final ProcessSummaryLine disabled = line(Status.INFO, "provider is turned off (it is tested anyway)", "providers are turned off (they are tested anyway)",
|
||||
"provider was turned off", "providers were turned off");
|
||||
private final List<ProcessSummaryLineInterface> problems = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantProvider row = new AssistantProvider(record);
|
||||
willTest.incrementCountAndAddPrimaryKey(row.getId());
|
||||
if(!Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
disabled.incrementCountAndAddPrimaryKey(row.getId());
|
||||
}
|
||||
if(runtime != null)
|
||||
{
|
||||
ProviderService service = runtime.getProviderService();
|
||||
for(String error : service.validate(row))
|
||||
{
|
||||
problems.add(single(Status.ERROR, row.getName() + ": " + error, row.getId()));
|
||||
}
|
||||
problems.add(single(Status.INFO, row.getName() + ": API key " + service.keyStatus(row), row.getId()));
|
||||
}
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
willTest.addSelfToListIfAnyCount(lines);
|
||||
disabled.addSelfToListIfAnyCount(lines);
|
||||
lines.addAll(problems);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result: one line per provider.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new QException("The assistant is not running on this server."));
|
||||
}
|
||||
String userId = AssistantSecurity.userId(QContext.getQSession());
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantProvider row = new AssistantProvider(record);
|
||||
ConnectionTest.Result result = ConnectionTest.run(runtime, row, userId);
|
||||
if(result.ok())
|
||||
{
|
||||
lines.add(single(Status.OK, row.getName() + ": connected to " + result.model() + " in " + result.latencyMs() + " ms", row.getId()));
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.add(single(Status.ERROR, row.getName() + ": " + result.error(), row.getId()));
|
||||
}
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(lines));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A kind of model provider an admin can configure in the assistant_provider
|
||||
** table (Anthropic now; OpenAI-compatible, Bedrock and others plug in the
|
||||
** same way). Hosts register types with AssistantQBitConfig.withProviderType;
|
||||
** the provider settings screen offers the registered types and their models.
|
||||
**
|
||||
** create() receives the API key already resolved from its reference; it must
|
||||
** never log the key or put it in toString or exception messages.
|
||||
*******************************************************************************/
|
||||
public interface AssistantProviderType
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The type's id, stored in assistant_provider.providerType (e.g. "anthropic").
|
||||
*******************************************************************************/
|
||||
String getType();
|
||||
|
||||
/*******************************************************************************
|
||||
** A label for the settings screen.
|
||||
*******************************************************************************/
|
||||
String getLabel();
|
||||
|
||||
/*******************************************************************************
|
||||
** The models offered for this type, best default first.
|
||||
*******************************************************************************/
|
||||
List<String> getModels();
|
||||
|
||||
/*******************************************************************************
|
||||
** The environment variable that holds the key by default (e.g.
|
||||
** ANTHROPIC_API_KEY).
|
||||
*******************************************************************************/
|
||||
String getDefaultApiKeyEnvName();
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider's own API base URL (shown when no override is set).
|
||||
*******************************************************************************/
|
||||
String getDefaultBaseUrl();
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a provider. apiKey is the resolved secret (never null or empty);
|
||||
** baseUrl is null for the type's default.
|
||||
*******************************************************************************/
|
||||
AssistantModelProvider create(String apiKey, String baseUrl);
|
||||
|
||||
}
|
||||
@@ -23,6 +23,8 @@ package com.kingsrook.qbits.assistant.provider;
|
||||
*******************************************************************************/
|
||||
public class ModelProviderException extends Exception
|
||||
{
|
||||
public static final int MAX_PROVIDER_MESSAGE_CHARS = 300;
|
||||
|
||||
/*******************************************************************************
|
||||
** Error classes the engine reacts to.
|
||||
*******************************************************************************/
|
||||
@@ -41,6 +43,10 @@ public class ModelProviderException extends Exception
|
||||
private final Kind kind;
|
||||
private final Integer httpStatus;
|
||||
private ModelUsage partialUsage;
|
||||
private String providerErrorType;
|
||||
private String providerMessage;
|
||||
private String requestId;
|
||||
private boolean strictSchemaRejected;
|
||||
|
||||
|
||||
|
||||
@@ -106,4 +112,92 @@ public class ModelProviderException extends Exception
|
||||
return (kind == Kind.RATE_LIMITED || kind == Kind.OVERLOADED || kind == Kind.TIMEOUT || kind == Kind.NETWORK || kind == Kind.SERVER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider's own error type (e.g. invalid_request_error), when it sent one.
|
||||
*******************************************************************************/
|
||||
public String getProviderErrorType()
|
||||
{
|
||||
return (providerErrorType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider's own error message: one line, capped, with anything that looks
|
||||
** like a key removed (see {@link #sanitizeProviderMessage}). Safe to log and
|
||||
** to show to administrators; never contains prompt content.
|
||||
*******************************************************************************/
|
||||
public String getProviderMessage()
|
||||
{
|
||||
return (providerMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider's request id, for its support.
|
||||
*******************************************************************************/
|
||||
public String getRequestId()
|
||||
{
|
||||
return (requestId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the provider's error details.
|
||||
*******************************************************************************/
|
||||
public ModelProviderException withProviderDetails(String providerErrorType, String providerMessage, String requestId)
|
||||
{
|
||||
this.providerErrorType = sanitizeProviderMessage(providerErrorType, 64);
|
||||
this.providerMessage = sanitizeProviderMessage(providerMessage, MAX_PROVIDER_MESSAGE_CHARS);
|
||||
this.requestId = sanitizeProviderMessage(requestId, 100);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line, at most max characters, with key-like tokens (sk-..., long
|
||||
** base64/hex runs) replaced by [redacted]. Null for blank input.
|
||||
*******************************************************************************/
|
||||
public static String sanitizeProviderMessage(String text, int max)
|
||||
{
|
||||
if(text == null || text.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String line = text.replaceAll("[\\p{Cntrl}\\s]+", " ").strip();
|
||||
line = line.replaceAll("(?i)\\bsk-[a-z0-9_\\-]{6,}", "[redacted]");
|
||||
line = line.replaceAll("[A-Za-z0-9+/_\\-]{40,}", "[redacted]");
|
||||
return (line.length() <= max ? line : line.substring(0, max - 1) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the provider refused the request because of its strict tool
|
||||
** schemas (e.g. Anthropic's "compiled grammar is too large"): the same
|
||||
** request without strict tools can succeed.
|
||||
*******************************************************************************/
|
||||
public boolean isStrictSchemaRejected()
|
||||
{
|
||||
return (strictSchemaRejected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for strictSchemaRejected
|
||||
*******************************************************************************/
|
||||
public ModelProviderException withStrictSchemaRejected(boolean strictSchemaRejected)
|
||||
{
|
||||
this.strictSchemaRejected = strictSchemaRejected;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public class ModelResponse
|
||||
private String stopReason;
|
||||
private ModelUsage usage = new ModelUsage();
|
||||
private String requestId;
|
||||
private String model;
|
||||
|
||||
@JsonIgnore
|
||||
private transient Object providerState;
|
||||
@@ -213,4 +214,35 @@ public class ModelResponse
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for model (the model that answered, when the provider reports it)
|
||||
*******************************************************************************/
|
||||
public String getModel()
|
||||
{
|
||||
return (this.model);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for model
|
||||
*******************************************************************************/
|
||||
public void setModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for model
|
||||
*******************************************************************************/
|
||||
public ModelResponse withModel(String model)
|
||||
{
|
||||
this.model = model;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.uiapi;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.CompleteOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128; REPLACE with an
|
||||
** adapter over it once a QQQ release has it). Same contract:
|
||||
**
|
||||
** - only spec'd `/qqq/v1` operations (from {@link MiddlewareVersionV1}'s
|
||||
** endpoint specs) can be called; anything else is refused before a request
|
||||
** is made, so raw routes (including the assistant's own) are unreachable;
|
||||
** - a loopback HTTP request to this server, carrying the user's own session
|
||||
** cookie: the identical Javalin pipeline (session setup, permission checks,
|
||||
** record locks, validation, audit, error mapping) the UI goes through. There
|
||||
** is no service session and no way to elevate;
|
||||
** - X-QQQ-Invoked-By marks the request for the access log.
|
||||
**
|
||||
** Why loopback rather than calling spec executors directly: buildInput, the
|
||||
** version's preExecute, filters and error mapping hang off the Javalin
|
||||
** pipeline; calling executors would skip some of them and drift over time.
|
||||
*******************************************************************************/
|
||||
public class LoopbackUiApiGateway implements UiApiGateway
|
||||
{
|
||||
public static final String BASE_PATH = "/qqq/v1";
|
||||
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
|
||||
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
|
||||
|
||||
private static final Set<String> SPEC_OPERATIONS = specOperations();
|
||||
|
||||
private final Supplier<String> baseUrlSupplier;
|
||||
private final boolean mockAuthentication;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** @param baseUrlSupplier the server's own root URL, e.g. http://127.0.0.1:8000
|
||||
** @param qInstance the instance (MOCK authentication reads the sessionId
|
||||
** cookie rather than sessionUUID, so both are sent there)
|
||||
*******************************************************************************/
|
||||
public LoopbackUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
|
||||
{
|
||||
this.baseUrlSupplier = baseUrlSupplier;
|
||||
this.mockAuthentication = qInstance != null && qInstance.getAuthentication() != null && QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType());
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).version(HttpClient.Version.HTTP_1_1).build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "METHOD /path/template" of every v1 endpoint spec.
|
||||
*******************************************************************************/
|
||||
static Set<String> specOperations()
|
||||
{
|
||||
Set<String> operations = new LinkedHashSet<>();
|
||||
for(AbstractEndpointSpec<?, ?, ?> spec : new MiddlewareVersionV1().getEndpointSpecs())
|
||||
{
|
||||
CompleteOperation operation = spec.defineCompleteOperation();
|
||||
operations.add(operation.getHttpMethod().name() + " " + operation.getPath());
|
||||
}
|
||||
return (Collections.unmodifiableSet(operations));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The spec'd operations ("METHOD template").
|
||||
*******************************************************************************/
|
||||
public static Set<String> getSpecOperations()
|
||||
{
|
||||
return (SPEC_OPERATIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isInvocable(String method, String pathTemplate)
|
||||
{
|
||||
return (SPEC_OPERATIONS.contains(method + " " + pathTemplate));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UiApiResult invoke(UiApiCall call) throws QException
|
||||
{
|
||||
if(call == null || !StringUtils.hasContent(call.getMethod()) || !StringUtils.hasContent(call.getPathTemplate()))
|
||||
{
|
||||
throw (new QException("A UI API call needs a method and a path template"));
|
||||
}
|
||||
if(!isInvocable(call.getMethod(), call.getPathTemplate()))
|
||||
{
|
||||
throw (new QException("Not a spec'd UI API operation: " + call.operation()));
|
||||
}
|
||||
if(!StringUtils.hasContent(call.getSessionUUID()))
|
||||
{
|
||||
throw (new QException("A session is required to invoke " + call.operation()));
|
||||
}
|
||||
|
||||
HttpRequest request = buildRequest(call);
|
||||
try
|
||||
{
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
return (new UiApiResult(response.statusCode(), response.body()));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Interrupted invoking " + call.operation(), e));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("Error invoking " + call.operation() + ": " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The HTTP request: path parameters filled and encoded, query string, JSON
|
||||
** body, the session cookie and the marker headers.
|
||||
*******************************************************************************/
|
||||
HttpRequest buildRequest(UiApiCall call) throws QException
|
||||
{
|
||||
String path = call.getPathTemplate();
|
||||
for(Map.Entry<String, String> entry : call.getPathParams().entrySet())
|
||||
{
|
||||
if(entry.getValue() == null)
|
||||
{
|
||||
throw (new QException("Missing path parameter " + entry.getKey() + " for " + call.operation()));
|
||||
}
|
||||
path = path.replace("{" + entry.getKey() + "}", encode(entry.getValue()));
|
||||
}
|
||||
if(path.contains("{"))
|
||||
{
|
||||
throw (new QException("Missing path parameter(s) for " + call.operation() + ": " + path));
|
||||
}
|
||||
|
||||
StringJoiner query = new StringJoiner("&", "?", "").setEmptyValue("");
|
||||
call.getQueryParams().forEach((name, value) -> query.add(encode(name) + "=" + encode(value)));
|
||||
|
||||
String base = baseUrlSupplier.get();
|
||||
if(!StringUtils.hasContent(base))
|
||||
{
|
||||
throw (new QException("The UI API base URL is not known yet"));
|
||||
}
|
||||
URI uri = URI.create(base.replaceAll("/+$", "") + BASE_PATH + path + query);
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(call.getTimeout() == null ? Duration.ofSeconds(60) : call.getTimeout());
|
||||
String cookie = "sessionUUID=" + call.getSessionUUID();
|
||||
if(mockAuthentication)
|
||||
{
|
||||
cookie += "; sessionId=" + call.getSessionUUID();
|
||||
}
|
||||
builder.header("Cookie", cookie);
|
||||
builder.header("Accept", "application/json");
|
||||
builder.header(INVOKED_BY_HEADER, StringUtils.hasContent(call.getInvokedBy()) ? call.getInvokedBy() : "assistant");
|
||||
if(StringUtils.hasContent(call.getUserTimezone()))
|
||||
{
|
||||
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
|
||||
}
|
||||
|
||||
if(call.getJsonBody() != null)
|
||||
{
|
||||
builder.header("Content-Type", "application/json");
|
||||
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.method(call.getMethod(), HttpRequest.BodyPublishers.noBody());
|
||||
}
|
||||
return (builder.build());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.uiapi;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One call of a spec'd `/qqq/v1` operation, made with a user's own session.
|
||||
** Mirrors core's `UiApiRequest` (QRun-IO/qqq#1128, docs/UI-API-EXTENSIONS.md
|
||||
** section 5), so the stand-in gateway can be swapped for core's invoker.
|
||||
*******************************************************************************/
|
||||
public class UiApiCall
|
||||
{
|
||||
private final String method;
|
||||
private final String pathTemplate;
|
||||
private final Map<String, String> pathParams = new LinkedHashMap<>();
|
||||
private final Map<String, String> queryParams = new LinkedHashMap<>();
|
||||
private Object jsonBody;
|
||||
private String sessionUUID;
|
||||
private String userTimezone;
|
||||
private String invokedBy = "assistant";
|
||||
private Duration timeout = Duration.ofSeconds(60);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A call of METHOD pathTemplate: the template as in openapi.yaml, relative
|
||||
** to /qqq/v1 (e.g. "/table/{tableName}/query").
|
||||
*******************************************************************************/
|
||||
public UiApiCall(String method, String pathTemplate)
|
||||
{
|
||||
this.method = method;
|
||||
this.pathTemplate = pathTemplate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "METHOD template": the operation's identity in logs and the allow-list.
|
||||
*******************************************************************************/
|
||||
public String operation()
|
||||
{
|
||||
return (method + " " + pathTemplate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for method
|
||||
*******************************************************************************/
|
||||
public String getMethod()
|
||||
{
|
||||
return (method);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pathTemplate
|
||||
*******************************************************************************/
|
||||
public String getPathTemplate()
|
||||
{
|
||||
return (pathTemplate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a path parameter (URL-encoded by the gateway).
|
||||
*******************************************************************************/
|
||||
public UiApiCall withPathParam(String name, Object value)
|
||||
{
|
||||
pathParams.put(name, value == null ? null : String.valueOf(value));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pathParams
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getPathParams()
|
||||
{
|
||||
return (pathParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a query parameter (ignored when null).
|
||||
*******************************************************************************/
|
||||
public UiApiCall withQueryParam(String name, Object value)
|
||||
{
|
||||
if(value != null)
|
||||
{
|
||||
queryParams.put(name, String.valueOf(value));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for queryParams
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getQueryParams()
|
||||
{
|
||||
return (queryParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jsonBody
|
||||
*******************************************************************************/
|
||||
public UiApiCall withJsonBody(Object jsonBody)
|
||||
{
|
||||
this.jsonBody = jsonBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for jsonBody
|
||||
*******************************************************************************/
|
||||
public Object getJsonBody()
|
||||
{
|
||||
return (jsonBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's own session UUID (required): the call is authenticated exactly
|
||||
** as the user's browser request would be.
|
||||
*******************************************************************************/
|
||||
public UiApiCall withSessionUUID(String sessionUUID)
|
||||
{
|
||||
this.sessionUUID = sessionUUID;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sessionUUID
|
||||
*******************************************************************************/
|
||||
public String getSessionUUID()
|
||||
{
|
||||
return (sessionUUID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userTimezone
|
||||
*******************************************************************************/
|
||||
public UiApiCall withUserTimezone(String userTimezone)
|
||||
{
|
||||
this.userTimezone = userTimezone;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userTimezone
|
||||
*******************************************************************************/
|
||||
public String getUserTimezone()
|
||||
{
|
||||
return (userTimezone);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By)
|
||||
*******************************************************************************/
|
||||
public UiApiCall withInvokedBy(String invokedBy)
|
||||
{
|
||||
this.invokedBy = invokedBy;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for invokedBy
|
||||
*******************************************************************************/
|
||||
public String getInvokedBy()
|
||||
{
|
||||
return (invokedBy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for timeout
|
||||
*******************************************************************************/
|
||||
public UiApiCall withTimeout(Duration timeout)
|
||||
{
|
||||
this.timeout = timeout;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for timeout
|
||||
*******************************************************************************/
|
||||
public Duration getTimeout()
|
||||
{
|
||||
return (timeout);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.uiapi;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How the assistant reaches data: spec'd `/qqq/v1` operations called with the
|
||||
** user's own session, so every tool call passes the same authentication,
|
||||
** permission, record-lock, validation and audit path as the UI.
|
||||
**
|
||||
** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker`
|
||||
** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces
|
||||
** it, and nothing else changes.
|
||||
*******************************************************************************/
|
||||
public interface UiApiGateway
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether METHOD template is a spec'd operation this gateway will call.
|
||||
*******************************************************************************/
|
||||
boolean isInvocable(String method, String pathTemplate);
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the call. Refuses (QException) an operation that is not spec'd, or a
|
||||
** call without a session. Otherwise it returns the response as the UI would
|
||||
** see it, error statuses included.
|
||||
*******************************************************************************/
|
||||
UiApiResult invoke(UiApiCall call) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.uiapi;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A `/qqq/v1` response exactly as the UI would see it: status and body.
|
||||
*******************************************************************************/
|
||||
public record UiApiResult(int status, String body)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether the status is 2xx.
|
||||
*******************************************************************************/
|
||||
public boolean isSuccess()
|
||||
{
|
||||
return (status >= 200 && status < 300);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The body as a JSON object, or null when it is not one.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> json()
|
||||
{
|
||||
if(!StringUtils.hasContent(body))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The API's own error message ({"error": "..."}), or null.
|
||||
*******************************************************************************/
|
||||
public String errorMessage()
|
||||
{
|
||||
Map<String, Object> json = json();
|
||||
return (json != null && json.get("error") instanceof String error && StringUtils.hasContent(error) ? error : null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
@Test
|
||||
void testProducedMetaData()
|
||||
{
|
||||
List<String> owned = List.of("assistant_conversation", "assistant_message", "assistant_turn", "assistant_turn_event", "assistant_tool_call", "assistant_feedback", "assistant_usage_daily");
|
||||
List<String> owned = List.of("assistant_conversation", "assistant_message", "assistant_turn", "assistant_turn_event", "assistant_tool_call", "assistant_feedback", "assistant_usage_daily", "assistant_usage");
|
||||
for(String name : owned)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
@@ -81,7 +81,13 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
QSecurityKeyType keyType = qInstance.getSecurityKeyType(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE);
|
||||
assertThat(keyType.getAllAccessKeyName()).isEqualTo("assistant_user_id_all_access");
|
||||
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).hasSize(9);
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// the nav: the home plus the main tables; processes are actions //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
|
||||
.containsExactly("assistant_provider", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getSchedule()).isNull();
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantConversation.TABLE_NAME)).isNotNull();
|
||||
|
||||
@@ -97,7 +97,11 @@ class TurnEngineTest extends BaseAssistantTest
|
||||
assertThat(first.getSystem().get(0).getText()).contains("qqq_data").contains("untrusted").contains("/app/");
|
||||
assertThat(first.getSystem().get(1).getCacheHint()).isEqualTo(CacheHint.SHORT);
|
||||
assertThat(first.getTools()).extracting(t -> t.getName()).contains("count_records", "query_records", "navigate").doesNotHaveDuplicates();
|
||||
assertThat(first.getTools()).allMatch(t -> Boolean.TRUE.equals(t.getStrict()));
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no strict tools by default: Anthropic refused all ten strict //
|
||||
// ("compiled grammar is too large"); the kernel validates inputs //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(first.getTools()).noneMatch(t -> Boolean.TRUE.equals(t.getStrict()));
|
||||
|
||||
ModelRequest second = requests.get(1);
|
||||
assertThat(second.getSystem().get(0).getText()).isEqualTo(first.getSystem().get(0).getText());
|
||||
|
||||
@@ -101,14 +101,15 @@ class AssistantRecordScreensTest extends BaseAssistantTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation lists its messages, turns, tool calls and feedback.
|
||||
** A conversation lists its messages, turns, tool calls, feedback and model
|
||||
** calls.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConversationChildLists()
|
||||
{
|
||||
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
|
||||
List<String> widgets = conversation.getSections().stream().map(QFieldSection::getWidgetName).filter(name -> name != null).toList();
|
||||
assertThat(widgets).hasSize(4);
|
||||
assertThat(widgets).hasSize(6);
|
||||
for(String widget : widgets)
|
||||
{
|
||||
assertThat(qInstance.getWidget(widget)).as(widget).isNotNull();
|
||||
@@ -118,7 +119,7 @@ class AssistantRecordScreensTest extends BaseAssistantTest
|
||||
// a second pass does not add them again //
|
||||
///////////////////////////////////////////////////
|
||||
AssistantRecordScreens.addConversationChildLists(qInstance);
|
||||
assertThat(conversation.getSections().stream().filter(section -> section.getWidgetName() != null)).hasSize(4);
|
||||
assertThat(conversation.getSections().stream().filter(section -> section.getWidgetName() != null)).hasSize(6);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.settings;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApiException;
|
||||
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHomeData;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHomeView;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider settings, key handling, Test connection, per-call usage, cost
|
||||
** math and the app home.
|
||||
*******************************************************************************/
|
||||
class AssistantSettingsTest extends BaseAssistantTest
|
||||
{
|
||||
static final String FAKE_TYPE = "fake";
|
||||
static final String KEY = "sk-test-SECRET-VALUE-0123456789-wxyz";
|
||||
|
||||
private final Map<String, String> secrets = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider type whose providers are the test's scripted provider.
|
||||
*******************************************************************************/
|
||||
class FakeType implements AssistantProviderType
|
||||
{
|
||||
String lastKey;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (FAKE_TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Fake");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("fake-model-1", "fake-model-2"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return ("FAKE_API_KEY");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return ("https://fake.example");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
lastKey = apiKey;
|
||||
return (provider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final FakeType fakeType = new FakeType();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
secrets.put("FAKE_API_KEY", KEY);
|
||||
secrets.put("DATABASE_PASSWORD", "db-secret-should-never-leak");
|
||||
config.withProviderType(fakeType).withSecretResolver(secrets::get).withPolicyCacheSeconds(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void asAdmin()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private InsertOutput insertProvider(Map<String, Serializable> values) throws Exception
|
||||
{
|
||||
QRecord record = new QRecord().withValue("name", "Fake").withValue("providerType", FAKE_TYPE).withValue("model", "fake-model-1")
|
||||
.withValue("enabled", true).withValue("isDefault", true);
|
||||
values.forEach(record::setValue);
|
||||
return (new InsertAction().execute(new InsertInput(AssistantProvider.TABLE_NAME).withRecord(record)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Integer providerId() throws Exception
|
||||
{
|
||||
InsertOutput output = insertProvider(Map.of());
|
||||
assertThat(output.getRecords().get(0).getErrors()).isEmpty();
|
||||
return (output.getRecords().get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables, nav, actions, child lists and exposure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMetaData()
|
||||
{
|
||||
for(String name : List.of(AssistantProvider.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantUsage.TABLE_NAME))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
assertThat(table).as(name).isNotNull();
|
||||
assertThat(table.getIsHidden()).isFalse();
|
||||
assertThat(AiTableMetaData.of(table).getExposure()).isEqualTo(AiExposure.NONE);
|
||||
}
|
||||
assertThat(qInstance.getTable(AssistantUsage.TABLE_NAME).getRecordSecurityLocks()).hasSize(1);
|
||||
assertThat(qInstance.getTable(AssistantProvider.TABLE_NAME).getField("apiKeyStatus").getIsEditable()).isFalse();
|
||||
assertThat(qInstance.getProcess(TestConnectionProcess.NAME).getTableName()).isEqualTo(AssistantProvider.TABLE_NAME);
|
||||
assertThat(qInstance.getProcess(TestConnectionProcess.NAME).getLabel()).isEqualTo("Test connection");
|
||||
assertThat(qInstance.getWidget(AssistantHome.WIDGET_NAME)).isNotNull();
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantRecordScreens.PROVIDER_TYPE_PVS_NAME).getEnumValues()).extracting(v -> String.valueOf(v.getId())).containsExactly(FAKE_TYPE);
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantRecordScreens.MODEL_PVS_NAME).getEnumValues()).extracting(v -> String.valueOf(v.getId())).contains("fake-model-1", "claude-sonnet-5-5");
|
||||
assertThat(qInstance.getTable(AssistantProvider.TABLE_NAME).getSections()).anyMatch(s -> s.getWidgetName() != null);
|
||||
assertThat(qInstance.getTable(AssistantProvider.TABLE_NAME).getSections()).anyMatch(s -> s.getFieldNames() != null && s.getFieldNames().contains("apiKeyStatus"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No read path returns the key: query, get, the entity and its JSON hold
|
||||
** only the masked status.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testKeyNeverReturned() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
Integer id = providerId();
|
||||
|
||||
List<QRecord> queried = new QueryAction().execute(new QueryInput(AssistantProvider.TABLE_NAME).withFilter(new QQueryFilter())).getRecords();
|
||||
QRecord got = new GetAction().execute(new GetInput(AssistantProvider.TABLE_NAME).withPrimaryKey(id)).getRecord();
|
||||
for(QRecord record : List.of(queried.get(0), got))
|
||||
{
|
||||
assertThat(record.getValueString("apiKeyStatus")).isEqualTo("Set • …wxyz (from FAKE_API_KEY)");
|
||||
assertThat(JsonUtils.toJson(record.getValues())).doesNotContain(KEY).doesNotContain("SECRET");
|
||||
assertThat(JsonUtils.toJson(new AssistantProvider(record))).doesNotContain(KEY);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// the status is never stored, even if a caller sends one //
|
||||
//////////////////////////////////////////////////////////////
|
||||
new UpdateAction().execute(new UpdateInput(AssistantProvider.TABLE_NAME).withRecord(new QRecord().withValue("id", id).withValue("apiKeyStatus", "forged").withValue("name", "Renamed")));
|
||||
secrets.remove("FAKE_API_KEY");
|
||||
QRecord after = new GetAction().execute(new GetInput(AssistantProvider.TABLE_NAME).withPrimaryKey(id)).getRecord();
|
||||
assertThat(after.getValueString("apiKeyStatus")).isEqualTo("Not set (FAKE_API_KEY is empty)");
|
||||
assertThat(after.getValueString("name")).isEqualTo("Renamed");
|
||||
|
||||
assertThat(ProviderService.mask("short")).isEqualTo("Set");
|
||||
assertThat(ProviderService.mask(null)).isEqualTo("Not set");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A key pasted into the variable field, an unrelated secret's variable and
|
||||
** an unknown base URL are all refused (and nothing is stored).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnsafeSettingsRefused() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
InsertOutput pasted = insertProvider(Map.of("apiKeyEnvName", KEY));
|
||||
assertThat(pasted.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
assertThat(pasted.getRecords().get(0).getErrors().get(0).getMessage()).contains("NAME of an environment variable").doesNotContain(KEY);
|
||||
|
||||
InsertOutput other = insertProvider(Map.of("name", "Other", "apiKeyEnvName", "DATABASE_PASSWORD"));
|
||||
assertThat(other.getRecords().get(0).getErrors().get(0).getMessage()).contains("not allowed");
|
||||
|
||||
InsertOutput url = insertProvider(Map.of("name", "Url", "baseUrl", "https://evil.example"));
|
||||
assertThat(url.getRecords().get(0).getErrors().get(0).getMessage()).contains("not allowed");
|
||||
|
||||
InsertOutput badType = insertProvider(Map.of("name", "Type", "providerType", "nope"));
|
||||
assertThat(badType.getRecords().get(0).getErrors().get(0).getMessage()).contains("not installed");
|
||||
|
||||
InsertOutput limits = insertProvider(Map.of("name", "Limits", "monthlyCostCapUsd", new BigDecimal("-1"), "maxOutputTokensPerRequest", 0, "maxConcurrentRequests", 0));
|
||||
assertThat(limits.getRecords().get(0).getErrors()).hasSize(3);
|
||||
|
||||
assertThat(new QueryAction().execute(new QueryInput(AssistantProvider.TABLE_NAME).withFilter(new QQueryFilter())).getRecords()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Marking a provider default unmarks the others; the default enabled one is
|
||||
** active; with no rows the configured provider is used.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testActiveProvider() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
ProviderService service = config.getRuntime().getProviderService();
|
||||
ProviderService.Active fallback = service.resolve("main-model");
|
||||
assertThat(fallback.row()).isNull();
|
||||
assertThat(fallback.model()).isEqualTo("main-model");
|
||||
|
||||
Integer first = providerId();
|
||||
Integer second = insertProvider(Map.of("name", "Second", "model", "fake-model-2")).getRecords().get(0).getValueInteger("id");
|
||||
assertThat(service.activeRow().getId()).isEqualTo(second);
|
||||
assertThat(new GetAction().execute(new GetInput(AssistantProvider.TABLE_NAME).withPrimaryKey(first)).getRecord().getValueBoolean("isDefault")).isFalse();
|
||||
|
||||
ProviderService.Active active = service.resolve("main-model");
|
||||
assertThat(active.providerId()).isEqualTo(second);
|
||||
assertThat(active.model()).isEqualTo("fake-model-2");
|
||||
assertThat(active.provider()).isSameAs(provider);
|
||||
assertThat(fakeType.lastKey).isEqualTo(KEY);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput(AssistantProvider.TABLE_NAME).withRecord(new QRecord().withValue("id", second).withValue("enabled", false)));
|
||||
assertThat(service.activeRow().getId()).isEqualTo(first);
|
||||
|
||||
secrets.remove("FAKE_API_KEY");
|
||||
assertThat(service.resolve("main-model")).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test connection (a record action): Result lines, the saved result, and a
|
||||
** TEST usage row - success and failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTestConnectionAction() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
Integer id = providerId();
|
||||
|
||||
provider.thenText("OK");
|
||||
List<ProcessSummaryLineInterface> lines = runTest(id);
|
||||
assertThat(lines).anyMatch(l -> l.getStatus() == Status.OK && l.getMessage().contains("connected to fake-model-1"));
|
||||
AssistantProvider row = config.getRuntime().getStore().getProvider(id);
|
||||
assertThat(row.getLastTestStatus()).isEqualTo("OK");
|
||||
assertThat(row.getLastTestModel()).isEqualTo("fake-model-1");
|
||||
assertThat(row.getLastTestLatencyMs()).isNotNull();
|
||||
assertThat(provider.getRequests().get(provider.getRequests().size() - 1).getMaxTokens()).isEqualTo(ConnectionTest.PING_MAX_TOKENS);
|
||||
|
||||
provider.thenError(ModelProviderException.Kind.AUTHENTICATION, "invalid x-api-key");
|
||||
lines = runTest(id);
|
||||
assertThat(lines).anyMatch(l -> l.getStatus() == Status.ERROR && l.getMessage().contains("API key was refused"));
|
||||
row = config.getRuntime().getStore().getProvider(id);
|
||||
assertThat(row.getLastTestStatus()).isEqualTo("ERROR");
|
||||
assertThat(row.getLastTestError()).contains("AUTHENTICATION").doesNotContain(KEY);
|
||||
|
||||
List<QRecord> usage = new QueryAction().execute(new QueryInput(AssistantUsage.TABLE_NAME).withFilter(new QQueryFilter()
|
||||
.withCriteria("purpose", QCriteriaOperator.EQUALS, UsageRecorder.PURPOSE_TEST))).getRecords();
|
||||
assertThat(usage).extracting(r -> r.getValueString("status")).containsExactlyInAnyOrder("OK", "ERROR");
|
||||
assertThat(usage).allMatch(r -> AssistantTestFixture.ADMIN.equals(r.getValueString("userId")) && id.equals(r.getValueInteger("providerId")));
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// a row that cannot be used fails before any call //
|
||||
////////////////////////////////////////////////////////////////
|
||||
int calls = provider.getCallCount();
|
||||
secrets.remove("FAKE_API_KEY");
|
||||
lines = runTest(id);
|
||||
assertThat(lines).anyMatch(l -> l.getStatus() == Status.ERROR && l.getMessage().contains("No API key"));
|
||||
assertThat(provider.getCallCount()).isEqualTo(calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run Test connection on one provider; the Result screen's lines.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLineInterface> runTest(Integer id) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(TestConnectionProcess.NAME);
|
||||
input.setCallback(QProcessCallbackFactory.forPrimaryKey("id", id));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
List<ProcessSummaryLineInterface> review = (List<ProcessSummaryLineInterface>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(review).anyMatch(l -> l.getMessage() != null && l.getMessage().contains("API key"));
|
||||
return ((List<ProcessSummaryLineInterface>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every model call of a turn writes a usage row, priced from the price
|
||||
** table; the per-request output cap applies; the monthly cap stops turns.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTurnUsageAndLimits() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
Integer id = insertProvider(Map.of("maxOutputTokensPerRequest", 500)).getRecords().get(0).getValueInteger("id");
|
||||
new InsertAction().execute(new InsertInput(AssistantModelPrice.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("providerType", FAKE_TYPE).withValue("model", "fake-model-1")
|
||||
.withValue("inputUsdPerMTok", new BigDecimal("3")).withValue("outputUsdPerMTok", new BigDecimal("15")).withValue("cacheReadUsdPerMTok", new BigDecimal("0.3"))));
|
||||
|
||||
provider.thenToolUse("list_tables", Map.of()).thenText("There are some tables.");
|
||||
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "what tables are there?");
|
||||
assertThat(outcome.errorCode()).isNull();
|
||||
assertThat(provider.getRequests()).allMatch(r -> r.getMaxTokens() == 500 && "fake-model-1".equals(r.getModel()));
|
||||
|
||||
asAdmin();
|
||||
List<QRecord> usage = new QueryAction().execute(new QueryInput(AssistantUsage.TABLE_NAME).withFilter(new QQueryFilter()
|
||||
.withCriteria("turnId", QCriteriaOperator.EQUALS, outcome.turnId()))).getRecords();
|
||||
assertThat(usage).hasSize(2);
|
||||
for(QRecord row : usage)
|
||||
{
|
||||
assertThat(row.getValueString("purpose")).isEqualTo("TURN");
|
||||
assertThat(row.getValueString("status")).isEqualTo("OK");
|
||||
assertThat(row.getValueInteger("providerId")).isEqualTo(id);
|
||||
assertThat(row.getValueInteger("conversationId")).isEqualTo(outcome.conversationId());
|
||||
assertThat(row.getValueString("userId")).isEqualTo(AssistantTestFixture.EDITOR);
|
||||
//////////////////////////////////////////////////////////////
|
||||
// 1200 in x $3 + 150 out x $15 + 3000 cache read x $0.30 //
|
||||
//////////////////////////////////////////////////////////////
|
||||
assertThat(row.getValueLong("costMicros")).isEqualTo(3600 + 2250 + 900);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// month to date is $0.01350; a $0.01 cap refuses new turns //
|
||||
////////////////////////////////////////////////////////////////
|
||||
assertThat(config.getRuntime().getProviderService().monthToDateCostMicros(id)).isEqualTo(13_500);
|
||||
new UpdateAction().execute(new UpdateInput(AssistantProvider.TABLE_NAME).withRecord(new QRecord().withValue("id", id).withValue("monthlyCostCapUsd", new BigDecimal("0.01"))));
|
||||
provider.thenText("never");
|
||||
int calls = provider.getCallCount();
|
||||
assertThatThrownBy(() -> runner.run(AssistantTestFixture.EDITOR, "again"))
|
||||
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getCode()).isEqualTo("BUDGET"));
|
||||
assertThat(provider.getCallCount()).isEqualTo(calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed model call is recorded with its kind.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailedCallRecorded() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
providerId();
|
||||
provider.thenError(ModelProviderException.Kind.OVERLOADED, "overloaded");
|
||||
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "hello");
|
||||
assertThat(outcome.errorCode()).isEqualTo("PROVIDER");
|
||||
|
||||
asAdmin();
|
||||
List<QRecord> usage = new QueryAction().execute(new QueryInput(AssistantUsage.TABLE_NAME).withFilter(new QQueryFilter())).getRecords();
|
||||
assertThat(usage).hasSize(1);
|
||||
assertThat(usage.get(0).getValueString("status")).isEqualTo("ERROR");
|
||||
assertThat(usage.get(0).getValueString("errorKind")).isEqualTo("OVERLOADED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cost math: price rows override the config, cache defaults, formatting.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCostMath() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
ProviderService service = config.getRuntime().getProviderService();
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(1_000_000L).withOutputTokens(100_000L).withCacheReadTokens(1_000_000L).withCacheWriteTokens(0L);
|
||||
|
||||
////////////////////////////////////////////////
|
||||
// no row: config defaults (Sonnet 5.5 $2/$10) //
|
||||
////////////////////////////////////////////////
|
||||
assertThat(service.pricing("anthropic", "claude-sonnet-5-5").costMicros(usage)).isEqualTo(2_000_000 + 1_000_000 + 200_000);
|
||||
assertThat(service.pricing("x", "unknown-model").costMicros(usage)).isZero();
|
||||
|
||||
new InsertAction().execute(new InsertInput(AssistantModelPrice.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("providerType", "anthropic").withValue("model", "claude-sonnet-5-5")
|
||||
.withValue("inputUsdPerMTok", new BigDecimal("1")).withValue("outputUsdPerMTok", new BigDecimal("5"))));
|
||||
ModelPricing row = service.pricing("anthropic", "claude-sonnet-5-5");
|
||||
assertThat(row.getCacheReadUsdPerMTok()).isEqualTo(0.1);
|
||||
assertThat(row.getCacheWrite5mUsdPerMTok()).isEqualTo(1.25);
|
||||
assertThat(row.costMicros(usage)).isEqualTo(1_000_000 + 500_000 + 100_000);
|
||||
assertThat(service.pricing("other-type", "claude-sonnet-5-5").costMicros(usage)).isEqualTo(3_200_000);
|
||||
|
||||
assertThat(ModelPricing.defaults()).containsKeys("claude-fable-5-1", "claude-haiku-4-5");
|
||||
assertThat(ProviderService.capMicros(new AssistantProvider().withMonthlyCostCapUsd(new BigDecimal("12.345678")))).isEqualTo(12_345_678L);
|
||||
assertThat(ProviderService.capMicros(null)).isNull();
|
||||
assertThat(AssistantHomeView.money(0)).isEqualTo("$0.00");
|
||||
assertThat(AssistantHomeView.money(5)).isEqualTo("<$0.01");
|
||||
assertThat(AssistantHomeView.money(12_345_678)).isEqualTo("$12.35");
|
||||
assertThat(AssistantHomeView.money(1_234_000_000L)).isEqualTo("$1,234.00");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home with nothing in it: honest empty states, no tables.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeEmpty() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
String html = render();
|
||||
assertThat(html).contains("No providers yet").contains("No assistant use in the last 30 days").contains("No tool calls yet")
|
||||
.contains("No failed model calls").contains("No one has used the assistant this month").doesNotContain("<table");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home with real rows: providers with health, usage, cost, tools and
|
||||
** failures.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeWithData() throws Exception
|
||||
{
|
||||
asAdmin();
|
||||
Integer id = insertProvider(Map.of("monthlyCostCapUsd", new BigDecimal("50"))).getRecords().get(0).getValueInteger("id");
|
||||
provider.thenText("OK");
|
||||
ConnectionTest.run(config.getRuntime(), config.getRuntime().getStore().getProvider(id), AssistantTestFixture.ADMIN);
|
||||
|
||||
provider.thenToolUse("list_tables", Map.of()).thenText("Here.");
|
||||
runner.run(AssistantTestFixture.EDITOR, "what tables?");
|
||||
provider.thenToolUse("describe_table", Map.of("table", "nope")).thenError(ModelProviderException.Kind.SERVER, "boom");
|
||||
runner.run(AssistantTestFixture.ANALYST, "describe nope");
|
||||
|
||||
asAdmin();
|
||||
AssistantHomeData data = AssistantHomeData.load(config.getRuntime());
|
||||
assertThat(data.getProviders()).hasSize(1);
|
||||
assertThat(data.getProviders().get(0).active()).isTrue();
|
||||
assertThat(data.getProviders().get(0).keyPresent()).isTrue();
|
||||
assertThat(data.getByUser()).extracting(t -> t.key).contains(AssistantTestFixture.EDITOR, AssistantTestFixture.ANALYST, AssistantTestFixture.ADMIN);
|
||||
assertThat(data.getByTool()).extracting(t -> t.name).contains("list_tables", "describe_table");
|
||||
assertThat(data.getFailedCalls()).hasSize(1);
|
||||
assertThat(data.getFailedTools()).hasSize(1);
|
||||
assertThat(data.errorRatePercent7d()).isEqualTo(25);
|
||||
assertThat(data.getDays().get(data.getToday()).conversations).isEqualTo(2);
|
||||
assertThat(data.getMonthRequests()).isEqualTo(5);
|
||||
assertThat(data.getMonthCostMicros()).isZero();
|
||||
|
||||
String html = render();
|
||||
assertThat(html).contains("Answering").contains("Fake").contains("In use").contains("Last test passed").contains("…wxyz")
|
||||
.contains("of $50.00 cap").contains("list_tables").contains("Model call #").contains("25% of 4 requests").doesNotContain(KEY).doesNotContain("<table");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the home widget as the current session.
|
||||
*******************************************************************************/
|
||||
private String render() throws Exception
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(qInstance.getWidget(AssistantHome.WIDGET_NAME));
|
||||
RenderWidgetOutput output = new RenderWidgetAction().execute(input);
|
||||
return (((RawHTML) output.getWidgetData()).getHtml());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -104,6 +104,10 @@ public class TurnRunner
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.config = config;
|
||||
if(config.getUiApiGateway() == null && config.getUiApiBaseUrl() == null)
|
||||
{
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.testing;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fixture app served by a real QQQ application server (the `/qqq/v1` UI
|
||||
** API plus the assistant routes) on a free port, so the assistant's tools go
|
||||
** through exactly the pipeline the UI uses. One server at a time: starting
|
||||
** one for a new instance stops the previous one.
|
||||
*******************************************************************************/
|
||||
public final class UiApiTestServer
|
||||
{
|
||||
private static QApplicationJavalinServer current;
|
||||
private static QInstance currentInstance;
|
||||
private static int currentPort;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private UiApiTestServer()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fixture application.
|
||||
*******************************************************************************/
|
||||
public static class FixtureApplication extends AbstractQQQApplication
|
||||
{
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public FixtureApplication(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve this instance (if not already) and point the config's UI API base
|
||||
** URL at it. Returns the base URL.
|
||||
*******************************************************************************/
|
||||
public static synchronized String serve(QInstance qInstance, AssistantQBitConfig config)
|
||||
{
|
||||
if(current == null || currentInstance != qInstance)
|
||||
{
|
||||
stop();
|
||||
try
|
||||
{
|
||||
currentPort = freePort();
|
||||
current = new QApplicationJavalinServer(new FixtureApplication(qInstance))
|
||||
.withPort(currentPort)
|
||||
.withServeFrontendNext(false)
|
||||
.withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false);
|
||||
current.start();
|
||||
currentInstance = qInstance;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not start the UI API test server", e));
|
||||
}
|
||||
}
|
||||
String baseUrl = "http://127.0.0.1:" + currentPort;
|
||||
config.setUiApiBaseUrl(baseUrl);
|
||||
return (baseUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop the running server, if any.
|
||||
*******************************************************************************/
|
||||
public static synchronized void stop()
|
||||
{
|
||||
if(current != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
current.stop();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// already stopped
|
||||
}
|
||||
current = null;
|
||||
currentInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int freePort() throws IOException
|
||||
{
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
return (socket.getLocalPort());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.tools;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The owner's rule (2026-10-13): the assistant goes through the same API the
|
||||
** UI does, so it is held to the same security boundaries. For each case the
|
||||
** UI's own request (a direct /qqq/v1 call with the user's session cookie) and
|
||||
** the assistant's tool call are made side by side: the status and the error
|
||||
** message must be identical, and the assistant may only ever be narrower
|
||||
** (data access NONE / READ).
|
||||
*******************************************************************************/
|
||||
class UiApiToolsSecurityTest extends BaseAssistantTest
|
||||
{
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The UI's request: METHOD /qqq/v1{path} as the user's browser sends it.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> ui(String method, String path, String user, String body) throws Exception
|
||||
{
|
||||
String base = UiApiTestServer.serve(qInstance, config);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + user).timeout(Duration.ofSeconds(30));
|
||||
if(body != null)
|
||||
{
|
||||
builder.header("Content-Type", "application/json");
|
||||
}
|
||||
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The assistant's tool call, as the turn engine makes it.
|
||||
*******************************************************************************/
|
||||
private UiApiToolbox.Outcome tool(String user, PolicyService.ToolPolicy policy, String toolName, Map<String, Object> in)
|
||||
{
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
|
||||
try
|
||||
{
|
||||
return (config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(user, null, config.getRuntime().getUiApiGateway(), policy,
|
||||
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(user).withToolName(write.toolName()).withTableName(write.tableName())
|
||||
.withRecordId(write.recordId()).withUiApiOperation(write.operation()).withParamsJson(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(write.values()))
|
||||
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING)
|
||||
.withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()), "call-1", toolName, in));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void setAccess(String tableName, DataAccessMode mode) throws QException
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
try
|
||||
{
|
||||
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(mode.name()));
|
||||
config.getRuntime().getDataAccessService().invalidate();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static PolicyService.ToolPolicy noConfirmation()
|
||||
{
|
||||
return (new PolicyService.ToolPolicy(false, Set.of(), 50, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No read permission: the UI gets 403; the assistant gets the same 403 and
|
||||
** the same message, as a DENIED tool result.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadDeniedIsTheSameDenial() throws Exception
|
||||
{
|
||||
String body = "{\"filter\":{\"criteria\":[],\"booleanOperator\":\"AND\"}}";
|
||||
HttpResponse<String> ui = ui("POST", "/table/blog_post/query", AssistantTestFixture.ANALYST, body);
|
||||
assertThat(ui.statusCode()).isEqualTo(403);
|
||||
|
||||
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.ANALYST, PolicyService.ToolPolicy.defaults(), UiApiToolbox.QUERY_RECORDS,
|
||||
input("table", "blog_post", "filter", filter("AND")));
|
||||
assertThat(outcome.getHttpStatus()).isEqualTo(ui.statusCode());
|
||||
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
|
||||
assertThat(outcome.getOperation()).isEqualTo("POST /table/{tableName}/query");
|
||||
assertThat(outcome.getResult().getModelContent()).contains(new UiApiResult(ui.statusCode(), ui.body()).errorMessage());
|
||||
|
||||
////////////////////////////////////////////
|
||||
// get and count: the same, by status //
|
||||
////////////////////////////////////////////
|
||||
assertThat(tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.GET_RECORD, input("table", "blog_post", "id", "1")).getHttpStatus())
|
||||
.isEqualTo(ui("GET", "/table/blog_post/1", AssistantTestFixture.ANALYST, null).statusCode());
|
||||
assertThat(tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.COUNT_RECORDS, input("table", "blog_post", "filter", filter("AND"))).getHttpStatus())
|
||||
.isEqualTo(ui("POST", "/table/blog_post/count", AssistantTestFixture.ANALYST, body).statusCode());
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// and the table the user may read works, with no change //
|
||||
////////////////////////////////////////////////////////////
|
||||
UiApiToolbox.Outcome ok = tool(AssistantTestFixture.ANALYST, null, UiApiToolbox.COUNT_RECORDS, input("table", "analytics_daily_stat", "filter", filter("AND")));
|
||||
assertThat(ok.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
|
||||
assertThat(ok.getHttpStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No insert permission: even with data access READ_WRITE and no
|
||||
** confirmation, the write gets the UI's 403.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriteDeniedIsTheSameDenial() throws Exception
|
||||
{
|
||||
setAccess("blog_post", DataAccessMode.READ_WRITE);
|
||||
HttpResponse<String> ui = ui("POST", "/table/blog_post", AssistantTestFixture.EDITOR, "{\"title\":\"From the UI\"}");
|
||||
assertThat(ui.statusCode()).isEqualTo(403);
|
||||
|
||||
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, noConfirmation(), UiApiToolbox.CREATE_RECORD,
|
||||
input("table", "blog_post", "values", Map.of("title", "From the assistant")));
|
||||
assertThat(outcome.getHttpStatus()).isEqualTo(403);
|
||||
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
|
||||
assertThat(outcome.getResult().getModelContent()).contains(new UiApiResult(ui.statusCode(), ui.body()).errorMessage());
|
||||
|
||||
assertThat(tool(AssistantTestFixture.EDITOR, noConfirmation(), UiApiToolbox.DELETE_RECORD, input("table", "blog_post", "id", "1")).getHttpStatus())
|
||||
.isEqualTo(ui("DELETE", "/table/blog_post/1", AssistantTestFixture.EDITOR, null).statusCode());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Data access only narrows: NONE hides a table the user can read; READ
|
||||
** refuses writes the user could make; nothing raises a permission.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDataAccessOnlyNarrows() throws Exception
|
||||
{
|
||||
assertThat(ui("POST", "/table/page/count", AssistantTestFixture.EDITOR, "{}").statusCode()).isEqualTo(200);
|
||||
setAccess("page", DataAccessMode.NONE);
|
||||
UiApiToolbox.Outcome hidden = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.COUNT_RECORDS, input("table", "page", "filter", filter("AND")));
|
||||
assertThat(hidden.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
|
||||
assertThat(hidden.getHttpStatus()).as("no request is made").isNull();
|
||||
|
||||
UiApiToolbox.Outcome list = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.LIST_TABLES, Map.of());
|
||||
assertThat(list.getResult().getModelContent()).contains("\"blog_post\"").doesNotContain("\"page\"").doesNotContain("analytics_daily_stat").doesNotContain("assistant_");
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// the admin may insert in the UI, but blog_post is READ (the //
|
||||
// default): the assistant refuses before any request //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
UiApiToolbox.Outcome readOnly = tool(AssistantTestFixture.ADMIN, noConfirmation(), UiApiToolbox.CREATE_RECORD, input("table", "blog_post", "values", Map.of("title", "x")));
|
||||
assertThat(readOnly.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
|
||||
assertThat(readOnly.getHttpStatus()).isNull();
|
||||
assertThat(readOnly.getResult().getModelContent()).contains("READ");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a sensitive table (AI exposure NONE) cannot be raised //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
setAccess("assistant_policy", DataAccessMode.READ_WRITE);
|
||||
assertThat(config.getRuntime().getDataAccessService().forTable("assistant_policy").mode()).isEqualTo(DataAccessMode.NONE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With confirmation on (the default) a write only proposes; confirming runs
|
||||
** it through the UI API with the confirming user's session, once, and logs
|
||||
** the operation, status and audit link.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConfirmedWriteRunsThroughTheUiApi() throws Exception
|
||||
{
|
||||
setAccess("blog_post", DataAccessMode.READ_WRITE);
|
||||
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.ADMIN, PolicyService.ToolPolicy.defaults(), UiApiToolbox.CREATE_RECORD,
|
||||
input("table", "blog_post", "values", Map.of("title", "Proposed by the assistant", "status", "DRAFT")));
|
||||
assertThat(proposed.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
|
||||
assertThat(proposed.getHttpStatus()).as("nothing runs before confirmation").isNull();
|
||||
assertThat(proposed.getPendingActionId()).isNotNull();
|
||||
assertThat(String.valueOf(proposed.getResult().getRender())).contains("pending_action");
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
|
||||
assertThat(config.getRuntime().getStore().queryRecords("blog_post", new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, "Proposed by the assistant"), 10)).isEmpty();
|
||||
|
||||
Map<String, Object> confirmed = api.confirmAction(proposed.getPendingActionId());
|
||||
assertThat(confirmed.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
|
||||
assertThat(confirmed.get("outcome")).isEqualTo("OK");
|
||||
List<QRecord> created = config.getRuntime().getStore().queryRecords("blog_post", new QQueryFilter().withCriteria("title", QCriteriaOperator.EQUALS, "Proposed by the assistant"), 10);
|
||||
assertThat(created).hasSize(1);
|
||||
|
||||
assertThatThrownBy(() -> api.confirmAction(proposed.getPendingActionId())).hasMessageContaining("already");
|
||||
|
||||
List<AssistantToolCall> log = config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME,
|
||||
new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, proposed.getPendingActionId()), AssistantToolCall.class);
|
||||
assertThat(log).hasSize(1);
|
||||
assertThat(log.get(0).getUiApiOperation()).isEqualTo("POST /table/{tableName}");
|
||||
assertThat(log.get(0).getHttpStatus()).isBetween(200, 299);
|
||||
assertThat(log.get(0).getAuditHref()).startsWith("/app/blog_post/");
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user who did not propose an action cannot see or confirm it (the
|
||||
** owner record lock), and a rejected action never runs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOnlyTheOwnerDecides() throws Exception
|
||||
{
|
||||
setAccess("blog_post", DataAccessMode.READ_WRITE);
|
||||
UiApiToolbox.Outcome proposed = tool(AssistantTestFixture.EDITOR, PolicyService.ToolPolicy.defaults(), UiApiToolbox.UPDATE_RECORD,
|
||||
input("table", "blog_post", "id", "1", "values", Map.of("title", "Changed")));
|
||||
Integer actionId = proposed.getPendingActionId();
|
||||
assertThat(actionId).isNotNull();
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.OTHER_EDITOR));
|
||||
AssistantApi other = new AssistantApi(config.getRuntime(), QContext.getQSession());
|
||||
assertThatThrownBy(() -> other.confirmAction(actionId)).hasMessageContaining("not found");
|
||||
QContext.clear();
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
AssistantApi owner = new AssistantApi(config.getRuntime(), QContext.getQSession());
|
||||
assertThat(owner.rejectAction(actionId).get("status")).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
|
||||
assertThatThrownBy(() -> owner.confirmAction(actionId)).hasMessageContaining("already");
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only spec'd /qqq/v1 operations can be invoked (the assistant's own raw
|
||||
** routes are not reachable), and never without a session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOnlySpecdOperationsWithASession()
|
||||
{
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/assistant/conversations").withSessionUUID(AssistantTestFixture.ADMIN)))
|
||||
.hasMessageContaining("Not a spec'd");
|
||||
assertThatThrownBy(() -> config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData")))
|
||||
.hasMessageContaining("session");
|
||||
for(String[] operation : UiApiToolbox.OPERATIONS.values())
|
||||
{
|
||||
assertThat(config.getRuntime().getUiApiGateway().isInvocable(operation[0], operation[1])).as(operation[0] + " " + operation[1]).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy narrows the tools, and caps rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPolicy()
|
||||
{
|
||||
PolicyService.ToolPolicy onlyCount = new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.COUNT_RECORDS), 2, true);
|
||||
assertThat(tool(AssistantTestFixture.EDITOR, onlyCount, UiApiToolbox.QUERY_RECORDS, input("table", "blog_post", "filter", filter("AND"))).getResult().getOutcome())
|
||||
.isEqualTo(AiToolOutcome.DENIED);
|
||||
|
||||
PolicyService.ToolPolicy twoRows = new PolicyService.ToolPolicy(true, Set.of(), 2, true);
|
||||
UiApiToolbox.Outcome capped = tool(AssistantTestFixture.EDITOR, twoRows, UiApiToolbox.QUERY_RECORDS, input("table", "blog_post", "filter", filter("AND"), "limit", 50));
|
||||
assertThat(capped.getResult().getRowCount()).isEqualTo(2);
|
||||
assertThat(capped.getResult().getModelContent()).contains("\"hasMore\":true").doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL).doesNotContain(AssistantTestFixture.PASSWORD_SENTINEL);
|
||||
}
|
||||
|
||||
}
|
||||