@revealui/ai 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/embeddings/index.d.ts +8 -0
- package/dist/embeddings/index.d.ts.map +1 -1
- package/dist/embeddings/index.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/llm/client.d.ts +28 -8
- package/dist/llm/client.d.ts.map +1 -1
- package/dist/llm/client.js +92 -13
- package/dist/llm/providers/anthropic.d.ts +30 -0
- package/dist/llm/providers/anthropic.d.ts.map +1 -0
- package/dist/llm/providers/anthropic.js +45 -0
- package/dist/llm/providers/openai-compat.d.ts +16 -0
- package/dist/llm/providers/openai-compat.d.ts.map +1 -1
- package/dist/llm/providers/openai-compat.js +35 -3
- package/dist/llm/providers/openai.d.ts +27 -0
- package/dist/llm/providers/openai.d.ts.map +1 -0
- package/dist/llm/providers/openai.js +41 -0
- package/dist/llm/resolve.d.ts +97 -0
- package/dist/llm/resolve.d.ts.map +1 -0
- package/dist/llm/resolve.js +218 -0
- package/dist/llm/server.d.ts +1 -0
- package/dist/llm/server.d.ts.map +1 -1
- package/dist/llm/server.js +2 -0
- package/dist/skills/types.d.ts +6 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ AI system for RevealUI - memory, LLM, orchestration, and tools.
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
18
|
- **Memory System**: CRDT-based persistent memory (Working, Episodic, Semantic)
|
|
19
|
-
- **LLM Integration**: Provider abstractions for
|
|
19
|
+
- **LLM Integration**: Provider abstractions for Ollama and Canonical Inference Snaps (open-model defaults), plus Groq, Anthropic, OpenAI, and HuggingFace as bring-your-own-key cloud adapters. Every adapter calls the vendor's OpenAI-compatible HTTP endpoint with your own key. No proprietary Anthropic or OpenAI SDK.
|
|
20
20
|
- **Agent Orchestration**: Runtime and execution engine for AI agents
|
|
21
21
|
- **Tool Calling**: Tool registry + standard-MCP-client integration (Stage 5.1a)
|
|
22
22
|
- **Vector Search**: Semantic search with pgvector
|
|
@@ -64,8 +64,9 @@ const provider = new InferenceSnapsProvider({
|
|
|
64
64
|
const client = new LLMClient({ provider })
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
Groq, Anthropic, OpenAI, and HuggingFace remain supported as pluggable,
|
|
68
|
+
bring-your-own-key cloud providers. The local inference path is the
|
|
69
|
+
documented default for self-hosted deployments.
|
|
69
70
|
|
|
70
71
|
## MCP tool integration
|
|
71
72
|
|
|
@@ -313,7 +314,7 @@ const state = await persistence.loadCRDTState(crdtId, 'lww_register')
|
|
|
313
314
|
|
|
314
315
|
### LLM Integration
|
|
315
316
|
|
|
316
|
-
Provider abstractions and unified client for Anthropic,
|
|
317
|
+
Provider abstractions and unified client for Ollama, Canonical Inference Snaps, Groq, Anthropic, OpenAI, and HuggingFace.
|
|
317
318
|
|
|
318
319
|
```typescript
|
|
319
320
|
import { LLMClient, createLLMClientFromEnv } from '@revealui/ai/llm/client'
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Note: Groq and Anthropic do not support embeddings - use Ollama for embedding tasks.
|
|
8
8
|
*/
|
|
9
9
|
import z from 'zod/v4';
|
|
10
|
+
import { type LLMClient } from '../llm/client.js';
|
|
10
11
|
declare const EmbeddingSchema: z.ZodObject<{
|
|
11
12
|
model: z.ZodString;
|
|
12
13
|
vector: z.ZodArray<z.ZodNumber>;
|
|
@@ -23,6 +24,13 @@ export interface GenerateEmbeddingOptions {
|
|
|
23
24
|
*/
|
|
24
25
|
model?: string;
|
|
25
26
|
cache?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Pre-resolved LLM client (GAP-360). When supplied — by an authenticated
|
|
29
|
+
* caller that ran `resolveLLMClientForRequest` — embeddings use it (per-user
|
|
30
|
+
* BYOK / hosted resolution). When omitted, embeddings fall back to the
|
|
31
|
+
* env-configured client, preserving self-hosted and internal-caller behavior.
|
|
32
|
+
*/
|
|
33
|
+
client?: Pick<LLMClient, 'embed'>;
|
|
26
34
|
}
|
|
27
35
|
/**
|
|
28
36
|
* Generate an embedding for the given text using the configured LLM provider.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/embeddings/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,CAAC,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/embeddings/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,CAAC,MAAM,QAAQ,CAAC;AACvB,OAAO,EAA0B,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE1E,QAAA,MAAM,eAAe;;;;;iBAgBjB,CAAC;AACL,KAAK,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAEjD,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,SAAS,CAAC,CA6BpB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,SAAS,EAAE,CAAC,CAMtB"}
|
package/dist/embeddings/index.js
CHANGED
|
@@ -41,8 +41,9 @@ export async function generateEmbedding(text, options = {}) {
|
|
|
41
41
|
if (!text || typeof text !== 'string' || text.trim().length === 0) {
|
|
42
42
|
throw new Error('Text must be a non-empty string');
|
|
43
43
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
44
|
+
// Prefer the caller-resolved client (BYOK / hosted). Fall back to the
|
|
45
|
+
// env-configured client for self-hosted and internal (no-user) callers.
|
|
46
|
+
const client = options.client ?? createLLMClientFromEnv();
|
|
46
47
|
// Ask client to embed - each provider uses its own default model when model is undefined
|
|
47
48
|
const result = await client.embed(text, model ? { model } : undefined);
|
|
48
49
|
// If provider returned batch, pick first
|
package/dist/index.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export * from './llm/provider-health.js';
|
|
|
54
54
|
export * from './llm/providers/base.js';
|
|
55
55
|
export * from './llm/providers/inference-snaps.js';
|
|
56
56
|
export * from './llm/providers/openai-compat.js';
|
|
57
|
+
export * from './llm/resolve.js';
|
|
57
58
|
export * from './llm/token-counter.js';
|
|
58
59
|
export * from './llm/workspace-provider-config.js';
|
|
59
60
|
export * from './memory/index.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAGH,cAAc,gBAAgB,CAAC;AAE/B,cAAc,kBAAkB,CAAC;AAIjC,cAAc,uBAAuB,CAAC;AAEtC,cAAc,sBAAsB,CAAC;AAErC,cAAc,iBAAiB,CAAC;AAChC,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oCAAoC,CAAC;AACnD,cAAc,kCAAkC,CAAC;AACjD,cAAc,wBAAwB,CAAC;AACvC,cAAc,oCAAoC,CAAC;AAEnD,cAAc,mBAAmB,CAAC;AAElC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sCAAsC,CAAC;AACrD,cAAc,iCAAiC,CAAC;AAEhD,cAAc,mBAAmB,CAAC;AAElC,cAAc,sBAAsB,CAAC;AAErC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAGH,cAAc,gBAAgB,CAAC;AAE/B,cAAc,kBAAkB,CAAC;AAIjC,cAAc,uBAAuB,CAAC;AAEtC,cAAc,sBAAsB,CAAC;AAErC,cAAc,iBAAiB,CAAC;AAChC,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oCAAoC,CAAC;AACnD,cAAc,kCAAkC,CAAC;AACjD,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,oCAAoC,CAAC;AAEnD,cAAc,mBAAmB,CAAC;AAElC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sCAAsC,CAAC;AACrD,cAAc,iCAAiC,CAAC;AAEhD,cAAc,mBAAmB,CAAC;AAElC,cAAc,sBAAsB,CAAC;AAErC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,7 @@ export * from './llm/provider-health.js';
|
|
|
61
61
|
export * from './llm/providers/base.js';
|
|
62
62
|
export * from './llm/providers/inference-snaps.js';
|
|
63
63
|
export * from './llm/providers/openai-compat.js';
|
|
64
|
+
export * from './llm/resolve.js';
|
|
64
65
|
export * from './llm/token-counter.js';
|
|
65
66
|
export * from './llm/workspace-provider-config.js';
|
|
66
67
|
// Re-export memory system
|
package/dist/llm/client.d.ts
CHANGED
|
@@ -10,7 +10,15 @@ import type { ProviderHealthMonitor } from './provider-health.js';
|
|
|
10
10
|
import type { Embedding, LLMChatOptions, LLMChunk, LLMEmbedOptions, LLMProvider, LLMResponse, LLMStreamOptions, Message } from './providers/base.js';
|
|
11
11
|
import { type CacheStats, type ResponseCacheOptions } from './response-cache.js';
|
|
12
12
|
import { type SemanticCacheOptions, type SemanticCacheStats } from './semantic-cache.js';
|
|
13
|
-
export type LLMProviderType = '
|
|
13
|
+
export type LLMProviderType = 'anthropic' | 'openai' | 'groq' | 'huggingface' | 'ollama' | 'inference-snaps';
|
|
14
|
+
/**
|
|
15
|
+
* Providers reachable from a hosted (serverless) deployment. Localhost-only
|
|
16
|
+
* providers are false. Consumed by PR-2/PR-3 (resolver + settings UI) to pick
|
|
17
|
+
* which providers a hosted account may configure; no runtime consumer yet.
|
|
18
|
+
*/
|
|
19
|
+
export declare const hostedViable: Record<LLMProviderType, boolean>;
|
|
20
|
+
/** True when the provider can serve a hosted (serverless) deployment. */
|
|
21
|
+
export declare function isHostedViable(provider: LLMProviderType): boolean;
|
|
14
22
|
export interface LLMClientConfig {
|
|
15
23
|
provider: LLMProviderType;
|
|
16
24
|
apiKey: string;
|
|
@@ -129,20 +137,24 @@ export declare class LLMClient {
|
|
|
129
137
|
* Create an LLM client from environment variables.
|
|
130
138
|
*
|
|
131
139
|
* When LLM_PROVIDER is not set, auto-detects the provider by checking env vars
|
|
132
|
-
* in priority order: INFERENCE_SNAPS → GROQ → OLLAMA
|
|
133
|
-
*
|
|
134
|
-
* one-line stderr warning so
|
|
140
|
+
* in priority order: INFERENCE_SNAPS → GROQ → OLLAMA → ANTHROPIC_API_KEY →
|
|
141
|
+
* OPENAI_API_KEY. If none are set, defaults to Inference Snaps at
|
|
142
|
+
* http://localhost:9090/v1 (Ubuntu local) and emits a one-line stderr warning so
|
|
143
|
+
* the implicit localhost default is discoverable in logs.
|
|
135
144
|
*
|
|
136
|
-
* All providers use OpenAI-compatible APIs. No proprietary provider SDKs
|
|
145
|
+
* All providers use OpenAI-compatible APIs. No proprietary provider SDKs
|
|
146
|
+
* (Anthropic + OpenAI ride their OpenAI-compatible endpoints).
|
|
137
147
|
*
|
|
138
148
|
* Canonical Inference Snaps is the reference local provider on Ubuntu — offline,
|
|
139
149
|
* silicon-optimized, no API key required. See `providers/inference-snaps.ts` for
|
|
140
150
|
* install docs (`sudo snap install gemma3`, etc.).
|
|
141
151
|
*
|
|
142
152
|
* Provider defaults:
|
|
143
|
-
* inference-snaps → gemma3
|
|
153
|
+
* inference-snaps → gemma3 (base URL defaults to http://localhost:9090/v1)
|
|
144
154
|
* groq → qwen/qwen3-32b
|
|
145
|
-
* ollama → gemma4:e2b
|
|
155
|
+
* ollama → gemma4:e2b (base URL defaults to http://localhost:11434)
|
|
156
|
+
* anthropic → claude-sonnet-4-6 (base URL defaults to https://api.anthropic.com/v1)
|
|
157
|
+
* openai → gpt-4o (base URL defaults to https://api.openai.com/v1)
|
|
146
158
|
*/
|
|
147
159
|
export declare function createLLMClientFromEnv(): LLMClient;
|
|
148
160
|
/**
|
|
@@ -155,8 +167,16 @@ export declare function createLLMClientFromEnv(): LLMClient;
|
|
|
155
167
|
* Returns `null` if the user has no stored keys (callers should fall back
|
|
156
168
|
* to `createLLMClientFromEnv()` or return a 402/feature-unavailable error).
|
|
157
169
|
*
|
|
170
|
+
* When `opts.hostedViableOnly` is set, a stored key whose provider is not
|
|
171
|
+
* hosted-viable (ollama / inference-snaps are localhost-only) resolves to
|
|
172
|
+
* `null` instead of a client. The resolver passes this on hosted deployments so
|
|
173
|
+
* a localhost-only BYOK key can never yield a localhost client — the exact
|
|
174
|
+
* silent-localhost defect GAP-360 closes (spec §6.5, fail-closed).
|
|
175
|
+
*
|
|
158
176
|
* @param userId - The user's ID from the `users` table
|
|
159
177
|
* @param db - A Drizzle NeonDB client instance
|
|
160
178
|
*/
|
|
161
|
-
export declare function createLLMClientForUser(userId: string, db: Database, auditStore?: AuditStore
|
|
179
|
+
export declare function createLLMClientForUser(userId: string, db: Database, auditStore?: Pick<AuditStore, 'append'>, opts?: {
|
|
180
|
+
hostedViableOnly?: boolean;
|
|
181
|
+
}): Promise<LLMClient | null>;
|
|
162
182
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/llm/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/llm/client.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/llm/client.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAGpD,OAAO,EACL,cAAc,EAGf,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,QAAQ,EACR,eAAe,EACf,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,OAAO,EACR,MAAM,qBAAqB,CAAC;AAS7B,OAAO,EAAE,KAAK,UAAU,EAAiB,KAAK,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChG,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAG7B,MAAM,MAAM,eAAe,GACvB,WAAW,GACX,QAAQ,GACR,MAAM,GACN,aAAa,GACb,QAAQ,GACR,iBAAiB,CAAC;AAEtB;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,eAAe,EAAE,OAAO,CAOzD,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAEjE;AAMD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,WAAW,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,eAAe,CAAC;IACnC,SAAS,CAAC,EAAE;QACV,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IACF,oFAAoF;IACpF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,wEAAwE;IACxE,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,6BAA6B;IAC7B,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,iEAAiE;IACjE,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,6BAA6B;IAC7B,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,4EAA4E;IAC5E,aAAa,CAAC,EAAE,qBAAqB,CAAC;IACtC,qEAAqE;IACrE,8BAA8B,CAAC,EAAE,MAAM,CAAC;IACxC,kFAAkF;IAClF,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAQD,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,gBAAgB,CAAC,CAAc;IACvC,OAAO,CAAC,qBAAqB,CAAC,CAAc;IAC5C,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,aAAa,CAAC,CAAwB;IAC9C,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,sBAAsB,CAAC,CAAiB;IAChD,wFAAwF;IACxF,OAAO,CAAC,aAAa,CAAS;gBAElB,MAAM,EAAE,eAAe;IA0DnC,OAAO,CAAC,cAAc;IAgCtB;;;OAGG;YACW,uBAAuB;IAmBrC,OAAO,CAAC,cAAc;IAoCtB,OAAO,CAAC,aAAa;IAMf,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IAwHzE,KAAK,CACT,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC;IAsD5B,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC;IA4EvF;;;OAGG;IACH,eAAe,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAA;KAAE;IAIlF;;OAEG;IACH,sBAAsB,IAAI;QACxB,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,UAAU,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAA;SAAE,CAAC;QACxF,QAAQ,CAAC,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,UAAU,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAA;SAAE,CAAC;KAC3F;IAkBD;;OAEG;IACH,gBAAgB,IAAI,qBAAqB,GAAG,SAAS;IAIrD;;;;OAIG;IACH,qBAAqB,IAAI,UAAU,GAAG,SAAS;IAI/C;;OAEG;IACH,kBAAkB,IAAI,IAAI;IAI1B;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,GAAG,SAAS;IAIvD;;OAEG;IACH,kBAAkB,IAAI,IAAI;CAG3B;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,IAAI,SAAS,CAyFlD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,QAAQ,EAGZ,UAAU,CAAC,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,EACvC,IAAI,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAE,GACpC,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAsD3B"}
|
package/dist/llm/client.js
CHANGED
|
@@ -3,16 +3,42 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single interface for all LLM providers with fallback and rate limiting
|
|
5
5
|
*/
|
|
6
|
+
// Log redaction lives in @revealui/security — import `redactLogContext`
|
|
7
|
+
// (recursive walker) or `redactLogField` (single key/value).
|
|
8
|
+
import { createLogger } from '@revealui/core/observability/logger';
|
|
6
9
|
import { decryptApiKey } from '@revealui/db/crypto';
|
|
7
10
|
import { tenantProviderConfigs, userApiKeys } from '@revealui/db/schema';
|
|
8
11
|
import { CircuitBreaker, CircuitBreakerOpenError, } from '@revealui/resilience';
|
|
9
12
|
import { and, eq } from 'drizzle-orm';
|
|
13
|
+
import { AnthropicProvider } from './providers/anthropic.js';
|
|
10
14
|
import { GroqProvider } from './providers/groq.js';
|
|
11
15
|
import { InferenceSnapsProvider, } from './providers/inference-snaps.js';
|
|
12
16
|
import { OllamaProvider } from './providers/ollama.js';
|
|
17
|
+
import { OpenAIProvider } from './providers/openai.js';
|
|
18
|
+
import { OpenAICompatProvider } from './providers/openai-compat.js';
|
|
13
19
|
import { ResponseCache } from './response-cache.js';
|
|
14
20
|
import { SemanticCache, } from './semantic-cache.js';
|
|
15
21
|
import { estimateRequest as _estimateRequestTokens } from './token-counter.js';
|
|
22
|
+
/**
|
|
23
|
+
* Providers reachable from a hosted (serverless) deployment. Localhost-only
|
|
24
|
+
* providers are false. Consumed by PR-2/PR-3 (resolver + settings UI) to pick
|
|
25
|
+
* which providers a hosted account may configure; no runtime consumer yet.
|
|
26
|
+
*/
|
|
27
|
+
export const hostedViable = {
|
|
28
|
+
anthropic: true,
|
|
29
|
+
openai: true,
|
|
30
|
+
groq: true,
|
|
31
|
+
huggingface: true,
|
|
32
|
+
ollama: false,
|
|
33
|
+
'inference-snaps': false,
|
|
34
|
+
};
|
|
35
|
+
/** True when the provider can serve a hosted (serverless) deployment. */
|
|
36
|
+
export function isHostedViable(provider) {
|
|
37
|
+
return hostedViable[provider];
|
|
38
|
+
}
|
|
39
|
+
/** Emitted once per process when the zero-config localhost default is selected. */
|
|
40
|
+
let warnedLocalhostDefault = false;
|
|
41
|
+
const envFactoryLogger = createLogger({ component: 'createLLMClientFromEnv' });
|
|
16
42
|
export class LLMClient {
|
|
17
43
|
provider;
|
|
18
44
|
fallbackProvider;
|
|
@@ -78,12 +104,22 @@ export class LLMClient {
|
|
|
78
104
|
}
|
|
79
105
|
createProvider(type, config) {
|
|
80
106
|
switch (type) {
|
|
107
|
+
case 'anthropic':
|
|
108
|
+
return new AnthropicProvider(config);
|
|
109
|
+
case 'openai':
|
|
110
|
+
return new OpenAIProvider(config);
|
|
81
111
|
case 'groq':
|
|
82
112
|
return new GroqProvider(config);
|
|
83
113
|
case 'ollama':
|
|
84
114
|
return new OllamaProvider(config);
|
|
85
115
|
case 'inference-snaps':
|
|
86
116
|
return new InferenceSnapsProvider(config);
|
|
117
|
+
case 'huggingface':
|
|
118
|
+
// HuggingFace exposes an OpenAI-compatible inference endpoint; baseURL is
|
|
119
|
+
// per-model (HF_MODEL_URL), so it has no dedicated wrapper — the compat
|
|
120
|
+
// base serves it directly. Fixes the latent defect where the env factory
|
|
121
|
+
// accepted 'huggingface' but createProvider threw 'Unknown provider type'.
|
|
122
|
+
return new OpenAICompatProvider(config);
|
|
87
123
|
default:
|
|
88
124
|
throw new Error(`Unknown provider type: ${String(type)}`);
|
|
89
125
|
}
|
|
@@ -412,23 +448,29 @@ export class LLMClient {
|
|
|
412
448
|
* Create an LLM client from environment variables.
|
|
413
449
|
*
|
|
414
450
|
* When LLM_PROVIDER is not set, auto-detects the provider by checking env vars
|
|
415
|
-
* in priority order: INFERENCE_SNAPS → GROQ → OLLAMA
|
|
416
|
-
*
|
|
417
|
-
* one-line stderr warning so
|
|
451
|
+
* in priority order: INFERENCE_SNAPS → GROQ → OLLAMA → ANTHROPIC_API_KEY →
|
|
452
|
+
* OPENAI_API_KEY. If none are set, defaults to Inference Snaps at
|
|
453
|
+
* http://localhost:9090/v1 (Ubuntu local) and emits a one-line stderr warning so
|
|
454
|
+
* the implicit localhost default is discoverable in logs.
|
|
418
455
|
*
|
|
419
|
-
* All providers use OpenAI-compatible APIs. No proprietary provider SDKs
|
|
456
|
+
* All providers use OpenAI-compatible APIs. No proprietary provider SDKs
|
|
457
|
+
* (Anthropic + OpenAI ride their OpenAI-compatible endpoints).
|
|
420
458
|
*
|
|
421
459
|
* Canonical Inference Snaps is the reference local provider on Ubuntu — offline,
|
|
422
460
|
* silicon-optimized, no API key required. See `providers/inference-snaps.ts` for
|
|
423
461
|
* install docs (`sudo snap install gemma3`, etc.).
|
|
424
462
|
*
|
|
425
463
|
* Provider defaults:
|
|
426
|
-
* inference-snaps → gemma3
|
|
464
|
+
* inference-snaps → gemma3 (base URL defaults to http://localhost:9090/v1)
|
|
427
465
|
* groq → qwen/qwen3-32b
|
|
428
|
-
* ollama → gemma4:e2b
|
|
466
|
+
* ollama → gemma4:e2b (base URL defaults to http://localhost:11434)
|
|
467
|
+
* anthropic → claude-sonnet-4-6 (base URL defaults to https://api.anthropic.com/v1)
|
|
468
|
+
* openai → gpt-4o (base URL defaults to https://api.openai.com/v1)
|
|
429
469
|
*/
|
|
430
470
|
export function createLLMClientFromEnv() {
|
|
431
|
-
// Auto-detect provider when LLM_PROVIDER is not explicitly set
|
|
471
|
+
// Auto-detect provider when LLM_PROVIDER is not explicitly set. The existing
|
|
472
|
+
// priority order (INFERENCE_SNAPS → GROQ → OLLAMA) is preserved so existing
|
|
473
|
+
// deployments resolve identically; the frontier providers are appended after.
|
|
432
474
|
let provider;
|
|
433
475
|
if (process.env.LLM_PROVIDER) {
|
|
434
476
|
provider = process.env.LLM_PROVIDER;
|
|
@@ -442,15 +484,38 @@ export function createLLMClientFromEnv() {
|
|
|
442
484
|
else if (process.env.OLLAMA_BASE_URL) {
|
|
443
485
|
provider = 'ollama';
|
|
444
486
|
}
|
|
487
|
+
else if (process.env.ANTHROPIC_API_KEY) {
|
|
488
|
+
provider = 'anthropic';
|
|
489
|
+
}
|
|
490
|
+
else if (process.env.OPENAI_API_KEY) {
|
|
491
|
+
provider = 'openai';
|
|
492
|
+
}
|
|
445
493
|
else {
|
|
446
|
-
// Zero-config Ubuntu default: assume Inference Snaps on the standard
|
|
447
|
-
//
|
|
494
|
+
// Zero-config Ubuntu default: assume Inference Snaps on the standard local
|
|
495
|
+
// port. This localhost default is unreachable inside a hosted serverless
|
|
496
|
+
// function, so warn once per process to make the implicit choice visible.
|
|
448
497
|
provider = 'inference-snaps';
|
|
498
|
+
if (!warnedLocalhostDefault) {
|
|
499
|
+
warnedLocalhostDefault = true;
|
|
500
|
+
envFactoryLogger.warn('No LLM provider env var set — defaulting to inference-snaps at ' +
|
|
501
|
+
'http://localhost:9090/v1. This localhost endpoint is unreachable on a ' +
|
|
502
|
+
'hosted deployment; set LLM_PROVIDER or a provider key.');
|
|
503
|
+
}
|
|
449
504
|
}
|
|
450
505
|
let apiKey;
|
|
451
506
|
let baseURL;
|
|
452
507
|
let defaultModel;
|
|
453
|
-
if (provider === '
|
|
508
|
+
if (provider === 'anthropic') {
|
|
509
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
510
|
+
baseURL = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com/v1';
|
|
511
|
+
defaultModel = 'claude-sonnet-4-6';
|
|
512
|
+
}
|
|
513
|
+
else if (provider === 'openai') {
|
|
514
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
515
|
+
baseURL = process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1';
|
|
516
|
+
defaultModel = 'gpt-4o';
|
|
517
|
+
}
|
|
518
|
+
else if (provider === 'huggingface') {
|
|
454
519
|
apiKey = process.env.HF_TOKEN;
|
|
455
520
|
baseURL = process.env.HF_MODEL_URL;
|
|
456
521
|
}
|
|
@@ -475,7 +540,8 @@ export function createLLMClientFromEnv() {
|
|
|
475
540
|
}
|
|
476
541
|
if (!apiKey) {
|
|
477
542
|
throw new Error(`API key not found for provider "${provider}". Set the corresponding env var ` +
|
|
478
|
-
`(INFERENCE_SNAPS_BASE_URL, GROQ_API_KEY, OLLAMA_BASE_URL,
|
|
543
|
+
`(INFERENCE_SNAPS_BASE_URL, GROQ_API_KEY, OLLAMA_BASE_URL, HF_TOKEN, ` +
|
|
544
|
+
`ANTHROPIC_API_KEY, or OPENAI_API_KEY).`);
|
|
479
545
|
}
|
|
480
546
|
return new LLMClient({
|
|
481
547
|
provider,
|
|
@@ -501,10 +567,19 @@ export function createLLMClientFromEnv() {
|
|
|
501
567
|
* Returns `null` if the user has no stored keys (callers should fall back
|
|
502
568
|
* to `createLLMClientFromEnv()` or return a 402/feature-unavailable error).
|
|
503
569
|
*
|
|
570
|
+
* When `opts.hostedViableOnly` is set, a stored key whose provider is not
|
|
571
|
+
* hosted-viable (ollama / inference-snaps are localhost-only) resolves to
|
|
572
|
+
* `null` instead of a client. The resolver passes this on hosted deployments so
|
|
573
|
+
* a localhost-only BYOK key can never yield a localhost client — the exact
|
|
574
|
+
* silent-localhost defect GAP-360 closes (spec §6.5, fail-closed).
|
|
575
|
+
*
|
|
504
576
|
* @param userId - The user's ID from the `users` table
|
|
505
577
|
* @param db - A Drizzle NeonDB client instance
|
|
506
578
|
*/
|
|
507
|
-
export async function createLLMClientForUser(userId, db,
|
|
579
|
+
export async function createLLMClientForUser(userId, db,
|
|
580
|
+
// Only `append` is used; the narrow type lets a persistent store whose entry
|
|
581
|
+
// type widens eventType/severity to `string` (e.g. DrizzleAuditStore) fit.
|
|
582
|
+
auditStore, opts) {
|
|
508
583
|
// Find the user's preferred provider config
|
|
509
584
|
const [preferredConfig] = await db
|
|
510
585
|
.select()
|
|
@@ -522,8 +597,12 @@ export async function createLLMClientForUser(userId, db, auditStore) {
|
|
|
522
597
|
const [keyRow] = await keyQuery;
|
|
523
598
|
if (!keyRow)
|
|
524
599
|
return null;
|
|
525
|
-
const plaintext = decryptApiKey(keyRow.encryptedKey);
|
|
526
600
|
const provider = keyRow.provider;
|
|
601
|
+
// Fail-closed hosted filter (§6.5): reject a localhost-only provider before
|
|
602
|
+
// decrypting, so no plaintext is touched for a key we will not use.
|
|
603
|
+
if (opts?.hostedViableOnly && !isHostedViable(provider))
|
|
604
|
+
return null;
|
|
605
|
+
const plaintext = decryptApiKey(keyRow.encryptedKey);
|
|
527
606
|
const model = preferredConfig?.model ?? undefined;
|
|
528
607
|
// Fire-and-forget: record when this key was last used (best-effort, never blocks)
|
|
529
608
|
db.update(userApiKeys)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Provider
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over OpenAICompatProvider using Anthropic's OpenAI-compatible
|
|
5
|
+
* surface at https://api.anthropic.com/v1. No proprietary Anthropic SDK — the
|
|
6
|
+
* fleet posture is "No proprietary provider SDKs" (see client.ts). Anthropic
|
|
7
|
+
* exposes an OpenAI-compatible chat/completions endpoint, so the same base
|
|
8
|
+
* implementation that serves Groq/Ollama/inference-snaps serves Anthropic too.
|
|
9
|
+
*
|
|
10
|
+
* Docs: https://docs.anthropic.com/en/api/openai-sdk
|
|
11
|
+
*/
|
|
12
|
+
import type { Embedding, LLMChatOptions, LLMChunk, LLMEmbedOptions, LLMProvider, LLMProviderConfig, LLMResponse, LLMStreamOptions, Message, ReasonerCapabilities } from './base.js';
|
|
13
|
+
export interface AnthropicProviderConfig extends Omit<LLMProviderConfig, 'apiKey'> {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
/** Defaults to https://api.anthropic.com/v1 */
|
|
16
|
+
baseURL?: string;
|
|
17
|
+
/** Defaults to claude-sonnet-4-6 (conservative documented default; overridable) */
|
|
18
|
+
model?: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
maxRetries?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class AnthropicProvider implements LLMProvider {
|
|
23
|
+
private inner;
|
|
24
|
+
constructor(config: AnthropicProviderConfig);
|
|
25
|
+
capabilities(): ReasonerCapabilities;
|
|
26
|
+
chat(messages: Message[], options?: LLMChatOptions): Promise<LLMResponse>;
|
|
27
|
+
stream(messages: Message[], options?: LLMStreamOptions): AsyncIterable<LLMChunk>;
|
|
28
|
+
embed(_text: string | string[], _options?: LLMEmbedOptions): Promise<Embedding | Embedding[]>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=anthropic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anthropic.d.ts","sourceRoot":"","sources":["../../../src/llm/providers/anthropic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,QAAQ,EACR,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,OAAO,EACP,oBAAoB,EACrB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC;IAChF,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,iBAAkB,YAAW,WAAW;IACnD,OAAO,CAAC,KAAK,CAAuB;gBAExB,MAAM,EAAE,uBAAuB;IAQ3C,YAAY,IAAI,oBAAoB;IAepC,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IAIzE,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC;IAIhF,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,QAAQ,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC;CAG9F"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Provider
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over OpenAICompatProvider using Anthropic's OpenAI-compatible
|
|
5
|
+
* surface at https://api.anthropic.com/v1. No proprietary Anthropic SDK — the
|
|
6
|
+
* fleet posture is "No proprietary provider SDKs" (see client.ts). Anthropic
|
|
7
|
+
* exposes an OpenAI-compatible chat/completions endpoint, so the same base
|
|
8
|
+
* implementation that serves Groq/Ollama/inference-snaps serves Anthropic too.
|
|
9
|
+
*
|
|
10
|
+
* Docs: https://docs.anthropic.com/en/api/openai-sdk
|
|
11
|
+
*/
|
|
12
|
+
import { OpenAICompatProvider } from './openai-compat.js';
|
|
13
|
+
export class AnthropicProvider {
|
|
14
|
+
inner;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.inner = new OpenAICompatProvider({
|
|
17
|
+
...config,
|
|
18
|
+
baseURL: config.baseURL ?? 'https://api.anthropic.com/v1',
|
|
19
|
+
model: config.model ?? 'claude-sonnet-4-6',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
capabilities() {
|
|
23
|
+
return {
|
|
24
|
+
providerTag: 'anthropic',
|
|
25
|
+
tools: true,
|
|
26
|
+
parallelToolCalls: false,
|
|
27
|
+
vision: false,
|
|
28
|
+
streaming: true,
|
|
29
|
+
// Anthropic exposes no embeddings endpoint on its OpenAI-compat surface.
|
|
30
|
+
embeddings: false,
|
|
31
|
+
reasoningEffort: false,
|
|
32
|
+
promptCache: false,
|
|
33
|
+
structuredOutput: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
chat(messages, options) {
|
|
37
|
+
return this.inner.chat(messages, options);
|
|
38
|
+
}
|
|
39
|
+
stream(messages, options) {
|
|
40
|
+
return this.inner.stream(messages, options);
|
|
41
|
+
}
|
|
42
|
+
embed(_text, _options) {
|
|
43
|
+
throw new Error('Anthropic does not expose an embeddings endpoint. Use OpenAI or Ollama.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -7,11 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Embedding, LLMChatOptions, LLMChunk, LLMEmbedOptions, LLMProvider, LLMProviderConfig, LLMResponse, LLMStreamOptions, Message, ReasonerCapabilities } from './base.js';
|
|
9
9
|
export interface OpenAICompatConfig extends LLMProviderConfig {
|
|
10
|
+
/** Per-request timeout in ms applied to connection setup. Default 60_000. */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
/** Retries on connection/timeout errors (not on HTTP error responses). Default 1. */
|
|
13
|
+
maxRetries?: number;
|
|
10
14
|
}
|
|
11
15
|
export declare class OpenAICompatProvider implements LLMProvider {
|
|
12
16
|
private config;
|
|
13
17
|
private baseURL;
|
|
18
|
+
private timeoutMs;
|
|
19
|
+
private maxRetries;
|
|
14
20
|
constructor(config: OpenAICompatConfig);
|
|
21
|
+
/**
|
|
22
|
+
* fetch() with a connection-setup timeout and bounded retries. The timeout
|
|
23
|
+
* aborts a hung connection (the localhost-default failure mode that otherwise
|
|
24
|
+
* burns the whole serverless duration) and is cleared once headers arrive, so
|
|
25
|
+
* a legitimately long streaming body is not truncated. Retries fire only on a
|
|
26
|
+
* thrown fetch error (connection refused, DNS, timeout) — never on an HTTP
|
|
27
|
+
* error response, which the caller inspects — so a POST is re-sent only when
|
|
28
|
+
* no response was received.
|
|
29
|
+
*/
|
|
30
|
+
private fetchWithResilience;
|
|
15
31
|
capabilities(): ReasonerCapabilities;
|
|
16
32
|
chat(messages: Message[], options?: LLMChatOptions): Promise<LLMResponse>;
|
|
17
33
|
embed(text: string | string[], options?: LLMEmbedOptions): Promise<Embedding | Embedding[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"openai-compat.d.ts","sourceRoot":"","sources":["../../../src/llm/providers/openai-compat.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,SAAS,EAET,cAAc,EACd,QAAQ,EACR,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,OAAO,EACP,oBAAoB,EAErB,MAAM,WAAW,CAAC;AAEnB,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;
|
|
1
|
+
{"version":3,"file":"openai-compat.d.ts","sourceRoot":"","sources":["../../../src/llm/providers/openai-compat.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,SAAS,EAET,cAAc,EACd,QAAQ,EACR,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,OAAO,EACP,oBAAoB,EAErB,MAAM,WAAW,CAAC;AAEnB,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAwDD,qBAAa,oBAAqB,YAAW,WAAW;IACtD,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAAS;gBAEf,MAAM,EAAE,kBAAkB;IAYtC;;;;;;;;OAQG;YACW,mBAAmB;IAgBjC,YAAY,IAAI,oBAAoB;IAgB9B,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IA6EzE,KAAK,CACT,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC;IAyC5B,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC;IAuFvF,OAAO,CAAC,cAAc;CA4BvB"}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* format. Used by: Ollama, Groq, Inference Snaps.
|
|
6
6
|
* NOT for direct OpenAI usage - RevealUI uses open-source models only.
|
|
7
7
|
*/
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
9
|
+
const DEFAULT_MAX_RETRIES = 1;
|
|
8
10
|
const authorizationHeader = 'Authorization';
|
|
9
11
|
const maxTokensKey = 'max_tokens';
|
|
10
12
|
const toolChoiceKey = 'tool_choice';
|
|
@@ -31,12 +33,42 @@ const isFunctionToolCall = (call) => {
|
|
|
31
33
|
export class OpenAICompatProvider {
|
|
32
34
|
config;
|
|
33
35
|
baseURL;
|
|
36
|
+
timeoutMs;
|
|
37
|
+
maxRetries;
|
|
34
38
|
constructor(config) {
|
|
35
39
|
this.config = config;
|
|
36
40
|
if (!config.baseURL) {
|
|
37
41
|
throw new Error('OpenAICompatProvider requires a baseURL - use a specific provider (InferenceSnapsProvider, OllamaProvider, etc.)');
|
|
38
42
|
}
|
|
39
43
|
this.baseURL = config.baseURL;
|
|
44
|
+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
45
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* fetch() with a connection-setup timeout and bounded retries. The timeout
|
|
49
|
+
* aborts a hung connection (the localhost-default failure mode that otherwise
|
|
50
|
+
* burns the whole serverless duration) and is cleared once headers arrive, so
|
|
51
|
+
* a legitimately long streaming body is not truncated. Retries fire only on a
|
|
52
|
+
* thrown fetch error (connection refused, DNS, timeout) — never on an HTTP
|
|
53
|
+
* error response, which the caller inspects — so a POST is re-sent only when
|
|
54
|
+
* no response was received.
|
|
55
|
+
*/
|
|
56
|
+
async fetchWithResilience(url, init) {
|
|
57
|
+
let lastError;
|
|
58
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
lastError = error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timeoutId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
40
72
|
}
|
|
41
73
|
capabilities() {
|
|
42
74
|
// Base OpenAI-compatible profile. Concrete providers (Groq, Ollama, inference-snaps)
|
|
@@ -54,7 +86,7 @@ export class OpenAICompatProvider {
|
|
|
54
86
|
};
|
|
55
87
|
}
|
|
56
88
|
async chat(messages, options) {
|
|
57
|
-
const response = await
|
|
89
|
+
const response = await this.fetchWithResilience(`${this.baseURL}/chat/completions`, {
|
|
58
90
|
method: 'POST',
|
|
59
91
|
headers: {
|
|
60
92
|
'Content-Type': 'application/json',
|
|
@@ -122,7 +154,7 @@ export class OpenAICompatProvider {
|
|
|
122
154
|
async embed(text, options) {
|
|
123
155
|
const texts = Array.isArray(text) ? text : [text];
|
|
124
156
|
const model = options?.model || 'text-embedding-3-small';
|
|
125
|
-
const response = await
|
|
157
|
+
const response = await this.fetchWithResilience(`${this.baseURL}/embeddings`, {
|
|
126
158
|
method: 'POST',
|
|
127
159
|
headers: {
|
|
128
160
|
'Content-Type': 'application/json',
|
|
@@ -155,7 +187,7 @@ export class OpenAICompatProvider {
|
|
|
155
187
|
return Array.isArray(text) ? embeddings : embeddings[0];
|
|
156
188
|
}
|
|
157
189
|
async *stream(messages, options) {
|
|
158
|
-
const response = await
|
|
190
|
+
const response = await this.fetchWithResilience(`${this.baseURL}/chat/completions`, {
|
|
159
191
|
method: 'POST',
|
|
160
192
|
headers: {
|
|
161
193
|
'Content-Type': 'application/json',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Provider
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over OpenAICompatProvider targeting OpenAI's native
|
|
5
|
+
* chat/completions + embeddings API at https://api.openai.com/v1. OpenAI is the
|
|
6
|
+
* reference OpenAI-compatible surface, so the shared base implementation serves
|
|
7
|
+
* it directly — no proprietary SDK (fleet posture in client.ts).
|
|
8
|
+
*/
|
|
9
|
+
import type { Embedding, LLMChatOptions, LLMChunk, LLMEmbedOptions, LLMProvider, LLMProviderConfig, LLMResponse, LLMStreamOptions, Message, ReasonerCapabilities } from './base.js';
|
|
10
|
+
export interface OpenAIProviderConfig extends Omit<LLMProviderConfig, 'apiKey'> {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Defaults to https://api.openai.com/v1 */
|
|
13
|
+
baseURL?: string;
|
|
14
|
+
/** Defaults to gpt-4o (conservative documented default; overridable) */
|
|
15
|
+
model?: string;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class OpenAIProvider implements LLMProvider {
|
|
20
|
+
private inner;
|
|
21
|
+
constructor(config: OpenAIProviderConfig);
|
|
22
|
+
capabilities(): ReasonerCapabilities;
|
|
23
|
+
chat(messages: Message[], options?: LLMChatOptions): Promise<LLMResponse>;
|
|
24
|
+
stream(messages: Message[], options?: LLMStreamOptions): AsyncIterable<LLMChunk>;
|
|
25
|
+
embed(text: string | string[], options?: LLMEmbedOptions): Promise<Embedding | Embedding[]>;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=openai.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openai.d.ts","sourceRoot":"","sources":["../../../src/llm/providers/openai.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,QAAQ,EACR,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,OAAO,EACP,oBAAoB,EACrB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,4CAA4C;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,cAAe,YAAW,WAAW;IAChD,OAAO,CAAC,KAAK,CAAuB;gBAExB,MAAM,EAAE,oBAAoB;IAQxC,YAAY,IAAI,oBAAoB;IAcpC,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IAIzE,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC;IAIhF,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC;CAG5F"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Provider
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over OpenAICompatProvider targeting OpenAI's native
|
|
5
|
+
* chat/completions + embeddings API at https://api.openai.com/v1. OpenAI is the
|
|
6
|
+
* reference OpenAI-compatible surface, so the shared base implementation serves
|
|
7
|
+
* it directly — no proprietary SDK (fleet posture in client.ts).
|
|
8
|
+
*/
|
|
9
|
+
import { OpenAICompatProvider } from './openai-compat.js';
|
|
10
|
+
export class OpenAIProvider {
|
|
11
|
+
inner;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.inner = new OpenAICompatProvider({
|
|
14
|
+
...config,
|
|
15
|
+
baseURL: config.baseURL ?? 'https://api.openai.com/v1',
|
|
16
|
+
model: config.model ?? 'gpt-4o',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
capabilities() {
|
|
20
|
+
return {
|
|
21
|
+
providerTag: 'openai',
|
|
22
|
+
tools: true,
|
|
23
|
+
parallelToolCalls: false,
|
|
24
|
+
vision: false,
|
|
25
|
+
streaming: true,
|
|
26
|
+
embeddings: true,
|
|
27
|
+
reasoningEffort: false,
|
|
28
|
+
promptCache: false,
|
|
29
|
+
structuredOutput: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
chat(messages, options) {
|
|
33
|
+
return this.inner.chat(messages, options);
|
|
34
|
+
}
|
|
35
|
+
stream(messages, options) {
|
|
36
|
+
return this.inner.stream(messages, options);
|
|
37
|
+
}
|
|
38
|
+
embed(text, options) {
|
|
39
|
+
return this.inner.embed(text, options);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request LLM client resolver (GAP-360 PR-2).
|
|
3
|
+
*
|
|
4
|
+
* The single home for key resolution at every dispatch site (spec §5.2 / §5.4).
|
|
5
|
+
* One resolver, consumed by all sites — no site-local copies.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order (spec §5.2):
|
|
8
|
+
* 1. Per-user BYOK — createLLMClientForUser (preferred)
|
|
9
|
+
* 2. Site inference config — workspace_inference_configs (hostedViable on hosted)
|
|
10
|
+
* 3. Deployment env — createLLMClientFromEnv (SELF-HOSTED ONLY)
|
|
11
|
+
* 4. Hosted + nothing above → throw LLMNotConfiguredError (typed → HTTP 409)
|
|
12
|
+
*
|
|
13
|
+
* Security invariants (spec §6, guardrail-2):
|
|
14
|
+
* - §6.1 userId is authenticated-identity-scoped; the caller derives it from
|
|
15
|
+
* session/entitlement context — or, for the durable worker, the
|
|
16
|
+
* authenticated dispatcher captured server-side at enqueue time — never
|
|
17
|
+
* from request params/body, and never from a client-writable DB column
|
|
18
|
+
* (e.g. a ticket's `reporterId`, which has no ownership check on the
|
|
19
|
+
* general tickets API). The resolver takes userId as an argument and
|
|
20
|
+
* never reads it from a request. The same rule binds the step-2 site
|
|
21
|
+
* inference key: the workspaceId is client-writable, so its stored key is
|
|
22
|
+
* decrypted only after userCanAccessSite confirms the caller owns or
|
|
23
|
+
* collaborates on that site. Otherwise a request could name another
|
|
24
|
+
* tenant's site id and run on that site's key.
|
|
25
|
+
* - §6.2 plaintext lifetime = request scope. The client is constructed per
|
|
26
|
+
* request; no key cache. Nothing here logs, serializes, or returns a key.
|
|
27
|
+
* - §6.3 decryption is server-side only via the existing decryptApiKey.
|
|
28
|
+
* - §6.4 the byok:key:accessed audit event fires inside createLLMClientForUser
|
|
29
|
+
* when an audit store is wired (ctx.auditStore).
|
|
30
|
+
* - §6.5 fail-closed: unknown provider / failed decrypt / a non-hostedViable
|
|
31
|
+
* provider on hosted resolves to LLMNotConfiguredError, never an env
|
|
32
|
+
* fallthrough on hosted (the exact silent-localhost defect class).
|
|
33
|
+
* - §6.6 no new secret surface.
|
|
34
|
+
*
|
|
35
|
+
* Feature flag (spec §7): HOSTED_BYOK_DISPATCH. Default ON for hosted, absent
|
|
36
|
+
* (off) for self-hosted so self-hosted env-first behavior is byte-unchanged.
|
|
37
|
+
* The flag is the one-release rollback lever.
|
|
38
|
+
*/
|
|
39
|
+
import type { Database } from '@revealui/db/client';
|
|
40
|
+
import type { AuditStore } from '../audit/store.js';
|
|
41
|
+
import { LLMClient } from './client.js';
|
|
42
|
+
/**
|
|
43
|
+
* Thrown when a hosted deployment has no usable LLM configuration for the
|
|
44
|
+
* request. Maps to HTTP 409 (configuration is the remedy, not payment).
|
|
45
|
+
* Callers name {@link settingsPath} in the machine-readable response body.
|
|
46
|
+
*/
|
|
47
|
+
export declare class LLMNotConfiguredError extends Error {
|
|
48
|
+
/** Stable machine-readable code for API response bodies. */
|
|
49
|
+
readonly code: "LLM_NOT_CONFIGURED";
|
|
50
|
+
/** Where the account owner configures a key. */
|
|
51
|
+
readonly settingsPath: "/settings/api-keys";
|
|
52
|
+
constructor(message?: string);
|
|
53
|
+
}
|
|
54
|
+
/** The 409 response body every dispatch site returns for an unconfigured account. */
|
|
55
|
+
export interface LLMNotConfiguredBody {
|
|
56
|
+
success: false;
|
|
57
|
+
error: string;
|
|
58
|
+
code: 'LLM_NOT_CONFIGURED';
|
|
59
|
+
settingsPath: '/settings/api-keys';
|
|
60
|
+
}
|
|
61
|
+
/** Build the machine-readable 409 body for {@link LLMNotConfiguredError}. */
|
|
62
|
+
export declare function llmNotConfiguredBody(err: LLMNotConfiguredError): LLMNotConfiguredBody;
|
|
63
|
+
export interface ResolveLLMContext {
|
|
64
|
+
/**
|
|
65
|
+
* True on the hosted revealui.com SaaS deployment. Derived by the caller from
|
|
66
|
+
* the existing deployment-mode signal (server: detectDeploymentMode; admin:
|
|
67
|
+
* REVEALUI_LICENSE_PRIVATE_KEY presence) — never sniffed here.
|
|
68
|
+
*/
|
|
69
|
+
isHosted: boolean;
|
|
70
|
+
/** Site id for step-2 site-level config lookup. Omit when the site has none. */
|
|
71
|
+
workspaceId?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Durable audit sink for the byok:key:accessed event (§6.4). Only `append` is
|
|
74
|
+
* used, so a persistent store (DrizzleAuditStore) fits. Omit where none is
|
|
75
|
+
* wired (e.g. the admin process, in-memory until GAP-338 closes).
|
|
76
|
+
*/
|
|
77
|
+
auditStore?: Pick<AuditStore, 'append'>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether the BYOK dispatch order (spec §5.2) is active. Default follows the
|
|
81
|
+
* deployment: ON when hosted, OFF when self-hosted. HOSTED_BYOK_DISPATCH
|
|
82
|
+
* overrides explicitly; an unrecognized value falls back to the default.
|
|
83
|
+
*/
|
|
84
|
+
export declare function hostedByokDispatchEnabled(isHosted: boolean): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the LLM client for a single request.
|
|
87
|
+
*
|
|
88
|
+
* @param userId - Authenticated user id, from session/entitlement context for
|
|
89
|
+
* request-scoped callers, or the authenticated dispatcher captured
|
|
90
|
+
* server-side at enqueue time for the durable worker. Never a request
|
|
91
|
+
* param/body value, and never a client-writable DB column. Null when there
|
|
92
|
+
* is no authenticated user.
|
|
93
|
+
* @param db - Drizzle client.
|
|
94
|
+
* @param ctx - Deployment mode, optional site id, optional audit sink.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveLLMClientForRequest(userId: string | null, db: Database, ctx: ResolveLLMContext): Promise<LLMClient>;
|
|
97
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/llm/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAIL,SAAS,EAEV,MAAM,aAAa,CAAC;AAMrB;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAG,oBAAoB,CAAU;IAC9C,gDAAgD;IAChD,QAAQ,CAAC,YAAY,EAAG,oBAAoB,CAAU;gBAE1C,OAAO,SAAoD;CAIxE;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,KAAK,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,oBAAoB,CAAC;IAC3B,YAAY,EAAE,oBAAoB,CAAC;CACpC;AAED,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,qBAAqB,GAAG,oBAAoB,CAOrF;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;CACzC;AAKD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,CAOpE;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,0BAA0B,CAC9C,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,EAAE,EAAE,QAAQ,EACZ,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,SAAS,CAAC,CAsDpB"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request LLM client resolver (GAP-360 PR-2).
|
|
3
|
+
*
|
|
4
|
+
* The single home for key resolution at every dispatch site (spec §5.2 / §5.4).
|
|
5
|
+
* One resolver, consumed by all sites — no site-local copies.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order (spec §5.2):
|
|
8
|
+
* 1. Per-user BYOK — createLLMClientForUser (preferred)
|
|
9
|
+
* 2. Site inference config — workspace_inference_configs (hostedViable on hosted)
|
|
10
|
+
* 3. Deployment env — createLLMClientFromEnv (SELF-HOSTED ONLY)
|
|
11
|
+
* 4. Hosted + nothing above → throw LLMNotConfiguredError (typed → HTTP 409)
|
|
12
|
+
*
|
|
13
|
+
* Security invariants (spec §6, guardrail-2):
|
|
14
|
+
* - §6.1 userId is authenticated-identity-scoped; the caller derives it from
|
|
15
|
+
* session/entitlement context — or, for the durable worker, the
|
|
16
|
+
* authenticated dispatcher captured server-side at enqueue time — never
|
|
17
|
+
* from request params/body, and never from a client-writable DB column
|
|
18
|
+
* (e.g. a ticket's `reporterId`, which has no ownership check on the
|
|
19
|
+
* general tickets API). The resolver takes userId as an argument and
|
|
20
|
+
* never reads it from a request. The same rule binds the step-2 site
|
|
21
|
+
* inference key: the workspaceId is client-writable, so its stored key is
|
|
22
|
+
* decrypted only after userCanAccessSite confirms the caller owns or
|
|
23
|
+
* collaborates on that site. Otherwise a request could name another
|
|
24
|
+
* tenant's site id and run on that site's key.
|
|
25
|
+
* - §6.2 plaintext lifetime = request scope. The client is constructed per
|
|
26
|
+
* request; no key cache. Nothing here logs, serializes, or returns a key.
|
|
27
|
+
* - §6.3 decryption is server-side only via the existing decryptApiKey.
|
|
28
|
+
* - §6.4 the byok:key:accessed audit event fires inside createLLMClientForUser
|
|
29
|
+
* when an audit store is wired (ctx.auditStore).
|
|
30
|
+
* - §6.5 fail-closed: unknown provider / failed decrypt / a non-hostedViable
|
|
31
|
+
* provider on hosted resolves to LLMNotConfiguredError, never an env
|
|
32
|
+
* fallthrough on hosted (the exact silent-localhost defect class).
|
|
33
|
+
* - §6.6 no new secret surface.
|
|
34
|
+
*
|
|
35
|
+
* Feature flag (spec §7): HOSTED_BYOK_DISPATCH. Default ON for hosted, absent
|
|
36
|
+
* (off) for self-hosted so self-hosted env-first behavior is byte-unchanged.
|
|
37
|
+
* The flag is the one-release rollback lever.
|
|
38
|
+
*/
|
|
39
|
+
import { createLogger } from '@revealui/core/observability/logger';
|
|
40
|
+
import { decryptApiKey } from '@revealui/db/crypto';
|
|
41
|
+
import { siteCollaborators, sites, workspaceInferenceConfigs } from '@revealui/db/schema';
|
|
42
|
+
import { and, eq } from 'drizzle-orm';
|
|
43
|
+
import { createLLMClientForUser, createLLMClientFromEnv, isHostedViable, LLMClient, } from './client.js';
|
|
44
|
+
const resolverLogger = createLogger({ component: 'resolveLLMClientForRequest' });
|
|
45
|
+
/** Emitted once per process when the hosted BYOK rollback lever is pulled. */
|
|
46
|
+
let warnedBreakGlass = false;
|
|
47
|
+
/**
|
|
48
|
+
* Thrown when a hosted deployment has no usable LLM configuration for the
|
|
49
|
+
* request. Maps to HTTP 409 (configuration is the remedy, not payment).
|
|
50
|
+
* Callers name {@link settingsPath} in the machine-readable response body.
|
|
51
|
+
*/
|
|
52
|
+
export class LLMNotConfiguredError extends Error {
|
|
53
|
+
/** Stable machine-readable code for API response bodies. */
|
|
54
|
+
code = 'LLM_NOT_CONFIGURED';
|
|
55
|
+
/** Where the account owner configures a key. */
|
|
56
|
+
settingsPath = '/settings/api-keys';
|
|
57
|
+
constructor(message = 'No LLM provider is configured for this account.') {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = 'LLMNotConfiguredError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Build the machine-readable 409 body for {@link LLMNotConfiguredError}. */
|
|
63
|
+
export function llmNotConfiguredBody(err) {
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: err.message,
|
|
67
|
+
code: err.code,
|
|
68
|
+
settingsPath: err.settingsPath,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const FLAG_ON = new Set(['true', '1', 'on', 'yes']);
|
|
72
|
+
const FLAG_OFF = new Set(['false', '0', 'off', 'no']);
|
|
73
|
+
/**
|
|
74
|
+
* Whether the BYOK dispatch order (spec §5.2) is active. Default follows the
|
|
75
|
+
* deployment: ON when hosted, OFF when self-hosted. HOSTED_BYOK_DISPATCH
|
|
76
|
+
* overrides explicitly; an unrecognized value falls back to the default.
|
|
77
|
+
*/
|
|
78
|
+
export function hostedByokDispatchEnabled(isHosted) {
|
|
79
|
+
const raw = process.env.HOSTED_BYOK_DISPATCH;
|
|
80
|
+
if (raw === undefined || raw.trim() === '')
|
|
81
|
+
return isHosted;
|
|
82
|
+
const value = raw.trim().toLowerCase();
|
|
83
|
+
if (FLAG_OFF.has(value))
|
|
84
|
+
return false;
|
|
85
|
+
if (FLAG_ON.has(value))
|
|
86
|
+
return true;
|
|
87
|
+
return isHosted;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the LLM client for a single request.
|
|
91
|
+
*
|
|
92
|
+
* @param userId - Authenticated user id, from session/entitlement context for
|
|
93
|
+
* request-scoped callers, or the authenticated dispatcher captured
|
|
94
|
+
* server-side at enqueue time for the durable worker. Never a request
|
|
95
|
+
* param/body value, and never a client-writable DB column. Null when there
|
|
96
|
+
* is no authenticated user.
|
|
97
|
+
* @param db - Drizzle client.
|
|
98
|
+
* @param ctx - Deployment mode, optional site id, optional audit sink.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveLLMClientForRequest(userId, db, ctx) {
|
|
101
|
+
const hosted = ctx.isHosted;
|
|
102
|
+
// Feature-flag gate. When disabled (self-hosted default), behavior is
|
|
103
|
+
// byte-unchanged: env-first, exactly as before this PR.
|
|
104
|
+
if (!hostedByokDispatchEnabled(hosted)) {
|
|
105
|
+
if (hosted && !warnedBreakGlass) {
|
|
106
|
+
warnedBreakGlass = true;
|
|
107
|
+
// Break-glass: HOSTED_BYOK_DISPATCH is explicitly off on a hosted
|
|
108
|
+
// deployment. Every account now shares the deployment env client while
|
|
109
|
+
// this lever is pulled — a deliberate one-release rollback, but an
|
|
110
|
+
// operator must know it is active.
|
|
111
|
+
resolverLogger.warn('HOSTED_BYOK_DISPATCH is disabled on a hosted deployment — all accounts are ' +
|
|
112
|
+
'sharing the deployment env LLM client instead of per-account BYOK keys.');
|
|
113
|
+
}
|
|
114
|
+
return createLLMClientFromEnv();
|
|
115
|
+
}
|
|
116
|
+
// 1. Per-user BYOK (preferred). On hosted, filter to hostedViable providers
|
|
117
|
+
// so a localhost-only BYOK key (e.g. ollama) can never yield a localhost
|
|
118
|
+
// client — that is the silent-localhost defect (§6.5, fail-closed).
|
|
119
|
+
if (userId) {
|
|
120
|
+
try {
|
|
121
|
+
const byok = await createLLMClientForUser(userId, db, ctx.auditStore, {
|
|
122
|
+
hostedViableOnly: hosted,
|
|
123
|
+
});
|
|
124
|
+
if (byok)
|
|
125
|
+
return byok;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Failed decrypt / unknown-provider row / CHECK-violating row. Fail-closed
|
|
129
|
+
// on hosted — never fall through to env. Self-hosted may continue.
|
|
130
|
+
if (hosted) {
|
|
131
|
+
throw new LLMNotConfiguredError('Your stored API key could not be used. Re-add it under /settings/api-keys.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 2. Site-level inference config. userId is passed so the site's stored key
|
|
136
|
+
// is decrypted only for a caller authorized on that site (§6.1) — the
|
|
137
|
+
// workspaceId reaching here is a client-writable value (e.g. a request
|
|
138
|
+
// body field), so it carries no ownership guarantee on its own.
|
|
139
|
+
const siteClient = await resolveSiteInferenceClient(db, userId, ctx.workspaceId, hosted);
|
|
140
|
+
if (siteClient)
|
|
141
|
+
return siteClient;
|
|
142
|
+
// 3. Deployment env — SELF-HOSTED ONLY. Forbidden on hosted (§5.2 step 3).
|
|
143
|
+
if (!hosted) {
|
|
144
|
+
return createLLMClientFromEnv();
|
|
145
|
+
}
|
|
146
|
+
// 4. Hosted + nothing above → fail loud, actionable.
|
|
147
|
+
throw new LLMNotConfiguredError();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether `userId` is authorized to act on `siteId` — the site's owner, or a
|
|
151
|
+
* row in `site_collaborators`. Gate for the site inference key (§6.1): the
|
|
152
|
+
* site's stored provider key must never be decrypted for a caller who does not
|
|
153
|
+
* belong to the site, even though the workspaceId is client-supplied.
|
|
154
|
+
*/
|
|
155
|
+
async function userCanAccessSite(db, userId, siteId) {
|
|
156
|
+
const [owned] = await db
|
|
157
|
+
.select({ id: sites.id })
|
|
158
|
+
.from(sites)
|
|
159
|
+
.where(and(eq(sites.id, siteId), eq(sites.ownerId, userId)))
|
|
160
|
+
.limit(1);
|
|
161
|
+
if (owned)
|
|
162
|
+
return true;
|
|
163
|
+
const [collaborator] = await db
|
|
164
|
+
.select({ id: siteCollaborators.id })
|
|
165
|
+
.from(siteCollaborators)
|
|
166
|
+
.where(and(eq(siteCollaborators.siteId, siteId), eq(siteCollaborators.userId, userId)))
|
|
167
|
+
.limit(1);
|
|
168
|
+
return Boolean(collaborator);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a client from the site's workspace_inference_configs row (spec §5.2
|
|
172
|
+
* step 2). Returns null when the site has no config, when the caller is not
|
|
173
|
+
* authorized on the site, or (on hosted) when its provider is not hostedViable.
|
|
174
|
+
* Fail-closed: a malformed row throws on hosted and is skipped on self-hosted.
|
|
175
|
+
*/
|
|
176
|
+
async function resolveSiteInferenceClient(db, userId, workspaceId, hosted) {
|
|
177
|
+
if (!workspaceId)
|
|
178
|
+
return null;
|
|
179
|
+
// §6.1 authorization: only decrypt a site's key for a caller who belongs to
|
|
180
|
+
// that site. An unauthorized (or anonymous) request skips the site config
|
|
181
|
+
// entirely; on hosted it then falls through to the step-4 409, never a
|
|
182
|
+
// decrypt of another tenant's key.
|
|
183
|
+
if (!(userId && (await userCanAccessSite(db, userId, workspaceId))))
|
|
184
|
+
return null;
|
|
185
|
+
const [config] = await db
|
|
186
|
+
.select()
|
|
187
|
+
.from(workspaceInferenceConfigs)
|
|
188
|
+
.where(eq(workspaceInferenceConfigs.workspaceId, workspaceId))
|
|
189
|
+
.limit(1);
|
|
190
|
+
if (!config)
|
|
191
|
+
return null;
|
|
192
|
+
const provider = config.provider;
|
|
193
|
+
// On hosted, only hostedViable providers are reachable. A non-viable site
|
|
194
|
+
// config is skipped so resolution fails closed rather than hitting localhost.
|
|
195
|
+
if (hosted && !isHostedViable(provider))
|
|
196
|
+
return null;
|
|
197
|
+
try {
|
|
198
|
+
// Keyless providers (ollama / inference-snaps) carry a NULL encrypted key
|
|
199
|
+
// and use the provider name as the placeholder key — matches the env
|
|
200
|
+
// factory. Keyed providers decrypt server-side at dispatch time (§6.3).
|
|
201
|
+
const apiKey = config.encryptedApiKey ? decryptApiKey(config.encryptedApiKey) : provider;
|
|
202
|
+
return new LLMClient({
|
|
203
|
+
provider,
|
|
204
|
+
apiKey,
|
|
205
|
+
model: config.model ?? undefined,
|
|
206
|
+
baseURL: config.baseURL ?? undefined,
|
|
207
|
+
temperature: config.temperature ?? undefined,
|
|
208
|
+
maxTokens: config.maxTokens ?? undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Unknown provider / failed decrypt. Fail-closed on hosted; skip on self-hosted.
|
|
213
|
+
if (hosted) {
|
|
214
|
+
throw new LLMNotConfiguredError('This site’s inference configuration could not be used. Update it under /settings/api-keys.');
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
package/dist/llm/server.d.ts
CHANGED
|
@@ -10,5 +10,6 @@ export * from './providers/groq.js';
|
|
|
10
10
|
export * from './providers/inference-snaps.js';
|
|
11
11
|
export * from './providers/ollama.js';
|
|
12
12
|
export * from './providers/openai-compat.js';
|
|
13
|
+
export * from './resolve.js';
|
|
13
14
|
export * from './workspace-provider-config.js';
|
|
14
15
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/llm/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/llm/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/llm/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,aAAa,CAAC;AAE5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,uBAAuB,CAAC;AACtC,cAAc,8BAA8B,CAAC;AAE7C,cAAc,cAAc,CAAC;AAI7B,cAAc,gCAAgC,CAAC"}
|
package/dist/llm/server.js
CHANGED
|
@@ -12,6 +12,8 @@ export * from './providers/groq.js';
|
|
|
12
12
|
export * from './providers/inference-snaps.js';
|
|
13
13
|
export * from './providers/ollama.js';
|
|
14
14
|
export * from './providers/openai-compat.js';
|
|
15
|
+
// Export the per-request client resolver (GAP-360 PR-2)
|
|
16
|
+
export * from './resolve.js';
|
|
15
17
|
// Export per-workspace provider registry (used by admin inference-config route
|
|
16
18
|
// to hydrate per-site config at boot + apply changes immediately on PUT)
|
|
17
19
|
export * from './workspace-provider-config.js';
|
package/dist/skills/types.d.ts
CHANGED
|
@@ -27,13 +27,13 @@ export declare const SkillMetadataSchema: z.ZodObject<{
|
|
|
27
27
|
repository: z.ZodOptional<z.ZodString>;
|
|
28
28
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
29
|
compatibility: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
30
|
+
anthropic: "anthropic";
|
|
31
|
+
openai: "openai";
|
|
30
32
|
"claude-code": "claude-code";
|
|
31
33
|
cursor: "cursor";
|
|
32
34
|
windsurf: "windsurf";
|
|
33
35
|
cline: "cline";
|
|
34
36
|
copilot: "copilot";
|
|
35
|
-
openai: "openai";
|
|
36
|
-
anthropic: "anthropic";
|
|
37
37
|
universal: "universal";
|
|
38
38
|
}>>>;
|
|
39
39
|
allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -78,13 +78,13 @@ export declare const SkillSchema: z.ZodObject<{
|
|
|
78
78
|
repository: z.ZodOptional<z.ZodString>;
|
|
79
79
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
80
80
|
compatibility: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
81
|
+
anthropic: "anthropic";
|
|
82
|
+
openai: "openai";
|
|
81
83
|
"claude-code": "claude-code";
|
|
82
84
|
cursor: "cursor";
|
|
83
85
|
windsurf: "windsurf";
|
|
84
86
|
cline: "cline";
|
|
85
87
|
copilot: "copilot";
|
|
86
|
-
openai: "openai";
|
|
87
|
-
anthropic: "anthropic";
|
|
88
88
|
universal: "universal";
|
|
89
89
|
}>>>;
|
|
90
90
|
allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -143,13 +143,13 @@ export declare const SkillActivationResultSchema: z.ZodObject<{
|
|
|
143
143
|
repository: z.ZodOptional<z.ZodString>;
|
|
144
144
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
145
145
|
compatibility: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
146
|
+
anthropic: "anthropic";
|
|
147
|
+
openai: "openai";
|
|
146
148
|
"claude-code": "claude-code";
|
|
147
149
|
cursor: "cursor";
|
|
148
150
|
windsurf: "windsurf";
|
|
149
151
|
cline: "cline";
|
|
150
152
|
copilot: "copilot";
|
|
151
|
-
openai: "openai";
|
|
152
|
-
anthropic: "anthropic";
|
|
153
153
|
universal: "universal";
|
|
154
154
|
}>>>;
|
|
155
155
|
allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revealui/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "AI runtime for agent-driven products — agents, memory, LLM providers (Inference Snaps, Ollama, OpenAI-compatible), tools, and orchestration. Anthropic-SDK-free.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"lru-cache": "^11.5.1",
|
|
27
27
|
"zod": "^4.4.3",
|
|
28
|
-
"@revealui/contracts": "0.
|
|
29
|
-
"@revealui/core": "0.11.
|
|
30
|
-
"@revealui/db": "0.
|
|
28
|
+
"@revealui/contracts": "0.7.0",
|
|
29
|
+
"@revealui/core": "0.11.1",
|
|
30
|
+
"@revealui/db": "0.8.0",
|
|
31
31
|
"@revealui/resilience": "0.2.4"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|