Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 2ad5e9068d fix(api): say NOT_CONFIGURED, with what to configure, when no model provider is usable (#82)
GET /config checked the user's permission before the provider, so a
server with no model provider told every user to ask for access, and
calls answered 403 NO_PERMISSION. Server-wide causes now come first
(DISABLED, NOT_CONFIGURED, NO_PERMISSION, KILL_SWITCH), the probe has a
`message` naming the cause (for NOT_CONFIGURED: add an enabled row on
Assistant > Providers, e.g. Anthropic with ANTHROPIC_API_KEY set, or a
local OpenAI-compatible server such as Ollama), and a user without
access on such a server gets 503 NOT_CONFIGURED instead of 403. The code
stays NOT_CONFIGURED because the Next UI and the spec already know it.
UI API extension assistant 1.7.0 (additive field).

The existing OpenAI-compatible adapter was verified live against a local
Ollama llama3.3:70b-instruct-q4_K_M (text and a tool call), so no new
provider module is needed for a keyless local model; the README shows
the Ollama row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:07:21 -06:00
james.maesandCodex bcf173f4f2 ci: adopt managed orb 0.8 patch track (#78)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:31:42 -05:00
james.maesandCodex 5a1985fd96 ci: adopt shared orb 0.7.18 (#77)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:48:54 -05:00
james.maesandCodex 055df2e764 ci: adopt shared orb 0.7.17 (#76)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:20:22 -05:00
james.maesandCodex 0f79e7352a ci: use the shared branch-aware CodeQL worker (#74)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:51:16 -05:00
10 changed files with 156 additions and 18 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.12
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+1 -1
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: 0.7.12
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## Unreleased - Clear reason without a model provider (UI API extension 1.7.0)
- `GET /config` checks the server-wide causes before the user's permission: `DISABLED`, then `NOT_CONFIGURED` (no usable model provider), then `NO_PERMISSION`, then `KILL_SWITCH`. A server with no provider no longer tells users to ask for access (#82).
- UI API extension assistant 1.7.0: `GET /config` has `message` whenever `reason` is set: a sentence for the user. For `NOT_CONFIGURED` it says what an administrator configures (an enabled row on **Assistant > Providers**, for example Anthropic with `ANTHROPIC_API_KEY` set in the server environment, or a local OpenAI-compatible server such as Ollama).
- Without a usable provider, calls from a user who also lacks `assistant.hasAccess` answer `503 NOT_CONFIGURED` with that message instead of `403 NO_PERMISSION`; users with access keep their conversations, and a new question answers `503 NOT_CONFIGURED`.
- Verified the OpenAI-compatible adapter (kind `OLLAMA`) live against a local Ollama `llama3.3:70b-instruct-q4_K_M`, including a tool call (`LiveOpenAiCompatibleTest`).
## Unreleased - Conversation management (UI API extension 1.6.0)
- `assistant_conversation` is open to every assistant user (`assistant.hasAccess` or `assistant.admin`, through the new `AssistantAccessPermissionChecker`) in the standard table UI. Rows stay locked to their owner by the `userId` record lock, so a user only ever sees, updates and deletes their own conversations; admins with the all-access key still see everyone's. The Assistant app (its nav) and every other assistant table stay admin-only.
+11
View File
@@ -479,6 +479,17 @@ OLLAMA, LM_STUDIO, VLLM, LLAMA_CPP or OPENAI_COMPATIBLE as their kind.
Registry prices override type/model prices, then configured prices. Unpriced
local models cost zero. Capabilities can be adjusted per registered model.
For a local Ollama with no key (the live test ran against `llama3.3:70b-instruct-q4_K_M`):
```java
config.withProviderType(new OpenAiCompatibleProviderType());
// provider row: type openai-compatible, kind OLLAMA, model llama3.3:70b-instruct-q4_K_M,
// base URL empty (http://localhost:11434/v1) or an allow-listed override such as http://127.0.0.1:11434/v1
```
Without any usable provider `GET /config` answers `enabled: false, reason: NOT_CONFIGURED` with a
`message` that says what to configure, before any permission check.
For Ollama behind a TLS proxy with a private CA and bearer authentication:
```java
+1 -1
View File
@@ -44,7 +44,7 @@ Guards:
`ProviderService.resolve(policyModel)`:
1. The enabled row marked default (marking one default unmarks the others), else the first enabled row.
2. With no enabled row, the provider registered in code (`withModelProvider`) and the policy's main model: hosts that never add a row keep working.
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently.
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently. `GET /config` reports it before `NO_PERMISSION`, with a `message` naming what to configure (#82).
The model is the row's, else the policy's main model. The rows and prices are cached for `policyCacheSeconds` and dropped on any change.
@@ -86,6 +86,20 @@ public class AssistantApi
public static final int TITLE_MAX_CHARS = 60;
/////////////////////////////////////////////////////////////////////////////
// GET /config reason (and error code) when no model provider is usable: //
// no enabled provider row that can be built (a missing key, a disallowed //
// variable or URL, an uninstalled type) and no provider registered in code //
/////////////////////////////////////////////////////////////////////////////
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
public static final String NO_MODEL_PROVIDER_MESSAGE = "No model provider is configured on this server. An administrator adds an enabled provider on "
+ "Assistant > Providers (for example type Anthropic, whose API key variable ANTHROPIC_API_KEY must be set in the server environment, or a local "
+ "OpenAI-compatible server such as Ollama).";
public static final String NO_PERMISSION_MESSAGE = "You do not have access to the assistant.";
public static final String DISABLED_MESSAGE = "The assistant is turned off for this application.";
public static final String KILL_SWITCH_MESSAGE = "The assistant has been turned off by an administrator.";
private final AssistantRuntime runtime;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -134,27 +148,39 @@ public class AssistantApi
Map<String, Object> result = new LinkedHashMap<>();
String reason = null;
String message = null;
/////////////////////////////////////////////////////////////////////////
// server-wide causes come before the user's own permission, so a //
// server without a usable model provider says so (QRun-IO/qbit- //
// assistant#82) instead of sending the user to ask for access //
/////////////////////////////////////////////////////////////////////////
if(!config.isEffectivelyEnabled())
{
reason = "DISABLED";
message = DISABLED_MESSAGE;
}
else if(activeProvider() == null)
{
reason = NOT_CONFIGURED;
message = NO_MODEL_PROVIDER_MESSAGE;
}
else if(!hasAccess())
{
reason = "NO_PERMISSION";
}
else if(activeProvider() == null)
{
reason = "NOT_CONFIGURED";
message = NO_PERMISSION_MESSAGE;
}
else if(runtime.getPolicyService().current(false).killSwitch())
{
reason = "KILL_SWITCH";
message = KILL_SWITCH_MESSAGE;
}
result.put("enabled", reason == null);
if(reason != null)
{
result.put("reason", reason);
result.put("message", message);
}
result.put("shortcut", config.getShortcut());
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
@@ -425,11 +451,11 @@ public class AssistantApi
ProviderService.Active active = runtime.getProviderService().resolve(policy.mainModel());
if(active == null)
{
throw (new AssistantApiException(503, "NOT_CONFIGURED", "The assistant is not configured on this server."));
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
}
if(policy.killSwitch())
{
throw (new AssistantApiException(503, "KILL_SWITCH", "The assistant has been turned off by an administrator."));
throw (new AssistantApiException(503, "KILL_SWITCH", KILL_SWITCH_MESSAGE));
}
Long capMicros = ProviderService.capMicros(active.row());
@@ -1165,11 +1191,33 @@ public class AssistantApi
{
if(!config.isEffectivelyEnabled())
{
throw (new AssistantApiException(503, "DISABLED", "The assistant is turned off for this application."));
throw (new AssistantApiException(503, "DISABLED", DISABLED_MESSAGE));
}
if(!hasAccess())
{
throw (new AssistantApiException(403, "NO_PERMISSION", "You do not have access to the assistant."));
if(activeProviderOrNull() == null)
{
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
}
throw (new AssistantApiException(403, "NO_PERMISSION", NO_PERMISSION_MESSAGE));
}
}
/*******************************************************************************
** The active provider, or null when none is usable or it cannot be read
** (only used to pick the clearer refusal).
*******************************************************************************/
private ProviderService.Active activeProviderOrNull()
{
try
{
return (activeProvider());
}
catch(QException | RuntimeException e)
{
return (null);
}
}
@@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.6.0";
public static final String EXTENSION_VERSION = "1.7.0";
public static final String PREFIX = "/assistant";
@@ -198,7 +198,8 @@ public final class AssistantSpecs
"starterPrompts", array(string(null), "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request.")));
rs.put("AssistantConfigResponseV1", object("Whether the assistant is available to this user, and how.",
"enabled", bool("Whether the panel may be used."),
"reason", string("Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH.").withNullable(true),
"reason", string("Why not: DISABLED, NOT_CONFIGURED (no usable model provider on the server), NO_PERMISSION or KILL_SWITCH, checked in that order.").withNullable(true),
"message", string("Since 1.7.0: when reason is set, a sentence for the user that names the cause (for NOT_CONFIGURED, what an administrator configures).").withNullable(true),
"shortcut", string("Keyboard shortcut, e.g. mod+j."),
"model", string("The model that answers."),
"limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn. When features.attachments is true also maxAttachmentBytes (the largest file read, in bytes), "
@@ -175,7 +175,13 @@ components:
description: "Whether the panel may be used."
type: "boolean"
reason:
description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH."
description: "Why not: DISABLED, NOT_CONFIGURED (no usable model provider\
\ on the server), NO_PERMISSION or KILL_SWITCH, checked in that order."
nullable: true
type: "string"
message:
description: "Since 1.7.0: when reason is set, a sentence for the user that\
\ names the cause (for NOT_CONFIGURED, what an administrator configures)."
nullable: true
type: "string"
shortcut:
@@ -305,7 +311,9 @@ components:
nullable: true
type: "string"
starterPrompts:
description: "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request."
description: "Up to three suggested first questions in total: permitted\
\ configured questions first, then generated questions. Table, row and\
\ field access are checked on every request."
items:
type: "string"
type: "array"
@@ -468,7 +476,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.6.0"
version: "1.7.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -166,6 +166,45 @@ class AssistantRouteProviderTest extends BaseAssistantTest
/*******************************************************************************
** No usable model provider (QRun-IO/qbit-assistant#82): the probe names the
** server's configuration, for users with and without access, and calls
** answer 503 NOT_CONFIGURED instead of 403 NO_PERMISSION.
*******************************************************************************/
@Test
void testNoModelProviderIsNotAPermissionProblem() throws Exception
{
String providerName = config.getProviderName();
config.setProviderName("no-such-provider");
try
{
for(String user : new String[] {EDITOR, NO_ACCESS})
{
Map<String, Object> probe = json(send("GET", "/config", user, null));
assertThat(probe).containsEntry("enabled", false).containsEntry("reason", "NOT_CONFIGURED");
assertThat((String) probe.get("message")).contains("No model provider").contains("Assistant > Providers").contains("ANTHROPIC_API_KEY");
}
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(503);
assertThat(json(denied)).containsEntry("code", "NOT_CONFIGURED");
assertThat((String) json(denied).get("message")).contains("No model provider");
assertThat(send("GET", "/conversations", EDITOR, null).statusCode()).isEqualTo(200);
HttpResponse<String> turn = send("POST", "/conversations", EDITOR, "{}");
Object id = json(turn).get("id");
HttpResponse<String> ask = send("POST", "/conversations/" + id + "/turns", EDITOR, "{\"text\":\"hi\"}");
assertThat(ask.statusCode()).isEqualTo(503);
assertThat(json(ask)).containsEntry("code", "NOT_CONFIGURED");
}
finally
{
config.setProviderName(providerName);
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -178,7 +217,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest
assertThat((Map<String, Object>) config.get("limits")).containsEntry("maxTurnSeconds", 300).containsEntry("maxToolCallsPerTurn", 25);
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION")
.containsEntry("message", "You do not have access to the assistant.");
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(403);
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")