Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 366e5d8af4 feat(enterprise): the assistant answers, with Claude when keyed or a local Ollama
The assistant qbit was installed with no model provider, and the
enterprise session lacked the literal assistant.hasAccess permission
(not in getAllAvailablePermissionNames), so every user saw NO_PERMISSION.
Sessions now hold assistant.hasAccess and assistant.admin, and the
profile registers the Anthropic and OpenAI-compatible provider types with
two provider rows reconciled at every start: Anthropic (key variable
ANTHROPIC_API_KEY, name only) is the default when the variable is set,
else a local Ollama (127.0.0.1:11434/v1, QQQ_ASSISTANT_OLLAMA_MODEL,
default llama3.3:70b-instruct-q4_K_M) when it answers and lists the
model, else neither and the assistant reports NOT_CONFIGURED. A turn may
take 900 s and an Ollama call 600 s for a local 70B model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:12:16 -06:00
7 changed files with 396 additions and 4 deletions

No files matched your search

+12
View File
@@ -15,6 +15,18 @@ Core uses mock authentication. The `sessionId` cookie selects the two fixed demo
The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent` as Admin, and inspect `/demo/order-webhook-receipts` to see the delivered payload. Demo User can read webhook records but cannot edit their configuration or invoke the delivery process. The receiver is available only in the core profile, which Compose publishes on host loopback.
## Enterprise profile: the assistant
`QQQ_ALL_PROFILE=enterprise` (the terminal-client test server, started by qqq-cli's
`scripts/enterprise-server.sh`) installs qbit-assistant with two model provider rows, reconciled at every
start: **Anthropic (Claude)** (key variable `ANTHROPIC_API_KEY`; only the name is stored) and **Local
Ollama** (OpenAI-compatible, kind OLLAMA, `QQQ_ASSISTANT_OLLAMA_URL` default `http://127.0.0.1:11434/v1`,
model `QQQ_ASSISTANT_OLLAMA_MODEL` default `llama3.3:70b-instruct-q4_K_M`, no key). The default is
Anthropic when `ANTHROPIC_API_KEY` is set, else Ollama when it answers and lists the model, else neither
(the assistant reports `NOT_CONFIGURED` with what to configure). Every enterprise session holds
`assistant.hasAccess` and `assistant.admin`. A turn may take 900 s and one Ollama call 600 s, for a local
70B model.
## Full profile
Set `QQQ_ALL_PROFILE=full` to use external services. The full profile requires an explicit `QQQ_ALL_BIND_HOST`; use `127.0.0.1` for local-only access or deliberately set another interface when a reverse proxy needs it. `QQQ_ALL_PORT` defaults to `8080`. The full profile replaces core's mock authentication with Keycloak OIDC. It does not start an embedded broker.
+2
View File
@@ -52,6 +52,8 @@
<!-- Enterprise profile (QQQ_ALL_PROFILE=enterprise): every locally runnable qbit, for terminal-client testing. -->
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-alerts</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant-provider-anthropic</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant-provider-openai</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-backups</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-cdn</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-cluster</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
@@ -66,6 +66,10 @@ final class CoreRuntime implements AutoCloseable
try
{
WebhookSubscriptionsHelper.clearMemoizations();
if(EnterpriseFeatures.isEnabled())
{
EnterpriseAssistant.reconcileAtStartup(launcher.getQInstance());
}
}
finally
{
@@ -97,7 +97,7 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin");
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
@@ -0,0 +1,258 @@
/*
* 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
*/
package com.kingsrook.qqq.all;
import java.io.Serializable;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicProviderType;
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleProviderType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** The assistant on the enterprise test server: two provider rows, kept in
** step with the environment at every start.
**
** - "Anthropic (Claude)": type anthropic, key variable ANTHROPIC_API_KEY
** (only the NAME is stored; enterprise-server.sh passes the value from its
** private 0600 secrets file). Enabled when the variable is set.
** - "Local Ollama": type openai-compatible, kind OLLAMA, base URL
** http://127.0.0.1:11434/v1, model QQQ_ASSISTANT_OLLAMA_MODEL (default
** llama3.3:70b-instruct-q4_K_M), no key. Enabled when Ollama answers and
** lists that model.
**
** The default is Anthropic when its key is set, else Ollama when reachable;
** with neither, both rows are disabled and GET /assistant/config answers
** NOT_CONFIGURED with a message that says what to configure.
*******************************************************************************/
final class EnterpriseAssistant
{
private static final QLogger LOG = QLogger.getLogger(EnterpriseAssistant.class);
static final String ANTHROPIC_ROW = "Anthropic (Claude)";
static final String OLLAMA_ROW = "Local Ollama";
static final String ANTHROPIC_KEY_ENV = "ANTHROPIC_API_KEY";
static final String OLLAMA_MODEL_ENV = "QQQ_ASSISTANT_OLLAMA_MODEL";
static final String OLLAMA_URL_ENV = "QQQ_ASSISTANT_OLLAMA_URL";
static final String OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
static final String OLLAMA_MODEL = "llama3.3:70b-instruct-q4_K_M";
/////////////////////////////////////////////////////////////////////////////
// a local 70B model reads the tool catalog slowly: give a turn and one //
// model call room (the qbit defaults are 300 s and 120 s) //
/////////////////////////////////////////////////////////////////////////////
static final Integer MAX_TURN_SECONDS = 900;
static final Integer OLLAMA_REQUEST_SECONDS = 600;
/*******************************************************************************
** Which provider answers, from the environment at startup.
*******************************************************************************/
enum Choice
{
ANTHROPIC,
OLLAMA,
NONE
}
private EnterpriseAssistant()
{
}
/*******************************************************************************
** The QBit config: both provider types, the Ollama URL allowed as a base
** URL override, and the code-registered Anthropic fallback.
*******************************************************************************/
static AssistantQBitConfig config(String backendName, String uiApiBaseUrl, Function<String, String> env)
{
return new AssistantQBitConfig()
.withBackendName(backendName).withUiApiBaseUrl(uiApiBaseUrl)
.withProviderType(new AnthropicProviderType())
.withProviderType(new OpenAiCompatibleProviderType())
.withAllowedBaseUrls(Set.of(ollamaBaseUrl(env)))
.withMaxTurnSeconds(MAX_TURN_SECONDS);
}
/*******************************************************************************
** Anthropic when its key is set, else Ollama when reachable, else none.
*******************************************************************************/
static Choice choose(Function<String, String> env, Predicate<String> ollamaHasModel)
{
String key = env.apply(ANTHROPIC_KEY_ENV);
if(key != null && !key.isBlank())
{
return Choice.ANTHROPIC;
}
return ollamaHasModel.test(ollamaModel(env)) ? Choice.OLLAMA : Choice.NONE;
}
/*******************************************************************************
** The Ollama model (QQQ_ASSISTANT_OLLAMA_MODEL, default llama3.3 70B).
*******************************************************************************/
static String ollamaModel(Function<String, String> env)
{
String model = env.apply(OLLAMA_MODEL_ENV);
return model == null || model.isBlank() ? OLLAMA_MODEL : model.trim();
}
/*******************************************************************************
** The Ollama base URL (QQQ_ASSISTANT_OLLAMA_URL, default 127.0.0.1:11434/v1).
*******************************************************************************/
static String ollamaBaseUrl(Function<String, String> env)
{
String url = env.apply(OLLAMA_URL_ENV);
return url == null || url.isBlank() ? OLLAMA_BASE_URL : url.trim();
}
/*******************************************************************************
** The two provider rows for a choice (id left out).
*******************************************************************************/
static List<QRecord> rows(Choice choice, Function<String, String> env)
{
QRecord anthropic = new QRecord()
.withValue("name", ANTHROPIC_ROW)
.withValue("providerType", AnthropicModelProvider.NAME)
.withValue("apiKeyEnvName", ANTHROPIC_KEY_ENV)
.withValue("enabled", choice == Choice.ANTHROPIC)
.withValue("isDefault", choice == Choice.ANTHROPIC);
QRecord ollama = new QRecord()
.withValue("name", OLLAMA_ROW)
.withValue("providerType", new OpenAiCompatibleProviderType().getType())
.withValue("kind", "OLLAMA")
.withValue("location", "LOCAL")
.withValue("baseUrl", ollamaBaseUrl(env))
.withValue("model", ollamaModel(env))
.withValue("requestTimeoutSeconds", OLLAMA_REQUEST_SECONDS)
.withValue("enabled", choice == Choice.OLLAMA)
.withValue("isDefault", choice == Choice.OLLAMA);
return List.of(anthropic, ollama);
}
/*******************************************************************************
** Insert or update the two rows (matched by name) for this start. Runs
** with the caller's QContext (the system session at startup).
*******************************************************************************/
static Choice reconcile(QInstance instance, Function<String, String> env, Predicate<String> ollamaHasModel) throws QException
{
if(instance.getTable(AssistantProvider.TABLE_NAME) == null)
{
return Choice.NONE;
}
Choice choice = choose(env, ollamaHasModel);
List<QRecord> wanted = rows(choice, env);
QueryInput queryInput = new QueryInput(AssistantProvider.TABLE_NAME);
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.IN, List.of(ANTHROPIC_ROW, OLLAMA_ROW))));
Map<Serializable, QRecord> existing = new java.util.HashMap<>();
for(QRecord record : new QueryAction().execute(queryInput).getRecords())
{
existing.putIfAbsent(record.getValueString("name"), record);
}
//////////////////////////////////////////////////////////////////////////
// the non-default row first, so marking the default one last leaves it //
// as the only default (the qbit clears other defaults on save) //
//////////////////////////////////////////////////////////////////////////
List<QRecord> ordered = wanted.stream().sorted((a, b) -> Boolean.compare(Boolean.TRUE.equals(a.getValueBoolean("isDefault")), Boolean.TRUE.equals(b.getValueBoolean("isDefault")))).toList();
for(QRecord row : ordered)
{
QRecord current = existing.get(row.getValueString("name"));
if(current == null)
{
new InsertAction().execute(new InsertInput(AssistantProvider.TABLE_NAME).withRecord(row));
}
else
{
row.setValue("id", current.getValue("id"));
new UpdateAction().execute(new UpdateInput(AssistantProvider.TABLE_NAME).withRecord(row));
}
}
return choice;
}
/*******************************************************************************
** Reconcile from the process environment; a failure is logged and leaves
** the server running (the assistant then reports NOT_CONFIGURED).
*******************************************************************************/
static void reconcileAtStartup(QInstance instance)
{
try
{
Choice choice = reconcile(instance, System::getenv, model -> ollamaHasModel(ollamaBaseUrl(System::getenv), model));
LOG.info("Enterprise assistant model provider: " + choice);
}
catch(QException | RuntimeException e)
{
LOG.warn("Could not set up the enterprise assistant provider rows", e);
}
}
/*******************************************************************************
** True when Ollama answers GET {base}/models within 3 s and lists the model.
*******************************************************************************/
static boolean ollamaHasModel(String baseUrl, String model)
{
try
{
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/models")).timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200 && response.body().contains("\"" + model + "\"");
}
catch(Exception e)
{
if(e instanceof InterruptedException)
{
Thread.currentThread().interrupt();
}
return false;
}
}
}
@@ -26,7 +26,6 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.BackupsQBitProducer;
@@ -350,8 +349,7 @@ final class EnterpriseFeatures
.withTrustedPublicKeys(Map.of("local", keyPair.getPublic()))
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i);
});
features.put("assistant", i -> new AssistantQBitProducer().withQBitConfig(new AssistantQBitConfig()
.withBackendName(backendName).withUiApiBaseUrl(baseUrl)
features.put("assistant", i -> new AssistantQBitProducer().withQBitConfig(EnterpriseAssistant.config(backendName, baseUrl, System::getenv)
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
if(isSampleAppOnClasspath())
@@ -0,0 +1,118 @@
/*
* 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
*/
package com.kingsrook.qqq.all;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The enterprise assistant's provider rows follow the environment: Anthropic with a key, else Ollama, else none. */
class EnterpriseAssistantTest
{
@TempDir Path dataDirectory;
@AfterEach
void clear()
{
QContext.clear();
MemoryRecordStore.fullReset();
}
@Test
void choiceFollowsKeyThenOllama()
{
assertEquals(EnterpriseAssistant.Choice.ANTHROPIC, EnterpriseAssistant.choose(env(Map.of("ANTHROPIC_API_KEY", "sk-test")), model -> true));
assertEquals(EnterpriseAssistant.Choice.OLLAMA, EnterpriseAssistant.choose(env(Map.of("ANTHROPIC_API_KEY", " ")), model -> true));
assertEquals(EnterpriseAssistant.Choice.NONE, EnterpriseAssistant.choose(env(Map.of()), model -> false));
}
@Test
void ollamaModelAndUrlFromEnvironment()
{
List<String> asked = new java.util.ArrayList<>();
EnterpriseAssistant.choose(env(Map.of("QQQ_ASSISTANT_OLLAMA_MODEL", "qwen3-coder:30b")), model -> asked.add(model));
assertEquals(List.of("qwen3-coder:30b"), asked);
assertEquals("llama3.3:70b-instruct-q4_K_M", EnterpriseAssistant.ollamaModel(env(Map.of())));
assertEquals("http://127.0.0.1:11434/v1", EnterpriseAssistant.ollamaBaseUrl(env(Map.of())));
AssistantQBitConfig config = EnterpriseAssistant.config("h2", "http://127.0.0.1:8078", env(Map.of("QQQ_ASSISTANT_OLLAMA_URL", "http://127.0.0.1:9999/v1")));
assertTrue(config.getAllowedBaseUrls().contains("http://127.0.0.1:9999/v1"));
assertTrue(config.getProviderTypes().containsKey("anthropic"));
assertTrue(config.getProviderTypes().containsKey("openai-compatible"));
}
@Test
void rowsNeverHoldAKey()
{
List<QRecord> rows = EnterpriseAssistant.rows(EnterpriseAssistant.Choice.ANTHROPIC, env(Map.of("ANTHROPIC_API_KEY", "sk-secret")));
for(QRecord row : rows)
{
assertFalse(row.getValues().values().stream().anyMatch(v -> String.valueOf(v).contains("sk-secret")));
}
assertEquals("ANTHROPIC_API_KEY", rows.get(0).getValueString("apiKeyEnvName"));
assertNull(rows.get(1).getValue("apiKeyEnvName"));
}
@Test
void reconcileInsertsThenSwitchesTheDefault() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
new AssistantQBitProducer().withQBitConfig(EnterpriseAssistant.config("transientMemory", "http://127.0.0.1:1", env(Map.of()))).produce(instance);
QContext.init(instance, new QSystemUserSession());
assertEquals(EnterpriseAssistant.Choice.OLLAMA, EnterpriseAssistant.reconcile(instance, env(Map.of()), model -> true));
Map<String, QRecord> rows = providerRows();
assertEquals(2, rows.size());
assertTrue(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("isDefault"));
assertTrue(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("enabled"));
assertEquals("OLLAMA", rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueString("kind"));
assertEquals("llama3.3:70b-instruct-q4_K_M", rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueString("model"));
assertFalse(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("enabled"));
assertEquals(EnterpriseAssistant.Choice.ANTHROPIC, EnterpriseAssistant.reconcile(instance, env(Map.of("ANTHROPIC_API_KEY", "sk-test")), model -> true));
rows = providerRows();
assertEquals(2, rows.size());
assertTrue(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("isDefault"));
assertTrue(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("enabled"));
assertFalse(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("isDefault"));
assertFalse(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("enabled"));
assertEquals(EnterpriseAssistant.Choice.NONE, EnterpriseAssistant.reconcile(instance, env(Map.of()), model -> false));
assertTrue(providerRows().values().stream().noneMatch(row -> row.getValueBoolean("enabled")));
}
private static Map<String, QRecord> providerRows() throws Exception
{
return new QueryAction().execute(new QueryInput(AssistantProvider.TABLE_NAME)).getRecords().stream()
.collect(Collectors.toMap(row -> row.getValueString("name"), row -> row));
}
private static Function<String, String> env(Map<String, String> values)
{
return new HashMap<>(values)::get;
}
}