feat!: spec'd /qqq/v1 assistant endpoints; tools on core's UiApiInvoker (QQQ RC.6) #19

Merged
KofTwentyTwo merged 6 commits from feature/spec-endpoints-core-invoker into develop 2026-10-14 12:57:22 +00:00
56 changed files with 4604 additions and 870 deletions

No files matched your search

+6 -1
View File
@@ -61,7 +61,7 @@
- Add HIPAA mode, eligible-provider enforcement, AES-256-GCM conversation storage,
provider diagnostic redaction, and independent PHI conversation retention.
- Add owner-locked, admin read-only data routing audits and HIPAA status on the home.
- Keep QQQ RC.4 compatibility with a classification stand-in and core #1142 adapter seam.
- Classification levels are core's `DataClassification` (the qbit stand-in is removed); the default source reads core AI metadata merged with configuration.
All notable changes to this project are documented here. The format follows Keep a Changelog, and versions follow semantic versioning.
@@ -79,6 +79,11 @@ 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.7, needs QQQ with QRun-IO/qqq#1128)
- The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone.
- Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools.
- The OpenAPI fragment is committed (`openapi/qqq-v1/assistant.yaml`) and contract-tested.
### 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`**.
+24 -8
View File
@@ -448,10 +448,10 @@ Apache 2.0.
## Data classification and HIPAA mode
On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration:
Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set):
```java
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
AssistantQBitConfig config = new AssistantQBitConfig()
.withDataClassification("patient", DataClassification.PHI)
@@ -469,8 +469,8 @@ The existing server-side `withSecretResolver` hook also resolves this reference.
Startup validates the key. HIPAA mode defaults off; encryption can also be enabled
with the key reference while HIPAA mode is off.
The qbit `DataClassification` enum is a stand-in for core QRun-IO/qqq#1142, with
PUBLIC < CONFIDENTIAL < PII < PHI. Unconfigured tables default to PUBLIC; a field
The levels are core's `DataClassification` (PUBLIC < CONFIDENTIAL < PII < PHI); the
qbit's former stand-in enum is gone. Unconfigured tables default to PUBLIC; a field
falls back to its table unless explicitly overridden. A field subset uses its
selected fields; unrestricted reads and record page contexts use the highest
classification across the table and its fields. Returned columns and record-label
@@ -479,10 +479,9 @@ explicitly if the host considers it safe for that subset. Cross-table results wi
tables use the highest classification anywhere. Metadata-only tools remain PUBLIC.
The assistant's own tables remain denied to tools.
Hosts can supply `withClassificationSource(ClassificationSource)` instead. When
upgrading to an RC containing core #1142, implement that interface with
`AiDataClassifications`/`AiTableMetaData`/`AiFieldMetaData` (map enum names), then
replace the stand-in once the qbit's minimum core version includes it.
The default `ClassificationSource` reads core's metadata (`AiDataClassifications`,
`AiTableMetaData`, `AiFieldMetaData`) merged with the configured map; configuration
wins when both are set. Hosts can still supply `withClassificationSource(ClassificationSource)`.
PHI is sent only to a persisted provider row explicitly marked `hipaaEligible`.
Every model dispatch rechecks eligibility, including calls after tools and strict
@@ -602,3 +601,20 @@ skills can extend the built-in presentation instructions. See the
The image-breakdown golden task also runs in the existing opt-in live provider suite:
`./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am`
(with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification.
### Record history and the assistant channel
Core's default `ClientChannelsMetaData` denies `audit*` to the `assistant`
channel. Accordingly, `get_record_history` is offered only when all three audit
tables it queries (`auditTable`, `audit`, and `auditDetail`) exist and are not
channel-denied, including their effective permission rules. A host that intends
to expose record history can opt in during instance configuration:
```java
ClientChannelsMetaData.ofOrWithNew(qInstance)
.getDeniedPatterns().get("assistant").remove("audit*");
```
This removes only the default audit pattern; other channel denials and the
signed-in user's audit permissions still apply. Keep explicit denials for audit
surfaces the host does not intend to expose. The default remains unchanged.
+15 -5
View File
@@ -143,7 +143,7 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th
- 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.
- `CoreUiApiGateway` adapts core's `UiApiInvoker` (the loopback stand-in is deleted). It labels calls with the client channel `assistant`, plus the turn id as detail (`assistant/<turnId>`). Form params go to the invoker's `withFormParam`; extra headers (the bulk-load tools' `X-QQQ-AI-Route`) go through `HeaderedUiApiInvoker`, a small subclass in core's package because core's request has no `withHeader` yet. A host can still inject its own gateway with `withUiApiGateway`.
- 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
@@ -228,6 +228,18 @@ The golden and red-team evals now run through the UI API too.
- Choosing *another* user would need that user's permissions without their session. QQQ core has no API to evaluate another user's permissions, and the assistant never builds or borrows a session. That part waits for a core API, so the report is always computed with the requester's own session.
- **Recent denials:** the home's "Recent denials" card lists the newest tool calls the UI API, data access or the policy refused, with who, the table, the operation and the HTTP status, or the assistant setting that refused it.
## Spec'd assistant endpoints and the core invoker (phase d, QQQ 4.1.0-RC.6)
- **The endpoints are spec'd `/qqq/v1` operations.** `api/specs/AssistantSpecs` registers the UI API extension `assistant` (semver 1.0.0, established prefix `/assistant`) through `MiddlewareSpecExtensionsMetaData`.
- The URLs, bodies and statuses stay the same (201 create, 202 cancel, 204 delete and feedback, and the assistant's `{"error","code"}` errors), so Next needs no change.
- They appear in the served `/qqq/v1/openapi.yaml`. The turn and events streams are declared as `text/event-stream`, with `AssistantTurnEventV1` describing the events.
- The fragment is committed as `qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml`. `AssistantSpecsContractTest` checks it against the code, and the release baseline in test resources, with core's `UiApiContract`.
- **No raw routes.** `AssistantRouteProvider` keeps only a before-handler that learns the server's local port from the first `/qqq/v1` request.
- **Tools call core's `UiApiInvoker`** (`CoreUiApiGateway`), and the loopback stand-in is removed.
- The request's credentials (`UiApiCredentials.fromContext`: session cookies, Authorization, API key, exactly as they came in) are captured by the spec that starts the turn, confirms an action or explains access. They are passed to the tools, so OAuth2, table auth and API-key sessions all work. They are never logged.
- The gateway also refuses the assistant's own operations, so a turn can never start a turn.
- QQQ 4.1.0-RC.6 (the default `qqq.snapshot.version`) contains #1128, so no local build is needed.
## Owner decisions (2026-10-13), continued: write mode, the hard wall, audit attribution, providers
These were added on the same day. All recommendations below are accepted; we adjust once they can be seen working.
@@ -354,10 +366,8 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
### P-c as built
- The qbit classification enum is a stand-in for core #1142 until a released RC
includes it. Configured table/field overrides are the default; hosts can supply
a `ClassificationSource` over core metadata, mapping enum names, then replace
the stand-in when the minimum QQQ version advances.
- The qbit classification stand-in is gone: the levels are core's `actions.ai.DataClassification` (QQQ 4.1.0-RC.6, core #1142).
The default `ClassificationSource` reads core metadata (`AiDataClassifications`, `AiTableMetaData`, `AiFieldMetaData`) merged with the configured table/field map; configuration wins when both are set. Hosts can still supply their own source with `withClassificationSource`.
- Turn/page/tool classification is monotonic, persisted on the turn and retained
on the conversation. Field subsets use overrides; unknown cross-table results
conservatively use the highest configured level. Metadata tools stay PUBLIC.
+4 -3
View File
@@ -194,8 +194,9 @@
</build>
<profiles>
<!-- The assistant needs QQQ 4.1.0-RC.5: the AI tool kernel (package -->
<!-- actions.ai, qqq#1078) and the developer panels (qqq#1076). Until -->
<!-- The assistant needs QQQ 4.1.0-RC.6: the AI tool kernel (qqq#1078), the -->
<!-- developer panels (qqq#1076) and the UI API extension point, invoker -->
<!-- and credentials (qqq#1128). Until -->
<!-- qbit-build-parent imports the 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
<!-- parent's 4.0.0 BOM, as in qbit-quick-search (ADR-0007). Override with -->
@@ -206,7 +207,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.testing.GoldenTasks;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
@@ -102,6 +103,10 @@ class LiveGoldenEvalTest
@MethodSource("tasks")
void live(GoldenTasks.Task task) throws Exception
{
if("D-page-12-this-week".equals(task.id()))
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
}
TurnRunner.Outcome outcome = new TurnRunner(qInstance, config).run(task.userId(), task.prompt());
assertThat(outcome.errorCode()).as("error").isNull();
task.liveCheck().accept(outcome);
@@ -32,7 +32,6 @@ import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
@@ -40,6 +39,7 @@ import com.kingsrook.qbits.assistant.provider.TrustedCaBundle;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -38,9 +38,9 @@ import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -120,7 +120,7 @@ public class AssistantRuntime
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance);
this.uiApiGateway = new UiApiGateway()
{
/*******************************************************************************
@@ -141,11 +141,11 @@ public class AssistantRuntime
String channel = scheduledTurn.channel(call.getSessionUUID());
if(channel != null)
{
if(delegate instanceof LoopbackUiApiGateway loopback)
if(!(delegate instanceof CoreUiApiGateway))
{
return (loopback.invokeWithClient(call, channel));
throw (new QException("Scheduled tasks require the core UI API gateway for client attribution."));
}
throw (new QException("Scheduled tasks require the loopback gateway for client attribution."));
call.withClientDetail(channel.substring(CoreUiApiGateway.CLIENT_CHANNEL.length() + 1));
}
return (delegate.invoke(call));
}
@@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -92,7 +93,17 @@ public class AccessExplainer
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withInvokedBy("assistant-explain"));
return (explain(gateway, sessionUUID, null, policy, tableName, prompt));
}
/*******************************************************************************
** Explain, with the caller's request credentials when they are known.
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain"));
if(!response.isSuccess())
{
throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage())));
@@ -55,6 +55,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
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;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -71,6 +72,7 @@ public class AssistantApi
private final AssistantStore store;
private final QSession session;
private final String userId;
private UiApiCredentials credentials;
@@ -93,6 +95,18 @@ public class AssistantApi
/*******************************************************************************
** The credentials of the request this API serves, exactly as they came in;
** the tools forward them to the UI API. Never logged.
*******************************************************************************/
public AssistantApi withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** GET /config - the feature probe. Never throws for a signed-in user.
*******************************************************************************/
@@ -373,7 +387,7 @@ public class AssistantApi
RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId));
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
budget.softExceeded() ? budget.message() : null);
budget.softExceeded() ? budget.message() : null).withCredentials(credentials);
runtime.getExecutor().submit(engine);
return (runningTurn);
}
@@ -598,7 +612,7 @@ public class AssistantApi
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.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials, action.getTurnId() == null ? null : String.valueOf(action.getTurnId()));
UiApiToolbox.Outcome outcome = BulkLoadTools.WRITE_TOOLS.contains(action.getToolName())
? runtime.getBulkLoadTools().executeConfirmed(context, action.getToolCallId(), action.getToolName(), action.getTableName(), values)
: runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
@@ -722,7 +736,7 @@ public class AssistantApi
{
requireAccess();
List<Map<String, Object>> tables = new ArrayList<>();
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), runtime.getPolicyService().current(false).tools(), table, prompt))
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt))
{
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
{
@@ -17,63 +17,27 @@
package com.kingsrook.qbits.assistant.api;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.engine.TurnEventLog;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
import io.javalin.config.JavalinConfig;
import io.javalin.http.ContentType;
import io.javalin.http.Context;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Javalin routes under /qqq/v1/assistant (contract section 3).
**
** Every request gets the user's own session from the instance's
** authentication module (ExecutorSessionUtils.setupSession: session cookie,
** bearer token or API key) - never a system session. Bodies must be JSON
** (a cross-site form cannot send that without a CORS preflight).
**
** Streams (POST /conversations/{id}/turns, GET /turns/{id}/events) are
** Server-Sent Events written to the response as they happen: "id: seq",
** "event: type", "data: json", a blank line; a ping every 15 seconds;
** Cache-Control no-cache and X-Accel-Buffering no (proxies must not
** buffer; give the route a long idle timeout). The turn itself runs on its
** own thread, so a client disconnect does not stop it; the client resumes
** with /events?after=<last seq>.
**
** Request and response bodies are never logged.
** No routes any more: the assistant's endpoints are spec'd /qqq/v1
** operations ({@link com.kingsrook.qbits.assistant.api.specs.AssistantSpecs}).
** What is left is a before-handler that learns this server's local port from
** the first /qqq/v1 request, so the tools (and Explain access) can call the
** UI API on 127.0.0.1 before any assistant operation has run.
*******************************************************************************/
public class AssistantRouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(AssistantRouteProvider.class);
public static final String BASE_PATH = "/qqq/v1/assistant";
public static final long PING_MILLIS = 15_000;
public static final long REMOTE_POLL_MS = 1_000;
public static final String BASE_PATH = "/qqq/v1/assistant";
private QInstance qInstance;
private long pingMillis = PING_MILLIS;
@@ -88,28 +52,12 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** Ping interval (tests use a short one).
*******************************************************************************/
public AssistantRouteProvider withPingMillis(long pingMillis)
{
this.pingMillis = pingMillis;
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
///////////////////////////////////////////////////////////////////////
// learn this server's local port from the first UI request (e.g. //
// the metaData load), so tools and Explain access can loop back to //
// /qqq/v1 before any assistant route has been called //
///////////////////////////////////////////////////////////////////////
config.routes.before("/qqq/v1/*", ctx ->
{
AssistantRuntime runtime = AssistantRuntime.of(qInstance);
@@ -125,412 +73,6 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface
}
}
});
config.routes.get(BASE_PATH + "/config", ctx -> json(ctx, false, (api, body) -> api.config()));
config.routes.get(BASE_PATH + "/conversations", ctx -> json(ctx, false, (api, body) -> api.listConversations(ctx.queryParam("status"))));
config.routes.post(BASE_PATH + "/conversations", ctx -> json(ctx, true, (api, body) ->
{
ctx.status(201);
return (api.createConversation(body));
}));
config.routes.get(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> api.getConversation(pathInt(ctx, "id"), queryInt(ctx, "before"), queryInt(ctx, "limit"))));
config.routes.patch(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, true, (api, body) -> api.patchConversation(pathInt(ctx, "id"), body)));
config.routes.delete(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) ->
{
api.deleteConversation(pathInt(ctx, "id"));
ctx.status(204);
return (null);
}));
config.routes.post(BASE_PATH + "/conversations/{id}/turns", this::startTurn);
config.routes.get(BASE_PATH + "/turns/{turnId}/events", this::turnEvents);
config.routes.post(BASE_PATH + "/turns/{turnId}/cancel", ctx -> json(ctx, true, (api, body) ->
{
ctx.status(202);
return (api.cancelTurn(pathInt(ctx, "turnId")));
}));
config.routes.get(BASE_PATH + "/access", ctx -> json(ctx, false, (api, body) -> api.access(ctx.queryParam("table"), ctx.queryParam("prompt"))));
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);
ctx.status(204);
return (null);
}));
}
/*******************************************************************************
** Session, runtime, body, handler, JSON - or the error.
*******************************************************************************/
private void json(Context context, boolean readsBody, Handler handler)
{
try
{
AssistantApi api = api(context);
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
Object result = handler.handle(api, body);
if(result != null)
{
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
}
}
catch(Exception e)
{
handleException(context, e);
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** POST /conversations/{id}/turns
*******************************************************************************/
private void startTurn(Context context)
{
RunningTurn turn;
try
{
AssistantApi api = api(context);
turn = api.startTurn(pathInt(context, "id"), readJsonBody(context));
}
catch(Exception e)
{
handleException(context, e);
QContext.clear();
return;
}
QContext.clear();
streamLive(context, turn.getLog(), 0);
}
/*******************************************************************************
** GET /turns/{turnId}/events?after=
*******************************************************************************/
private void turnEvents(Context context)
{
Integer turnId;
int after;
AssistantApi api;
AssistantApi.TurnEvents events;
try
{
api = api(context);
turnId = pathInt(context, "turnId");
Integer afterParam = queryInt(context, "after");
after = afterParam == null ? 0 : Math.max(0, afterParam);
events = api.turnEvents(turnId, after);
}
catch(Exception e)
{
handleException(context, e);
QContext.clear();
return;
}
if(events.live() != null)
{
QContext.clear();
streamLive(context, events.live().getLog(), after);
return;
}
///////////////////////////////////////////////////////////////////////
// persisted events; if the turn still runs on another replica, poll //
///////////////////////////////////////////////////////////////////////
try
{
OutputStream out = startStream(context);
int last = after;
long lastWrite = System.currentTimeMillis();
long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L;
AssistantApi.TurnEvents current = events;
while(true)
{
boolean ended = false;
for(TurnEvent event : current.persisted())
{
write(out, event.toSseFrame());
last = event.getSeq();
lastWrite = System.currentTimeMillis();
ended |= event.isTerminal();
}
if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt)
{
return;
}
if(!current.more())
{
Thread.sleep(REMOTE_POLL_MS);
}
if(System.currentTimeMillis() - lastWrite >= pingMillis)
{
write(out, ping());
lastWrite = System.currentTimeMillis();
}
current = api.turnEvents(turnId, last);
if(current.live() != null)
{
QContext.clear();
writeLive(out, current.live().getLog(), last);
return;
}
}
}
catch(IOException e)
{
LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(Exception e)
{
LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** Stream a live log from a sequence number until message.end.
*******************************************************************************/
private void streamLive(Context context, TurnEventLog log, int after)
{
try
{
writeLive(startStream(context), log, after);
}
catch(IOException e)
{
LOG.debug("Assistant stream closed by the client (the turn continues)");
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
/*******************************************************************************
**
*******************************************************************************/
private void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
{
int last = after;
while(true)
{
List<TurnEvent> events = log.after(last, pingMillis);
if(events.isEmpty())
{
if(log.isClosed())
{
return;
}
write(out, ping());
continue;
}
for(TurnEvent event : events)
{
write(out, event.toSseFrame());
last = event.getSeq();
if(event.isTerminal())
{
return;
}
}
}
}
/*******************************************************************************
** Start an SSE response.
*******************************************************************************/
private static OutputStream startStream(Context context) throws IOException
{
context.status(200);
context.disableCompression();
context.res().setContentType("text/event-stream; charset=utf-8");
context.res().setHeader("Cache-Control", "no-cache");
context.res().setHeader("X-Accel-Buffering", "no");
context.res().setHeader("Connection", "keep-alive");
OutputStream out = context.res().getOutputStream();
out.flush();
return (out);
}
/*******************************************************************************
**
*******************************************************************************/
private static void write(OutputStream out, String frame) throws IOException
{
out.write(frame.getBytes(StandardCharsets.UTF_8));
out.flush();
}
/*******************************************************************************
**
*******************************************************************************/
private static String ping()
{
return ("event: " + TurnEvent.PING + "\ndata: {}\n\n");
}
/*******************************************************************************
** Session + API for a request. 404 when the QBit is not in the instance.
*******************************************************************************/
private AssistantApi api(Context context) throws Exception
{
AssistantRuntime runtime = AssistantRuntime.of(qInstance);
if(runtime == null)
{
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));
}
/*******************************************************************************
** Map errors: API errors with their status, others through QQQ's handler
** (401 / 403 / 404 / 500).
*******************************************************************************/
private static void handleException(Context context, Exception e)
{
if(e instanceof AssistantApiException apiException)
{
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", apiException.getMessage());
if(apiException.getCode() != null)
{
body.put("code", apiException.getCode());
}
context.status(apiException.getStatus());
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJson(body));
return;
}
if(!(e instanceof QException))
{
LOG.warn("Assistant request failed", e);
}
QJavalinUtils.handleException(null, context, e);
}
/*******************************************************************************
** A JSON object body (empty body = empty object).
*******************************************************************************/
static Map<String, Object> readJsonBody(Context context) throws AssistantApiException
{
String body = context.body();
if(!StringUtils.hasContent(body))
{
return (new LinkedHashMap<>());
}
String contentType = context.contentType();
if(contentType == null || !contentType.toLowerCase().startsWith("application/json"))
{
throw (new AssistantApiException(400, "Request bodies must be JSON (Content-Type: application/json)."));
}
try
{
Map<String, Object> map = JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS));
return (map == null ? new LinkedHashMap<>() : map);
}
catch(Exception e)
{
throw (new AssistantApiException(400, "The request body is not a JSON object."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer pathInt(Context context, String name) throws AssistantApiException
{
try
{
return (Integer.parseInt(context.pathParam(name)));
}
catch(NumberFormatException e)
{
throw (new AssistantApiException(400, name + " must be an integer."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer queryInt(Context context, String name) throws AssistantApiException
{
String value = context.queryParam(name);
if(!StringUtils.hasContent(value))
{
return (null);
}
try
{
return (Integer.parseInt(value.trim()));
}
catch(NumberFormatException e)
{
throw (new AssistantApiException(400, name + " must be an integer."));
}
}
/*******************************************************************************
** One JSON endpoint's work.
*******************************************************************************/
@FunctionalInterface
private interface Handler
{
/*******************************************************************************
**
*******************************************************************************/
Object handle(AssistantApi api, Map<String, Object> body) throws Exception;
}
}
@@ -19,7 +19,9 @@ package com.kingsrook.qbits.assistant.api;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
@@ -43,6 +45,16 @@ public class AssistantRoutes
{
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(AssistantRouteProvider.class));
}
////////////////////////////////////////////////////////////////////
// the endpoints: the spec'd "assistant" UI API extension //
////////////////////////////////////////////////////////////////////
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance);
boolean hasExtension = extensions.getExtensions() != null && extensions.getExtensions().containsKey(AssistantSpecs.EXTENSION_NAME);
if(!hasExtension)
{
extensions.withExtension(AssistantSpecs.extension());
}
}
}
@@ -0,0 +1,222 @@
/*
* 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.api;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.engine.TurnEventLog;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import io.javalin.http.Context;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Writes a turn's events as server-sent events: the live log of a turn
** running here, or the persisted events (polling while the turn runs on
** another replica), with a ping every pingMillis while idle.
*******************************************************************************/
public final class TurnStreams
{
private static final QLogger LOG = QLogger.getLogger(TurnStreams.class);
public static final long PING_MILLIS = 15_000;
public static final long REMOTE_POLL_MS = 1_000;
private static volatile long pingMillis = PING_MILLIS;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TurnStreams()
{
}
/*******************************************************************************
** The ping interval (tests use a short one).
*******************************************************************************/
public static void setPingMillis(long millis)
{
pingMillis = millis;
}
/*******************************************************************************
** Stream a live log from a sequence number until message.end.
*******************************************************************************/
public static void streamLive(Context context, TurnEventLog log, int after)
{
try
{
writeLive(startStream(context), log, after);
}
catch(IOException e)
{
LOG.debug("Assistant stream closed by the client (the turn continues)");
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
/*******************************************************************************
** Stream the events of a turn after a sequence number: live when it runs
** here, else the persisted ones (polling while it runs elsewhere). The
** caller's QContext (session) must be set: the replay reads the store.
*******************************************************************************/
public static void streamEvents(Context context, AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first)
{
if(first.live() != null)
{
streamLive(context, first.live().getLog(), after);
return;
}
try
{
OutputStream out = startStream(context);
int last = after;
long lastWrite = System.currentTimeMillis();
long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L;
AssistantApi.TurnEvents current = first;
while(true)
{
boolean ended = false;
for(TurnEvent event : current.persisted())
{
write(out, event.toSseFrame());
last = event.getSeq();
lastWrite = System.currentTimeMillis();
ended |= event.isTerminal();
}
if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt)
{
return;
}
if(!current.more())
{
Thread.sleep(REMOTE_POLL_MS);
}
if(System.currentTimeMillis() - lastWrite >= pingMillis)
{
write(out, ping());
lastWrite = System.currentTimeMillis();
}
current = api.turnEvents(turnId, last);
if(current.live() != null)
{
writeLive(out, current.live().getLog(), last);
return;
}
}
}
catch(IOException e)
{
LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
catch(Exception e)
{
LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
{
int last = after;
while(true)
{
List<TurnEvent> events = log.after(last, pingMillis);
if(events.isEmpty())
{
if(log.isClosed())
{
return;
}
write(out, ping());
continue;
}
for(TurnEvent event : events)
{
write(out, event.toSseFrame());
last = event.getSeq();
if(event.isTerminal())
{
return;
}
}
}
}
/*******************************************************************************
** Start an SSE response.
*******************************************************************************/
private static OutputStream startStream(Context context) throws IOException
{
context.status(200);
context.disableCompression();
context.res().setContentType("text/event-stream; charset=utf-8");
context.res().setHeader("Cache-Control", "no-cache");
context.res().setHeader("X-Accel-Buffering", "no");
context.res().setHeader("Connection", "keep-alive");
OutputStream out = context.res().getOutputStream();
out.flush();
return (out);
}
/*******************************************************************************
**
*******************************************************************************/
private static void write(OutputStream out, String frame) throws IOException
{
out.write(frame.getBytes(StandardCharsets.UTF_8));
out.flush();
}
/*******************************************************************************
**
*******************************************************************************/
private static String ping()
{
return ("event: " + TurnEvent.PING + "\ndata: {}\n\n");
}
}
@@ -22,11 +22,12 @@ package com.kingsrook.qbits.assistant.classification;
import java.util.Collection;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
/*******************************************************************************
** Host seam for table and field classifications; a core metadata adapter can replace the configured source.
** Host seam for table and field classifications; the default reads core metadata merged with configuration.
*******************************************************************************/
public interface ClassificationSource
{
@@ -63,7 +64,7 @@ public interface ClassificationSource
DataClassification result = DataClassification.PUBLIC;
for(String field : fields)
{
result = DataClassification.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field));
result = Classifications.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field));
}
return (result);
}
@@ -80,7 +81,7 @@ public interface ClassificationSource
{
if(!table.startsWith("assistant_"))
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
}
return (result);
@@ -1,15 +1,11 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
* 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,
@@ -21,24 +17,31 @@
package com.kingsrook.qbits.assistant.classification;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** Stand-in for core DataClassification (QRun-IO/qqq#1142); replace when the qbit builds on an RC that has it.
** The assistant's conventions over core's {@link DataClassification}: an
** absent classification is PUBLIC, and a stored level this version does not
** know is treated as PHI (fail conservative).
*******************************************************************************/
public enum DataClassification
public final class Classifications
{
PUBLIC, CONFIDENTIAL, PII, PHI;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private Classifications()
{
}
/*******************************************************************************
** Whether this classification is at least the supplied level; null means PUBLIC.
** The level, or PUBLIC when absent.
*******************************************************************************/
public boolean isAtLeast(DataClassification other)
public static DataClassification orPublic(DataClassification classification)
{
return (ordinal() >= (other == null ? PUBLIC : other).ordinal());
return (classification == null ? DataClassification.PUBLIC : classification);
}
@@ -48,9 +51,7 @@ public enum DataClassification
*******************************************************************************/
public static DataClassification max(DataClassification left, DataClassification right)
{
DataClassification a = left == null ? PUBLIC : left;
DataClassification b = right == null ? PUBLIC : right;
return (a.isAtLeast(b) ? a : b);
return (DataClassification.max(orPublic(left), orPublic(right)));
}
@@ -62,15 +63,15 @@ public enum DataClassification
{
if(value == null)
{
return (PUBLIC);
return (DataClassification.PUBLIC);
}
try
{
return (valueOf(value));
return (DataClassification.valueOf(value));
}
catch(IllegalArgumentException e)
{
return (PHI);
return (DataClassification.PHI);
}
}
}
@@ -23,12 +23,21 @@ package com.kingsrook.qbits.assistant.classification;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Default classification source for RCs without core classification metadata.
** The default classification source: core's AI classification metadata
** (AiTableMetaData / AiFieldMetaData, via AiDataClassifications) merged with
** the classifications configured on the qbit. A configured value wins when
** both are set; a field falls back to its own core classification, then to
** its table's; anything unclassified (and the assistant's own tables) is
** PUBLIC.
*******************************************************************************/
public class ConfiguredClassificationSource implements ClassificationSource
{
@@ -71,7 +80,7 @@ public class ConfiguredClassificationSource implements ClassificationSource
@Override
public DataClassification ofTable(String table)
{
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : DataClassification.max(null, tables.get(table)));
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : Classifications.max(null, tables.get(table)));
}
@@ -87,11 +96,28 @@ public class ConfiguredClassificationSource implements ClassificationSource
return (DataClassification.PUBLIC);
}
DataClassification value = field == null ? null : fields.getOrDefault(table, Map.of()).get(field);
if(value == null)
{
QTableMetaData metadata = coreTable(table);
AiFieldMetaData coreField = metadata == null || metadata.getFields() == null || field == null ? null : AiFieldMetaData.of(metadata.getFields().get(field));
value = coreField == null ? null : coreField.getClassification();
}
return (value == null ? ofTable(table) : value);
}
/*******************************************************************************
** The table's core metadata, or null (no instance in context, or unknown).
*******************************************************************************/
private static QTableMetaData coreTable(String table)
{
QInstance qInstance = QContext.getQInstance();
return (qInstance == null ? null : qInstance.getTable(table));
}
/*******************************************************************************
** Resolve all field names from the current instance, also retaining configured overrides.
*******************************************************************************/
@@ -108,12 +134,12 @@ public class ConfiguredClassificationSource implements ClassificationSource
{
for(String field : metadata.getFields().keySet())
{
result = DataClassification.max(result, ofField(table, field));
result = Classifications.max(result, ofField(table, field));
}
}
for(DataClassification value : fields.getOrDefault(table, Map.of()).values())
{
result = DataClassification.max(result, value);
result = Classifications.max(result, value);
}
return (result);
}
@@ -129,11 +155,11 @@ public class ConfiguredClassificationSource implements ClassificationSource
DataClassification result = ClassificationSource.super.highestAnywhere();
for(String table : tables.keySet())
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
for(String table : fields.keySet())
{
result = DataClassification.max(result, highest(table));
result = Classifications.max(result, highest(table));
}
return (result);
}
@@ -21,7 +21,6 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
@@ -33,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -27,7 +27,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -36,6 +35,7 @@ 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.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -29,7 +29,6 @@ import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
@@ -44,6 +43,7 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
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;
@@ -22,8 +22,8 @@ package com.kingsrook.qbits.assistant.engine;
import java.util.Optional;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
@@ -17,18 +17,6 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantModel;
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.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
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 java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
@@ -43,6 +31,18 @@ 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.AssistantModel;
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.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
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;
@@ -738,7 +738,7 @@ public class ProviderService
/*******************************************************************************
** P-e seam: no automatic alternative is selected in P-c. Never downgrade PHI eligibility.
*******************************************************************************/
public java.util.Optional<Active> eligibleAlternative(com.kingsrook.qbits.assistant.classification.DataClassification classification, AssistantProvider blockedProvider)
public java.util.Optional<Active> eligibleAlternative(com.kingsrook.qqq.backend.core.actions.ai.DataClassification classification, AssistantProvider blockedProvider)
{
return (java.util.Optional.empty());
}
@@ -27,11 +27,12 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.classification.Classifications;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
@@ -55,7 +56,7 @@ public class TurnClassification
public TurnClassification(ClassificationSource source, AssistantConversation conversation, Map<String, Object> pageContext)
{
this.source = source;
classification = DataClassification.stored(conversation.getMaxClassification());
classification = Classifications.stored(conversation.getMaxClassification());
if(pageContext != null && pageContext.get("tableName") instanceof String table)
{
touch(table, pageContext.get("recordId") == null ? source.ofTable(table) : source.highest(table));
@@ -210,7 +211,7 @@ public class TurnClassification
*******************************************************************************/
private void touchUnknown()
{
classification = DataClassification.max(classification, source.highestAnywhere());
classification = Classifications.max(classification, source.highestAnywhere());
for(String table : QContext.getQInstance().getTables().keySet())
{
if(!table.startsWith("assistant_") && source.highest(table) != DataClassification.PUBLIC)
@@ -229,7 +230,7 @@ public class TurnClassification
{
if(table != null && !table.startsWith("assistant_"))
{
classification = DataClassification.max(classification, value);
classification = Classifications.max(classification, value);
if(QContext.getQInstance().getTable(table) != null)
{
tables.add(table);
@@ -256,7 +257,7 @@ public class TurnClassification
{
if(conversation != null)
{
classification = DataClassification.max(classification, DataClassification.stored(conversation.getMaxClassification()));
classification = Classifications.max(classification, Classifications.stored(conversation.getMaxClassification()));
}
}
@@ -22,10 +22,10 @@ package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -58,8 +58,8 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
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;
@@ -72,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
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;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -148,6 +149,7 @@ public class TurnEngine implements Runnable
private ProviderService.Active budgetOrigin;
private volatile boolean finished = false;
private UiApiCredentials credentials;
@@ -176,6 +178,19 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** The credentials of the request that started the turn, forwarded to the
** UI API by the tools (session cookies, Authorization, API key as they came
** in). Without them the session UUID is used. Never logged.
*******************************************************************************/
public TurnEngine withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** Run the turn to completion. Never throws.
*******************************************************************************/
@@ -917,7 +932,7 @@ public class TurnEngine implements Runnable
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite));
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId())));
}
@@ -29,7 +29,7 @@ import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.classification.Classifications;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
@@ -788,8 +788,8 @@ public class AssistantStore
.withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables()));
if(current != null)
{
var maximum = DataClassification.max(
DataClassification.stored(current.getMaxClassification()), classification.getClassification());
var maximum = Classifications.max(
Classifications.stored(current.getMaxClassification()), classification.getClassification());
if(!maximum.name().equals(current.getMaxClassification()))
{
updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("maxClassification", maximum.name()));
@@ -23,10 +23,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
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.ProcessSummaryLineInterface;
@@ -25,7 +25,6 @@ import java.util.Objects;
import java.util.concurrent.Semaphore;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
@@ -36,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -223,7 +223,7 @@ public class RagService
checkKillSwitch();
filter.setSkip(skip);
UiApiResult page = runtime.getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName())
.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}"));
.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}"));
if(!page.isSuccess())
{
throw (new QException("Index query answered HTTP " + page.status() + "; existing chunks were preserved."));
@@ -40,6 +40,7 @@ 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;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -118,7 +119,7 @@ public class ScheduledTaskRunner
{
session = login.login(runtime, task);
QContext.init(runtime.getQInstance(), session);
AssistantApi api = new AssistantApi(runtime, session);
AssistantApi api = new AssistantApi(runtime, session).withCredentials(UiApiCredentials.ofSessionUUID(session.getUuid()));
Integer conversationId = ((Number) api.createConversation(Map.of("title", task.getName())).get("id")).intValue();
run.setConversationId(conversationId);
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId)
@@ -25,7 +25,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
@@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
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.DataClassification;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -187,7 +187,7 @@ public class PresentResultsTool
{
try
{
metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
metadata = context.gateway().invoke(context.prepare(new UiApiCall("GET", "/metaData")));
}
catch(QException e)
{
@@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
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.QFilterCriteria;
@@ -56,9 +57,11 @@ import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -150,8 +153,37 @@ public class UiApiToolbox
** Where a tool call runs: the user's session, the gateway, the policy, and
** what to do with a write that needs confirmation.
*******************************************************************************/
public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials, String turnId)
{
/*******************************************************************************
** With the session UUID only (no request credentials captured).
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, null, null);
}
/*******************************************************************************
** With the request credentials, outside a turn.
*******************************************************************************/
public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials)
{
this(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, null);
}
/*******************************************************************************
** A call made by this context: the caller's credentials, timezone and
** the client detail (the turn id, labelled assistant/turnId in the access
** log) filled in.
*******************************************************************************/
public UiApiCall prepare(UiApiCall call)
{
return (call.withSessionUUID(sessionUUID).withCredentials(credentials).withUserTimezone(userTimezone).withClientDetail(turnId));
}
}
@@ -328,11 +360,34 @@ public class UiApiToolbox
byName.put(DELETE_RECORD, tool(DELETE_RECORD, "Delete a record. The user confirms it in the panel before anything is deleted, unless the administrator turned confirmation off.",
AiJsonSchema.object(AiJsonSchema.properties("table", AiJsonSchema.string("Table name from list_tables"), "id", AiJsonSchema.string("The record's primary key value.")), List.of("table", "id"))));
if(!historyAllowed())
{
byName.remove(RECORD_HISTORY);
}
return (new ArrayList<>(byName.values()));
}
/*******************************************************************************
** Offer history only when every audit table it queries is available to the
** assistant channel, including inherited permission rules.
*******************************************************************************/
private boolean historyAllowed()
{
for(String name : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL))
{
QTableMetaData table = qInstance.getTable(name);
if(table == null || ClientChannelsMetaData.of(qInstance).isDenied("assistant", table.getName(), PermissionsHelper.getEffectivePermissionRules(table, qInstance)))
{
return (false);
}
}
return (true);
}
/*******************************************************************************
** The definitions a policy allows; write tools only when some table allows
** writes (anyWritableTable).
@@ -658,7 +713,7 @@ public class UiApiToolbox
if(truncated)
{
UiApiResult counted = context.gateway().invoke(new UiApiCall("POST", "/table/{tableName}/count").withPathParam("tableName", tableName).withJsonBody(filterBody(linkFilter))
.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
.withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withClientDetail(context.turnId()));
totalCount = counted.isSuccess() ? asMap(counted.json()).get("count") : null;
}
@@ -1255,7 +1310,7 @@ public class UiApiToolbox
*******************************************************************************/
private UiApiResult call(Context context, Outcome outcome, UiApiCall call) throws QException
{
call.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant");
context.prepare(call).withInvokedBy("assistant");
outcome.operation = call.operation();
UiApiResult response = context.gateway().invoke(call);
outcome.httpStatus = response.status();
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.assistant.tools.bulkload;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -31,8 +32,15 @@ public interface BulkLoadRunner
/*******************************************************************************
** A load to start: the target table, the upload, and the saved profile.
*******************************************************************************/
record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId)
record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId, UiApiCredentials credentials, String turnId)
{
/*******************************************************************************
** Without request credentials or a turn.
*******************************************************************************/
public Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId)
{
this(sessionUUID, userTimezone, gateway, tableName, uploadRef, savedBulkLoadProfileId, null, null);
}
}
@@ -541,7 +541,7 @@ public class BulkLoadTools
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> values) throws QException
{
BulkLoadRunner.RunHandle handle = runner.start(new BulkLoadRunner.Request(context.sessionUUID(), context.userTimezone(), context.gateway(), tableName,
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId"))));
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId")), context.credentials(), context.turnId()));
call.operation = handle.operation();
call.httpStatus = handle.httpStatus();
boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status());
@@ -863,7 +863,7 @@ public class BulkLoadTools
{
return (null);
}
uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant")
context.prepare(uiApiCall).withInvokedBy("assistant")
.withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard");
UiApiResult response = context.gateway().invoke(uiApiCall);
httpStatus = response.status();
@@ -70,6 +70,8 @@ public class JobsLaunchBulkLoadRunner implements BulkLoadRunner
.withPathParam("processName", processName)
.withJsonBody(body)
.withSessionUUID(request.sessionUUID())
.withCredentials(request.credentials())
.withClientDetail(request.turnId())
.withUserTimezone(request.userTimezone())
.withInvokedBy("assistant"));
@@ -0,0 +1,170 @@
/*
* 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.Locale;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.HeaderedUiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiResponse;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
/*******************************************************************************
** The assistant's gateway over core's {@link UiApiInvoker} (QRun-IO/qqq#1128,
** docs/UI-API-EXTENSIONS.md section 5): only spec'd /qqq/v1 operations (core
** and registered extensions), a loopback request through the identical
** Javalin pipeline, carrying the caller's own credentials exactly as they
** came in (session cookies, Authorization, API key). There is no service
** session and no way to elevate.
*******************************************************************************/
public class CoreUiApiGateway implements UiApiGateway
{
public static final String OWN_PREFIX = "/assistant";
public static final String CLIENT_CHANNEL = "assistant";
private final Supplier<String> baseUrlSupplier;
private final QInstance qInstance;
private volatile UiApiInvoker invoker;
/*******************************************************************************
** @param baseUrlSupplier this server's own root URL (e.g. http://127.0.0.1:8000)
** @param qInstance the instance (its spec extensions are invocable too)
*******************************************************************************/
public CoreUiApiGateway(Supplier<String> baseUrlSupplier, QInstance qInstance)
{
this.baseUrlSupplier = baseUrlSupplier;
this.qInstance = qInstance;
}
/*******************************************************************************
** The invoker, over a v1 middleware version that knows the instance's
** extensions (built on first use: the instance is complete by then).
*******************************************************************************/
UiApiInvoker invoker()
{
UiApiInvoker current = invoker;
if(current == null)
{
MiddlewareVersionV1 version = new MiddlewareVersionV1();
version.setQInstance(qInstance);
////////////////////////////////////////////////////////////////////////////
// the same as UiApiInvoker.forServer(server, "assistant"): this server's //
// loopback address, its first version, the client channel "assistant" //
// (a call adds only the detail after the slash: the turn id) //
////////////////////////////////////////////////////////////////////////////
current = new HeaderedUiApiInvoker(baseUrlSupplier, version).withClientChannel(CLIENT_CHANNEL);
invoker = current;
}
return (current);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
///////////////////////////////////////////////////////////////////
// never the assistant's own operations (no recursion, no turns //
// started by a turn) //
///////////////////////////////////////////////////////////////////
if(pathTemplate == null || pathTemplate.equals(OWN_PREFIX) || pathTemplate.startsWith(OWN_PREFIX + "/"))
{
return (false);
}
try
{
return (invoker().findOperation(HttpMethod.valueOf(method.toUpperCase(Locale.ROOT)), pathTemplate).isPresent());
}
catch(IllegalArgumentException e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if(call == null || call.getMethod() == null || call.getPathTemplate() == null)
{
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()));
}
UiApiCredentials credentials = call.getCredentials() != null ? call.getCredentials()
: (call.getSessionUUID() == null ? null : UiApiCredentials.ofSessionUUID(call.getSessionUUID()));
if(credentials == null || !credentials.isPresent())
{
throw (new QException("A session is required to invoke " + call.operation()));
}
HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest();
request.withMethod(HttpMethod.valueOf(call.getMethod().toUpperCase(Locale.ROOT)))
.withPath(call.getPathTemplate())
.withCredentials(credentials)
.withUserTimezone(call.getUserTimezone())
.withInvokedBy(call.getInvokedBy())
.withTimeout(call.getTimeout())
.withClientDetail(call.getClientDetail());
call.getHeaders().forEach(request::withHeader);
call.getPathParams().forEach(request::withPathParam);
call.getQueryParams().forEach(request::withQueryParam);
call.getFormParams().forEach(request::withFormParam);
if(call.getJsonBody() instanceof String json)
{
////////////////////////////////////////////////////////////////
// a pre-serialized body (e.g. a QQueryFilter as QQQ writes //
// it): parse it, the invoker serializes the object //
////////////////////////////////////////////////////////////////
try
{
request.withJsonBody(JsonUtils.toObject(json, Object.class));
}
catch(Exception e)
{
throw (new QException("The body of " + call.operation() + " is not valid JSON", e));
}
}
else if(call.getJsonBody() != null)
{
request.withJsonBody(call.getJsonBody());
}
UiApiResponse response = invoker().invoke(request);
return (new UiApiResult(response.getStatus(), response.getBody()));
}
}
@@ -1,286 +0,0 @@
/*
* 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.UUID;
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";
/** Headers a call cannot set itself (lower case). */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase());
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));
}
}
/*******************************************************************************
** Add server-owned client attribution for a scheduled turn. All operation,
** session, encoding and transport rules remain those of this gateway.
*******************************************************************************/
public UiApiResult invokeWithClient(UiApiCall call, String clientChannel) throws QException
{
if(call == null || !isInvocable(call.getMethod(), call.getPathTemplate()) || !StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A scheduled UI API call requires a spec'd operation and a session."));
}
try
{
HttpRequest request = HttpRequest.newBuilder(buildRequest(call), (name, value) -> true).setHeader("X-QQQ-Client", clientChannel).build();
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("Scheduled UI API request interrupted."));
}
catch(IOException e)
{
throw (new QException("Scheduled UI API request failed."));
}
}
/*******************************************************************************
** 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());
}
call.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase()))
{
builder.header(name, value);
}
});
if(!call.getFormParams().isEmpty())
{
////////////////////////////////////////////////////////////////
// multipart/form-data text fields (the /processes endpoints) //
////////////////////////////////////////////////////////////////
String boundary = "qqq-assistant-" + UUID.randomUUID();
StringBuilder body = new StringBuilder();
String lineEnd = "\r\n";
call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd)
.append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd)
.append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd)
.append(value).append(lineEnd));
body.append("--").append(boundary).append("--").append(lineEnd);
builder.header("Content-Type", "multipart/form-data; boundary=" + boundary);
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8));
}
else 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"));
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.uiapi;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
/*******************************************************************************
@@ -37,7 +38,9 @@ public class UiApiCall
private final Map<String, String> headers = new LinkedHashMap<>();
private Object jsonBody;
private String sessionUUID;
private UiApiCredentials credentials;
private String userTimezone;
private String clientDetail;
private String invokedBy = "assistant";
private Duration timeout = Duration.ofSeconds(60);
@@ -215,6 +218,29 @@ public class UiApiCall
/*******************************************************************************
** The caller's credentials exactly as their request carried them (session
** cookies, Authorization, API key); preferred over the session UUID alone.
** Never logged (their toString is redacted).
*******************************************************************************/
public UiApiCall withCredentials(UiApiCredentials credentials)
{
this.credentials = credentials;
return (this);
}
/*******************************************************************************
** Getter for credentials
*******************************************************************************/
public UiApiCredentials getCredentials()
{
return (credentials);
}
/*******************************************************************************
** Getter for sessionUUID
*******************************************************************************/
@@ -246,6 +272,29 @@ public class UiApiCall
/*******************************************************************************
** Fluent setter for clientDetail: the detail after the slash of the client
** channel the invoker labels the call with (assistant/turnId; a scheduled
** run uses scheduled/taskId). Ignored when blank.
*******************************************************************************/
public UiApiCall withClientDetail(String clientDetail)
{
this.clientDetail = clientDetail;
return (this);
}
/*******************************************************************************
** Getter for clientDetail
*******************************************************************************/
public String getClientDetail()
{
return (clientDetail);
}
/*******************************************************************************
** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By)
*******************************************************************************/
@@ -25,9 +25,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
** 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.
** {@link CoreUiApiGateway} implements it over core's `UiApiInvoker`
** (QRun-IO/qqq#1128); a host may inject another (tests).
*******************************************************************************/
public interface UiApiGateway
{
@@ -0,0 +1,126 @@
/*
* 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.qqq.middleware.javalin.specs.invoke;
import java.net.http.HttpRequest;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractMiddlewareVersion;
/*******************************************************************************
** Core's {@link UiApiInvoker} plus extra request headers (the assistant's
** X-QQQ-AI-Route). Core's {@link UiApiRequest} carries none, and the hook that
** builds the HTTP request is package-private, so this subclass lives in core's
** package to override it. Everything else (the operation allow-list, the
** credentials, the client channel, the depth limit) is core's own.
**
** STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8): delete this class and use
** UiApiRequest.withHeader once the qbit builds on a QQQ RC that has it.
*******************************************************************************/
public class HeaderedUiApiInvoker extends UiApiInvoker
{
/** Headers a request cannot set itself (lower case): core sets these. */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept",
INVOKED_BY_HEADER.toLowerCase(Locale.ROOT), TIMEZONE_HEADER.toLowerCase(Locale.ROOT), CLIENT_CHANNEL_HEADER.toLowerCase(Locale.ROOT));
/*******************************************************************************
** @param baseUrlSupplier the server's own root URL
** @param middlewareVersion the version whose operations can be invoked
*******************************************************************************/
public HeaderedUiApiInvoker(Supplier<String> baseUrlSupplier, AbstractMiddlewareVersion middlewareVersion)
{
super(baseUrlSupplier, middlewareVersion);
}
/*******************************************************************************
** Core's request, then the extra headers of a {@link HeaderedRequest}
** (never the reserved ones).
*******************************************************************************/
@Override
HttpRequest buildHttpRequest(UiApiOperation operation, UiApiRequest request) throws QException
{
HttpRequest built = super.buildHttpRequest(operation, request);
HttpRequest.Builder builder = HttpRequest.newBuilder(built, (name, value) -> true);
////////////////////////////////////////////////////////////////////////////
// RC.6 strips slashes from client detail. Preserve nested attribution //
// while keeping the channel chosen by core, never by extra headers. //
////////////////////////////////////////////////////////////////////////////
if(request.getClientDetail() != null && request.getClientDetail().contains("/"))
{
String channel = built.headers().firstValue(CLIENT_CHANNEL_HEADER).orElseThrow().split("/", 2)[0];
String detail = request.getClientDetail().replaceAll("[^A-Za-z0-9._:/-]", "");
builder.setHeader(CLIENT_CHANNEL_HEADER, channel + "/" + detail);
}
if(!(request instanceof HeaderedRequest headered))
{
return (builder.build());
}
headered.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)))
{
builder.header(name, value);
}
});
return (builder.build());
}
/*******************************************************************************
** A UI API request that also carries extra headers.
*******************************************************************************/
public static class HeaderedRequest extends UiApiRequest
{
private final Map<String, String> headers = new LinkedHashMap<>();
/*******************************************************************************
** Add a header (ignored when the name or value is null).
*******************************************************************************/
public HeaderedRequest withHeader(String name, String value)
{
if(name != null && value != null)
{
headers.put(name, value);
}
return (this);
}
/*******************************************************************************
** Getter for headers
*******************************************************************************/
public Map<String, String> getHeaders()
{
return (headers);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -24,7 +24,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
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.CoreUiApiGateway;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
@@ -176,9 +177,10 @@ class ArchitectureTest
{
assertThat(ContextBuilder.KERNEL_TOOLS).containsExactlyInAnyOrder("navigate", "search_help");
assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(ContextBuilder.KERNEL_TOOLS);
CoreUiApiGateway gateway = new CoreUiApiGateway(() -> "http://127.0.0.1:1", new QInstance());
for(String[] operation : UiApiToolbox.OPERATIONS.values())
{
assertThat(LoopbackUiApiGateway.getSpecOperations()).as("spec'd: " + operation[0] + " " + operation[1]).contains(operation[0] + " " + operation[1]);
assertThat(gateway.isInvocable(operation[0], operation[1])).as("spec'd: " + operation[0] + " " + operation[1]).isTrue();
}
}
@@ -35,11 +35,11 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -57,7 +57,6 @@ import static org.assertj.core.api.Assertions.assertThat;
*******************************************************************************/
class AssistantRouteProviderTest extends BaseAssistantTest
{
private Javalin javalin;
private String base;
private HttpClient client = HttpClient.newHttpClient();
@@ -70,10 +69,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest
void startServer()
{
QContext.clear();
AssistantRouteProvider routes = new AssistantRouteProvider().withPingMillis(200);
routes.setQInstance(qInstance);
javalin = Javalin.create(routes::acceptJavalinConfig).start(0);
base = "http://localhost:" + javalin.port() + AssistantRouteProvider.BASE_PATH;
TurnStreams.setPingMillis(200);
base = UiApiTestServer.serve(qInstance, config) + AssistantRouteProvider.BASE_PATH;
}
@@ -84,7 +81,7 @@ class AssistantRouteProviderTest extends BaseAssistantTest
@AfterEach
void stopServer()
{
javalin.stop();
TurnStreams.setPingMillis(TurnStreams.PING_MILLIS);
}
@@ -0,0 +1,73 @@
/*
* 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.api.specs;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The assistant's UI API extension: registered by the producer, and its
** OpenAPI fragment matches the committed one
** (src/main/resources/openapi/qqq-v1/assistant.yaml) and is compatible with
** the last release's (baseline). Regenerate with
** -Dassistant.regenerateOpenApi=true.
*******************************************************************************/
class AssistantSpecsContractTest extends BaseAssistantTest
{
static final Path COMMITTED = Path.of("src/main/resources/openapi/qqq-v1/assistant.yaml");
static final Path BASELINE = Path.of("src/test/resources/openapi/qqq-v1/assistant-baseline.yaml");
/*******************************************************************************
**
*******************************************************************************/
@Test
void testRegisteredAndCommitted() throws Exception
{
assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions()).containsKey(AssistantSpecs.EXTENSION_NAME);
String generated = UiApiContract.generateExtensionYaml(new MiddlewareVersionV1(), AssistantSpecs.extension());
if(Boolean.getBoolean("assistant.regenerateOpenApi"))
{
Files.createDirectories(COMMITTED.getParent());
Files.writeString(COMMITTED, generated, StandardCharsets.UTF_8);
if(!Files.exists(BASELINE))
{
Files.createDirectories(BASELINE.getParent());
Files.writeString(BASELINE, generated, StandardCharsets.UTF_8);
}
}
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
assertThat(problems).isEmpty();
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -67,11 +68,11 @@ class ClassificationTest extends BaseAssistantTest
assertThat(source.highest(null)).isEqualTo(DataClassification.PUBLIC);
assertThat(source.highest("assistant_message")).isEqualTo(DataClassification.PUBLIC);
assertThat(source.highestAnywhere()).isEqualTo(DataClassification.PHI);
assertThat(DataClassification.max(null, null)).isEqualTo(DataClassification.PUBLIC);
assertThat(DataClassification.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI);
assertThat(Classifications.max(null, null)).isEqualTo(DataClassification.PUBLIC);
assertThat(Classifications.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI);
assertThat(DataClassification.PII.isAtLeast(null)).isTrue();
assertThat(DataClassification.PUBLIC.isAtLeast(DataClassification.PII)).isFalse();
assertThat(DataClassification.stored("future")).isEqualTo(DataClassification.PHI);
assertThat(Classifications.stored("future")).isEqualTo(DataClassification.PHI);
ClassificationSource host = new ConfiguredClassificationSource().withClassification("media", DataClassification.PII);
assertThat(config.withClassificationSource(host).getClassificationSource()).isSameAs(host);
assertThat(new ConfiguredClassificationSource().withClassification(null, null).withClassification(null, null, null).ofTable(null)).isEqualTo(DataClassification.PUBLIC);
@@ -26,7 +26,6 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
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.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -17,7 +17,6 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
@@ -30,11 +29,11 @@ import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.home.AssistantHomeData;
import com.kingsrook.qbits.assistant.home.AssistantHomeView;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -55,6 +54,7 @@ import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
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.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -21,6 +21,7 @@ import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@@ -53,6 +54,10 @@ class GoldenEvalTest extends BaseAssistantTest
@MethodSource("tasks")
void golden(GoldenTasks.Task task) throws Exception
{
if("D-page-12-this-week".equals(task.id()))
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
}
task.script().accept(provider);
TurnRunner.Outcome outcome = runner.run(task.userId(), task.prompt());
@@ -90,7 +90,11 @@ class RedTeamTest extends BaseAssistantTest
for(Map<String, Object> result : outcome.events(TurnEvent.TOOL_RESULT))
{
if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name")))
if("get_record_history".equals(result.get("name")))
{
assertThat(result.get("outcome")).as("history is not offered under the default channel wall").isEqualTo("INVALID_INPUT");
}
else if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name")))
{
assertThat(result.get("outcome")).as(String.valueOf(result.get("name"))).isIn("DENIED", "NOT_FOUND");
}
@@ -27,7 +27,6 @@ import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
@@ -46,6 +45,7 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
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;
@@ -22,7 +22,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
@@ -49,6 +48,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
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.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
@@ -39,7 +39,6 @@ import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -56,6 +55,7 @@ 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.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
@@ -72,7 +72,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -137,7 +137,7 @@ public class AssistantTestFixture
.withField(new QFieldMetaData("statDate", QFieldType.DATE).withLabel("Stat Date"))
.withField(new QFieldMetaData("pageviews", QFieldType.INTEGER).withLabel("Pageviews")));
if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qbits.assistant.classification.DataClassification.PHI)
if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qqq.backend.core.actions.ai.DataClassification.PHI)
{
qInstance.addTable(table("patient", "Patient", "status")
.withField(new QFieldMetaData("email", QFieldType.STRING))
@@ -41,9 +41,13 @@ 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.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -58,6 +62,55 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class UiApiToolsSecurityTest extends BaseAssistantTest
{
/*******************************************************************************
** The default channel wall hides history, even when the user can read audits.
*******************************************************************************/
@Test
void historyHiddenByDefault()
{
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
assertThat(tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12")).getHttpStatus()).isEqualTo(403);
}
/*******************************************************************************
** A host opt-in offers history and reads it through the real UI API.
*******************************************************************************/
@Test
void historyOfferedAndReadableAfterHostOptIn()
{
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).contains(UiApiToolbox.RECORD_HISTORY);
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12", "since", "THIS_WEEK"));
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(outcome.getHttpStatus()).isEqualTo(200);
assertThat(outcome.getResult().getModelContent()).contains("title", "metaDescription", "/app/page/12");
}
/*******************************************************************************
** Each audit table's explicit or inherited channel denial still hides history.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL })
void historyRespectsEachAuditTableDenial(String name)
{
ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance);
channels.getDeniedPatterns().get("assistant").remove("audit*");
channels.withDeniedObject("assistant", name);
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
channels.getDeniedObjects().get("assistant").remove(name);
qInstance.getTable(name).getPermissionRules().withDeniedClients(Set.of("assistant"));
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
qInstance.setDefaultPermissionRules(qInstance.getTable(name).getPermissionRules());
qInstance.getTable(name).setPermissionRules(null);
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
}
private final HttpClient client = HttpClient.newHttpClient();
@@ -0,0 +1,82 @@
/*
* 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.qqq.middleware.javalin.specs.invoke;
import java.net.http.HttpRequest;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Extra route headers cannot override core-owned credentials or attribution.
*******************************************************************************/
class HeaderedUiApiInvokerTest
{
/*******************************************************************************
** Non-reserved headers pass through; reserved headers are ignored regardless
** of case, retaining only values supplied through core's request properties.
*******************************************************************************/
@Test
void forwardsOnlyNonReservedHeaders() throws Exception
{
HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1());
invoker.withClientChannel("assistant");
HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest();
request.withMethod(HttpMethod.POST).withPath("/table/{tableName}/query").withPathParam("tableName", "page")
.withCredentials(new UiApiCredentials("session-value", null, "Bearer credential-value", "key-value"))
.withJsonBody(java.util.Map.of()).withUserTimezone("America/Chicago").withInvokedBy("turn-engine").withClientDetail("scheduled/42");
for(String name : List.of("cOoKiE", "AUTHORIZATION", "X-API-Key", "Content-Type", "ACCEPT", "X-QQQ-Invoked-By", "x-qqq-usertimezone", "X-QQQ-CLIENT"))
{
request.withHeader(name, "must-not-forward");
}
request.withHeader("X-QQQ-AI-Route", "phi-route").withHeader("X-Request-Tag", "tag-value").withHeader(null, "ignored").withHeader("X-Null", null);
UiApiOperation operation = invoker.findOperation(HttpMethod.POST, request.getPath()).orElseThrow();
HttpRequest built = invoker.buildHttpRequest(operation, request);
assertThat(built.headers().allValues("Cookie")).containsExactly("sessionUUID=session-value");
assertThat(built.headers().allValues("Authorization")).containsExactly("Bearer credential-value");
assertThat(built.headers().allValues("x-api-key")).containsExactly("key-value");
assertThat(built.headers().allValues("Content-Type")).containsExactly("application/json");
assertThat(built.headers().allValues("Accept")).isEmpty();
assertThat(built.headers().allValues(UiApiInvoker.INVOKED_BY_HEADER)).containsExactly("turn-engine");
assertThat(built.headers().allValues(UiApiInvoker.TIMEZONE_HEADER)).containsExactly("America/Chicago");
assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/scheduled/42");
assertThat(built.headers().allValues("X-QQQ-AI-Route")).containsExactly("phi-route");
assertThat(built.headers().allValues("X-Request-Tag")).containsExactly("tag-value");
assertThat(built.headers().allValues("X-Null")).isEmpty();
assertThat(built.headers().map().values()).allSatisfy(values -> assertThat(values).doesNotContain("must-not-forward"));
}
/*******************************************************************************
** Ordinary core requests retain attribution without requiring extra headers.
*******************************************************************************/
@Test
void ordinaryRequestPreservesCoreHeaders() throws Exception
{
HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1());
invoker.withClientChannel("assistant");
UiApiRequest request = new UiApiRequest().withMethod(HttpMethod.GET).withPath("/metaData").withClientDetail("turn-42");
HttpRequest built = invoker.buildHttpRequest(invoker.findOperation(HttpMethod.GET, request.getPath()).orElseThrow(), request);
assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/turn-42");
assertThat(built.headers().allValues("X-QQQ-AI-Route")).isEmpty();
}
}
File diff suppressed because it is too large. Load diff