From de215f8ff916b9608f4f746fc0eeb55d2e7eab84 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:49:45 -0500 Subject: [PATCH] refactor: qbit-assistant-provider-api module for the provider SPI and pricing (#30) Architecture review QRun-IO/qqq#1150 F5: qbit-marketing compiled against assistant internals. - New artifact qbit-assistant-provider-api with the provider SPI and value types (same package names) and engine.ModelPricing; production dependency jackson-annotations only. An ArchUnit test keeps it free of assistant internals. - AssistantProviderType's settings validation takes ProviderSettings instead of the assistant_provider entity (documented in UPGRADE.md). - qbit-assistant and the provider modules depend on the API artifact. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 +- CHANGELOG.md | 6 + CLAUDE.md | 8 +- README.md | 20 +- UPGRADE.md | 20 ++ pom.xml | 6 + qbit-assistant-provider-anthropic/pom.xml | 5 + qbit-assistant-provider-api/pom.xml | 116 ++++++++++ .../qbits/assistant/engine/ModelPricing.java | 7 +- .../provider/AssistantModelProvider.java | 0 .../provider/AssistantProviderType.java | 5 +- .../qbits/assistant/provider/CacheHint.java | 0 .../provider/CompletableStreamHandle.java | 0 .../assistant/provider/DiscoveredModel.java | 0 .../assistant/provider/EmbeddingRequest.java | 0 .../assistant/provider/EmbeddingResponse.java | 0 .../assistant/provider/HealthResult.java | 0 .../assistant/provider/ModelCapabilities.java | 0 .../provider/ModelEventListener.java | 0 .../assistant/provider/ModelMessage.java | 0 .../qbits/assistant/provider/ModelPart.java | 0 .../provider/ModelProviderException.java | 0 .../assistant/provider/ModelRequest.java | 0 .../assistant/provider/ModelResponse.java | 0 .../provider/ModelToolDefinition.java | 0 .../qbits/assistant/provider/ModelUsage.java | 0 .../assistant/provider/ProviderSettings.java | 32 +++ .../assistant/provider/StreamHandle.java | 0 .../qbits/assistant/provider/SystemBlock.java | 0 .../qbits/assistant/provider/TokenCount.java | 0 .../assistant/provider/TrustedCaBundle.java | 0 .../provider/ProviderApiArchitectureTest.java | 75 ++++++ .../assistant/provider/ProviderApiTest.java | 214 ++++++++++++++++++ .../provider/ProviderComponentsTest.java | 80 +++++++ .../provider/ProviderDefaultsTest.java | 164 ++++++++++++++ .../provider/TrustedCaBundleTest.java | 0 qbit-assistant-provider-bedrock/pom.xml | 5 + .../provider/bedrock/BedrockProviderType.java | 7 +- qbit-assistant-provider-openai/pom.xml | 5 + qbit-assistant-provider-vertex/pom.xml | 5 + .../provider/vertex/VertexProviderType.java | 9 +- qbit-assistant/pom.xml | 6 +- .../assistant/engine/ProviderService.java | 8 +- .../engine/EngineComponentsTest.java | 57 ----- 44 files changed, 787 insertions(+), 81 deletions(-) create mode 100644 qbit-assistant-provider-api/pom.xml rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java (97%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantModelProvider.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java (96%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/CacheHint.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/CompletableStreamHandle.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/DiscoveredModel.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingRequest.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingResponse.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/HealthResult.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelCapabilities.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelEventListener.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelMessage.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelPart.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelProviderException.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelRequest.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelResponse.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelToolDefinition.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ModelUsage.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java (92%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/StreamHandle.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/SystemBlock.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/TokenCount.java (100%) rename {qbit-assistant => qbit-assistant-provider-api}/src/main/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundle.java (100%) create mode 100644 qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiArchitectureTest.java create mode 100644 qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiTest.java create mode 100644 qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderComponentsTest.java create mode 100644 qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderDefaultsTest.java rename {qbit-assistant => qbit-assistant-provider-api}/src/test/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundleTest.java (100%) diff --git a/AGENTS.md b/AGENTS.md index 2618a5e..64fe76a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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}`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd94ca..d3b8c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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-f - Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING diff --git a/CLAUDE.md b/CLAUDE.md index 8945998..b521953 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -102,4 +104,4 @@ QQQ style: - `develop` publishes SNAPSHOTs. - `release/*` publishes RCs; the orb bumps `` 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}`. diff --git a/README.md b/README.md index ad5a707..cb42fac 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/UPGRADE.md b/UPGRADE.md index fd719a8..4f70d82 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,25 @@ # 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-f: knowledge retrieval Apply the **P-f** assistant-table DDL in diff --git a/pom.xml b/pom.xml index 173f755..3ec5731 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,7 @@ 4.0.0 + qbit-assistant-provider-api qbit-assistant qbit-assistant-provider-anthropic qbit-assistant-provider-openai @@ -85,6 +86,11 @@ + + com.kingsrook.qbits + qbit-assistant-provider-api + ${revision} + com.kingsrook.qbits qbit-assistant-provider-anthropic diff --git a/qbit-assistant-provider-anthropic/pom.xml b/qbit-assistant-provider-anthropic/pom.xml index 39fa50e..92c6d97 100644 --- a/qbit-assistant-provider-anthropic/pom.xml +++ b/qbit-assistant-provider-anthropic/pom.xml @@ -32,9 +32,14 @@ https://github.com/QRun-IO/qbit-assistant + + com.kingsrook.qbits + qbit-assistant-provider-api + com.kingsrook.qbits qbit-assistant + test com.kingsrook.qqq diff --git a/qbit-assistant-provider-api/pom.xml b/qbit-assistant-provider-api/pom.xml new file mode 100644 index 0000000..b77552b --- /dev/null +++ b/qbit-assistant-provider-api/pom.xml @@ -0,0 +1,116 @@ + + + + + 4.0.0 + + + com.kingsrook.qbits + qbit-assistant-parent + ${revision} + + + qbit-assistant-provider-api + jar + + QBit Assistant - Provider API + Provider SPI and pricing value types, independent of the assistant runtime + https://github.com/QRun-IO/qbit-assistant + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + com.tngtech.archunit + archunit-junit5 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.70 + + + CLASS + COVEREDRATIO + 0.90 + + + + + + + + + + + diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java similarity index 97% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java index 3168d77..af917cc 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java +++ b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/engine/ModelPricing.java @@ -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 defaults() { Map 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)); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantModelProvider.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantModelProvider.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantModelProvider.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantModelProvider.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java similarity index 96% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java index 9133bb3..8e842cb 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java +++ b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/AssistantProviderType.java @@ -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 validate(AssistantProvider row) + default List validate(ProviderSettings settings) { return (List.of()); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/CacheHint.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/CacheHint.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/CacheHint.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/CacheHint.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/CompletableStreamHandle.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/CompletableStreamHandle.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/CompletableStreamHandle.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/CompletableStreamHandle.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/DiscoveredModel.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/DiscoveredModel.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/DiscoveredModel.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/DiscoveredModel.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingRequest.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingRequest.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingRequest.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingRequest.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingResponse.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingResponse.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingResponse.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/EmbeddingResponse.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/HealthResult.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/HealthResult.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/HealthResult.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/HealthResult.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelCapabilities.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelCapabilities.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelCapabilities.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelCapabilities.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelEventListener.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelEventListener.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelEventListener.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelEventListener.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelMessage.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelMessage.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelMessage.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelMessage.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelPart.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelPart.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelPart.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelPart.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelProviderException.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelProviderException.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelProviderException.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelProviderException.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelRequest.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelRequest.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelRequest.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelRequest.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelResponse.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelResponse.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelResponse.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelResponse.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelToolDefinition.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelToolDefinition.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelToolDefinition.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelToolDefinition.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelUsage.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelUsage.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ModelUsage.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ModelUsage.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java similarity index 92% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java index e980c11..b94930b 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java +++ b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/ProviderSettings.java @@ -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); + } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/StreamHandle.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/StreamHandle.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/StreamHandle.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/StreamHandle.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/SystemBlock.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/SystemBlock.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/SystemBlock.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/SystemBlock.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/TokenCount.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/TokenCount.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/TokenCount.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/TokenCount.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundle.java b/qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundle.java similarity index 100% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundle.java rename to qbit-assistant-provider-api/src/main/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundle.java diff --git a/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiArchitectureTest.java b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiArchitectureTest.java new file mode 100644 index 0000000..5a584c7 --- /dev/null +++ b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiArchitectureTest.java @@ -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 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 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); + } +} diff --git a/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiTest.java b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiTest.java new file mode 100644 index 0000000..e7e59b8 --- /dev/null +++ b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderApiTest.java @@ -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.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.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.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.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 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]"); + } +} diff --git a/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderComponentsTest.java b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderComponentsTest.java new file mode 100644 index 0000000..e89acf7 --- /dev/null +++ b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderComponentsTest.java @@ -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(); + } +} diff --git a/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderDefaultsTest.java b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderDefaultsTest.java new file mode 100644 index 0000000..9f9342b --- /dev/null +++ b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/ProviderDefaultsTest.java @@ -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 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()); + } + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundleTest.java b/qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundleTest.java similarity index 100% rename from qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundleTest.java rename to qbit-assistant-provider-api/src/test/java/com/kingsrook/qbits/assistant/provider/TrustedCaBundleTest.java diff --git a/qbit-assistant-provider-bedrock/pom.xml b/qbit-assistant-provider-bedrock/pom.xml index 95eca0d..856b74c 100644 --- a/qbit-assistant-provider-bedrock/pom.xml +++ b/qbit-assistant-provider-bedrock/pom.xml @@ -41,9 +41,14 @@ qbit-assistant-provider-anthropic + + com.kingsrook.qbits + qbit-assistant-provider-api + com.kingsrook.qbits qbit-assistant + test com.kingsrook.qqq diff --git a/qbit-assistant-provider-bedrock/src/main/java/com/kingsrook/qbits/assistant/provider/bedrock/BedrockProviderType.java b/qbit-assistant-provider-bedrock/src/main/java/com/kingsrook/qbits/assistant/provider/bedrock/BedrockProviderType.java index b9c96c9..3d0421d 100644 --- a/qbit-assistant-provider-bedrock/src/main/java/com/kingsrook/qbits/assistant/provider/bedrock/BedrockProviderType.java +++ b/qbit-assistant-provider-bedrock/src/main/java/com/kingsrook/qbits/assistant/provider/bedrock/BedrockProviderType.java @@ -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 validate(AssistantProvider row) + public List validate(ProviderSettings settings) { List 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)."); } diff --git a/qbit-assistant-provider-openai/pom.xml b/qbit-assistant-provider-openai/pom.xml index b99bee1..af5b23a 100644 --- a/qbit-assistant-provider-openai/pom.xml +++ b/qbit-assistant-provider-openai/pom.xml @@ -32,9 +32,14 @@ https://github.com/QRun-IO/qbit-assistant + + com.kingsrook.qbits + qbit-assistant-provider-api + com.kingsrook.qbits qbit-assistant + test com.kingsrook.qqq diff --git a/qbit-assistant-provider-vertex/pom.xml b/qbit-assistant-provider-vertex/pom.xml index bde85bf..fdd8948 100644 --- a/qbit-assistant-provider-vertex/pom.xml +++ b/qbit-assistant-provider-vertex/pom.xml @@ -45,9 +45,14 @@ qbit-assistant-provider-openai + + com.kingsrook.qbits + qbit-assistant-provider-api + com.kingsrook.qbits qbit-assistant + test com.kingsrook.qqq diff --git a/qbit-assistant-provider-vertex/src/main/java/com/kingsrook/qbits/assistant/provider/vertex/VertexProviderType.java b/qbit-assistant-provider-vertex/src/main/java/com/kingsrook/qbits/assistant/provider/vertex/VertexProviderType.java index 27f0a34..d60fb52 100644 --- a/qbit-assistant-provider-vertex/src/main/java/com/kingsrook/qbits/assistant/provider/vertex/VertexProviderType.java +++ b/qbit-assistant-provider-vertex/src/main/java/com/kingsrook/qbits/assistant/provider/vertex/VertexProviderType.java @@ -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 validate(AssistantProvider row) + public List validate(ProviderSettings settings) { List 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."); } diff --git a/qbit-assistant/pom.xml b/qbit-assistant/pom.xml index a8a4acd..1da8c2e 100644 --- a/qbit-assistant/pom.xml +++ b/qbit-assistant/pom.xml @@ -28,10 +28,14 @@ jar QBit Assistant - In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI + In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry https://github.com/QRun-IO/qbit-assistant + + com.kingsrook.qbits + qbit-assistant-provider-api + com.kingsrook.qqq qqq-backend-core diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java index 1badf42..fdcfe1b 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java @@ -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) diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/EngineComponentsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/EngineComponentsTest.java index baf49e5..854fd90 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/EngineComponentsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/EngineComponentsTest.java @@ -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(); - } - - - /******************************************************************************* ** *******************************************************************************/ -- 2.54.0