refactor: qbit-assistant-provider-api module for the provider SPI and pricing (#30) #37
No files matched your search
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,6 +48,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: JDBC vector storage; depends on the runtime RAG interfaces (`VectorStore`, `ChunkVector`).
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Sessions:**
|
||||
@@ -93,4 +97,4 @@ QQQ style:
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
|
||||
- `v*` tags publish releases.
|
||||
- All five modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Provider API extraction (#30)
|
||||
|
||||
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
|
||||
- Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.
|
||||
- Provider-specific validation now takes unresolved `ProviderSettings` instead of `AssistantProvider`; add an ArchUnit boundary and standalone coverage gates.
|
||||
## Unreleased - P-g
|
||||
|
||||
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
|
||||
|
||||
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,7 +48,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; no QQQ RDBMS internals.
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (`VectorStore`, `ChunkVector`), with no QQQ RDBMS internals.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
@@ -105,4 +107,4 @@ QQQ style:
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
|
||||
- `v*` tags publish releases.
|
||||
- All six modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -4,10 +4,28 @@ An in-app AI assistant for any QQQ application. The user asks a question in a ch
|
||||
|
||||
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
|
||||
|
||||
## Modules
|
||||
|
||||
| Artifact | What it is |
|
||||
|---|---|
|
||||
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
|
||||
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
|
||||
|
||||
### Implementing a provider outside qbit-assistant
|
||||
|
||||
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
|
||||
QBit Assistant, and implement `AssistantModelProvider` and optionally
|
||||
`AssistantProviderType`. No assistant runtime dependency is needed. Existing
|
||||
`com.kingsrook.qbits.assistant.provider.*` imports and
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
|
||||
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
|
||||
(including `apiKeyEnvName`), without a database entity or resolved credentials.
|
||||
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
|
||||
|
||||
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
|
||||
|
||||
|
||||
+19
@@ -1,5 +1,24 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Provider API extraction (#30)
|
||||
|
||||
Hosts that depend on `qbit-assistant` keep working: it now brings in
|
||||
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
|
||||
|
||||
External providers and applications using only provider/pricing types should
|
||||
replace their `qbit-assistant` dependency with
|
||||
`com.kingsrook.qbits:qbit-assistant-provider-api` at the same version. This removes
|
||||
the assistant runtime and QQQ from that dependency path; only Jackson annotations
|
||||
are required by the API. Anthropic, OpenAI, Bedrock and Vertex adapters use this
|
||||
split; pgvector retains `qbit-assistant` for its runtime RAG interfaces.
|
||||
|
||||
Recompile custom provider types that override `validate(AssistantProvider)` and
|
||||
change the signature to `validate(ProviderSettings)`. The settings carry the
|
||||
configured kind, URL, API version, region, project, CA bundle name, timeouts and
|
||||
`apiKeyEnvName`; the key and bearer-token supplier are not resolved for validation.
|
||||
Providers using the default validator need no source change. Keep the runtime and
|
||||
provider artifacts on the same version; old validator overrides must be rebuilt.
|
||||
## P-g: scheduled tasks
|
||||
|
||||
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<modules>
|
||||
<module>qbit-assistant-provider-api</module>
|
||||
<module>qbit-assistant</module>
|
||||
<module>qbit-assistant-provider-anthropic</module>
|
||||
<module>qbit-assistant-provider-openai</module>
|
||||
@@ -85,6 +86,11 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant - Provider API</name>
|
||||
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+3
-4
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
|
||||
|
||||
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
|
||||
public static Map<String, ModelPricing> defaults()
|
||||
{
|
||||
Map<String, ModelPricing> map = new LinkedHashMap<>();
|
||||
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
|
||||
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
|
||||
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
|
||||
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
|
||||
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
|
||||
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
|
||||
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
|
||||
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
|
||||
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
|
||||
File renamed without changes.
+3
-2
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -140,8 +139,10 @@ public interface AssistantProviderType
|
||||
|
||||
/*******************************************************************************
|
||||
** Additional settings errors, without resolving credentials or doing I/O.
|
||||
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
|
||||
** token supplier and SSLContext are not resolved for validation.
|
||||
*******************************************************************************/
|
||||
default List<String> validate(AssistantProvider row)
|
||||
default List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+32
@@ -36,6 +36,7 @@ public class ProviderSettings
|
||||
private String tlsCaBundle;
|
||||
@JsonIgnore
|
||||
private transient SSLContext sslContext;
|
||||
private String apiKeyEnvName;
|
||||
private String kind;
|
||||
private String apiKey;
|
||||
private String baseUrl;
|
||||
@@ -397,4 +398,35 @@ public class ProviderSettings
|
||||
this.bearerTokenSupplier = bearerTokenSupplier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured environment variable name, never its secret value.
|
||||
*******************************************************************************/
|
||||
public String getApiKeyEnvName()
|
||||
{
|
||||
return (this.apiKeyEnvName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public void setApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.tngtech.archunit.base.DescribedPredicate;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep external provider consumers independent of the assistant runtime and QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiArchitectureTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Allow only the extracted types, including their nested enums, under the
|
||||
** assistant namespace. A future dependency on any runtime package must fail.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void dependsOnlyOnApiTypes()
|
||||
{
|
||||
Set<String> apiTypes = Set.of(
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
|
||||
"com.kingsrook.qbits.assistant.provider.CacheHint",
|
||||
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.HealthResult",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelMessage",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelPart",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelUsage",
|
||||
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
|
||||
"com.kingsrook.qbits.assistant.provider.StreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.SystemBlock",
|
||||
"com.kingsrook.qbits.assistant.provider.TokenCount",
|
||||
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
|
||||
"com.kingsrook.qbits.assistant.engine.ModelPricing");
|
||||
JavaClasses imported = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages("com.kingsrook.qbits.assistant");
|
||||
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
|
||||
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
|
||||
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
|
||||
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider contracts work on a classpath with no assistant runtime or QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiTest
|
||||
{
|
||||
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve provider-neutral requests and tool order through JSON; signed
|
||||
** provider state remains in memory only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestRoundTripAndCopy() throws Exception
|
||||
{
|
||||
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
|
||||
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
|
||||
.withVision(false).withStreaming(true).withReasoningEffort(true);
|
||||
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
|
||||
.withInputSchema(Map.of("type", "object")).withStrict(true);
|
||||
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
|
||||
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
|
||||
.withProviderState("private-signature");
|
||||
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
|
||||
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
|
||||
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
|
||||
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
|
||||
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
|
||||
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
|
||||
String json = mapper.writeValueAsString(request);
|
||||
assertThat(json).doesNotContain("private-signature", "providerState");
|
||||
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
|
||||
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
|
||||
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
|
||||
ModelRequest copy = request.copy();
|
||||
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
|
||||
copy.getMessages().clear();
|
||||
assertThat(request.getMessages()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response helpers retain tool ordering, text, usage and safe metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void responseAndPricingRoundTrip() throws Exception
|
||||
{
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
|
||||
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
|
||||
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
|
||||
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
|
||||
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
|
||||
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
|
||||
.withUsage(usage).withProviderState("signature");
|
||||
assertThat(response.getText()).isEqualTo("ab");
|
||||
assertThat(response.getToolUses()).containsExactly(first, second);
|
||||
String json = mapper.writeValueAsString(response);
|
||||
assertThat(json).doesNotContain("signature", "providerState");
|
||||
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
|
||||
assertThat(restored.getProviderState()).isNull();
|
||||
assertThat(response.getProviderState()).isEqualTo("signature");
|
||||
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
|
||||
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
|
||||
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
|
||||
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
|
||||
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
|
||||
assertThat(tokens.getInputTokens()).isEqualTo(12L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain validation settings require no credentials or entity classes, and
|
||||
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsRoundTrip() throws Exception
|
||||
{
|
||||
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
|
||||
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
|
||||
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
|
||||
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
|
||||
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
|
||||
String json = mapper.writeValueAsString(settings);
|
||||
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
|
||||
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
|
||||
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
|
||||
assertThat(settings.getSslContext()).isNotNull();
|
||||
assertThat(restored.getBearerTokenSupplier()).isNull();
|
||||
assertThat(restored.getSslContext()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding batches snapshot input and enforce the transport size contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void embeddingContracts()
|
||||
{
|
||||
List<String> input = new ArrayList<>(List.of("first", "second"));
|
||||
EmbeddingRequest request = new EmbeddingRequest("embed", input);
|
||||
input.clear();
|
||||
assertThat(request.input()).containsExactly("first", "second");
|
||||
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
|
||||
float[] vector = new float[] { 0.25F, 0.75F };
|
||||
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
|
||||
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
|
||||
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
|
||||
assertThat(response.requestId()).isEqualTo("request");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancellation is idempotent, preserves accrued usage, and wins over late
|
||||
** completion. Timeout also cancels the underlying transport.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cancellationAndTimeout() throws Exception
|
||||
{
|
||||
AtomicInteger cancellations = new AtomicInteger();
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(10L);
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.setPartialUsage(() -> usage);
|
||||
handle.setOnCancel(cancellations::incrementAndGet);
|
||||
handle.cancel();
|
||||
handle.cancel();
|
||||
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
|
||||
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
|
||||
assertThat(handle.isCancelled()).isTrue();
|
||||
assertThat(cancellations.get()).isEqualTo(1);
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
|
||||
CompletableStreamHandle timeout = new CompletableStreamHandle();
|
||||
timeout.setPartialUsage(() -> usage);
|
||||
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
|
||||
{
|
||||
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
|
||||
assertThat(error.getPartialUsage()).isSameAs(usage);
|
||||
});
|
||||
assertThat(timeout.isCancelled()).isTrue();
|
||||
handle.setPartialUsage(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closed"));
|
||||
});
|
||||
assertThat(handle.partialUsage()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error metadata is bounded and redacted independently of error classification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorDetails()
|
||||
{
|
||||
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
|
||||
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
|
||||
assertThat(error.isStrictSchemaRejected()).isTrue();
|
||||
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
|
||||
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
|
||||
assertThat(error.getRequestId()).isEqualTo("req-123");
|
||||
assertThat(error.isRetryable()).isFalse();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standalone contracts moved from the runtime's component tests.
|
||||
*******************************************************************************/
|
||||
class ProviderComponentsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPricing()
|
||||
{
|
||||
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
|
||||
assertThat(sonnet.costMicros(null)).isZero();
|
||||
assertThat(new ModelPricing().costMicros(usage)).isZero();
|
||||
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
|
||||
assertThat(usage.billableTokens()).isEqualTo(7800);
|
||||
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStreamHandle() throws Exception
|
||||
{
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.complete(new ModelResponse().withStopReason("end_turn"));
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
|
||||
|
||||
CompletableStreamHandle failing = new CompletableStreamHandle();
|
||||
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
|
||||
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.isRetryable()).isTrue();
|
||||
assertThat(e.getHttpStatus()).isEqualTo(429);
|
||||
});
|
||||
|
||||
CompletableStreamHandle cancelled = new CompletableStreamHandle();
|
||||
cancelled.setOnCancel(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closing"));
|
||||
});
|
||||
cancelled.cancel();
|
||||
cancelled.cancel();
|
||||
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
|
||||
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A minimal external provider compiles and uses SPI defaults without the runtime.
|
||||
*******************************************************************************/
|
||||
class ProviderDefaultsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Legacy two-argument factories and static model lists remain supported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void minimalProviderDefaults() throws Exception
|
||||
{
|
||||
ExampleProvider provider = new ExampleProvider();
|
||||
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
|
||||
assertThat(provider.create(settings)).isSameAs(provider);
|
||||
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
|
||||
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
|
||||
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
|
||||
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
|
||||
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
|
||||
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
|
||||
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
|
||||
assertThat(provider.validate(settings)).isEmpty();
|
||||
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
|
||||
assertThat(provider.checkHealth(settings).healthy()).isNull();
|
||||
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
|
||||
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
|
||||
assertThat(provider.countTokens(new ModelRequest())).isNull();
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
|
||||
StringBuilder deltas = new StringBuilder();
|
||||
ModelEventListener listener = deltas::append;
|
||||
listener.onToolUseStart("call", "lookup");
|
||||
assertThat(deltas).isEmpty();
|
||||
listener.onTextDelta("visible answer");
|
||||
assertThat(deltas).hasToString("visible answer");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the original required SPI methods are implemented.
|
||||
*******************************************************************************/
|
||||
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
|
||||
{
|
||||
private ProviderSettings settings;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("example-provider");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("example"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return ("EXAMPLE_KEY");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ModelCapabilities getCapabilities(String modelId)
|
||||
{
|
||||
return (new ModelCapabilities());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
throw (new UnsupportedOperationException());
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -41,9 +41,14 @@
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+3
-4
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
|
||||
import com.anthropic.bedrock.backends.BedrockBackend;
|
||||
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
|
||||
import com.anthropic.core.Timeout;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for BEDROCK.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
|
||||
}
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
@@ -45,9 +45,14 @@
|
||||
<artifactId>qbit-assistant-provider-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+4
-5
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for VERTEX.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
|
||||
}
|
||||
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
|
||||
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
|
||||
{
|
||||
errors.add("GCP project id (cloudProject) is required for VERTEX.");
|
||||
}
|
||||
|
||||
@@ -28,10 +28,14 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant</name>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI</description>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
|
||||
+6
-2
@@ -258,7 +258,11 @@ public class ProviderService
|
||||
return (errors);
|
||||
}
|
||||
|
||||
errors.addAll(type.validate(row));
|
||||
errors.addAll(type.validate(new ProviderSettings().withKind(kind(row))
|
||||
.withApiKeyEnvName(row.getApiKeyEnvName()).withBaseUrl(row.getBaseUrl())
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withConnectTimeoutSeconds(row.getConnectTimeoutSeconds())
|
||||
.withRequestTimeoutSeconds(row.getRequestTimeoutSeconds())));
|
||||
String envName = keyEnvName(row);
|
||||
if(envName != null && !AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
|
||||
{
|
||||
@@ -678,7 +682,7 @@ public class ProviderService
|
||||
{
|
||||
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
|
||||
}
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKey(key)
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKeyEnvName(row.getApiKeyEnvName()).withApiKey(key)
|
||||
.withBaseUrl(StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null)
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withSslContext(StringUtils.hasContent(row.getTlsCaBundle()) ? config.getTrustedCaBundles().get(row.getTlsCaBundle()) : null)
|
||||
|
||||
-57
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
@@ -27,16 +26,12 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -203,27 +198,6 @@ class EngineComponentsTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPricing()
|
||||
{
|
||||
ModelPricing sonnet = ModelPricing.defaults().get(AssistantModelRouting.SONNET_5_5);
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
|
||||
assertThat(sonnet.costMicros(null)).isZero();
|
||||
assertThat(new ModelPricing().costMicros(usage)).isZero();
|
||||
assertThat(ModelPricing.defaults().get(AssistantModelRouting.OPUS_5_5).getCacheReadUsdPerMTok()).isEqualTo(0.2);
|
||||
assertThat(usage.billableTokens()).isEqualTo(7800);
|
||||
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -268,37 +242,6 @@ class EngineComponentsTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStreamHandle() throws Exception
|
||||
{
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.complete(new ModelResponse().withStopReason("end_turn"));
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
|
||||
|
||||
CompletableStreamHandle failing = new CompletableStreamHandle();
|
||||
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
|
||||
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.isRetryable()).isTrue();
|
||||
assertThat(e.getHttpStatus()).isEqualTo(429);
|
||||
});
|
||||
|
||||
CompletableStreamHandle cancelled = new CompletableStreamHandle();
|
||||
cancelled.setOnCancel(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closing"));
|
||||
});
|
||||
cancelled.cancel();
|
||||
cancelled.cancel();
|
||||
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
|
||||
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user