@revealui/ai 0.6.4 → 0.8.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 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 Anthropic, GROQ, Ollama, Canonical Inference Snaps, and more
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
- Cloud providers (Anthropic, OpenAI, GROQ) remain supported; the local
68
- inference path is the documented default for self-hosted deployments.
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, GROQ, and Ollama.
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;AAGvB,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;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,SAAS,CAAC,CA4BpB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,SAAS,EAAE,CAAC,CAMtB"}
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"}
@@ -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
- // Use unified LLM client - auto-detects provider from env vars
45
- const client = createLLMClientFromEnv();
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';
@@ -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
@@ -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 = 'groq' | 'ollama' | 'huggingface' | 'inference-snaps';
13
+ export type LLMProviderType = 'anthropic' | 'openai' | 'groq' | 'huggingface' | 'ollama' | 'inference-snaps' | 'xai';
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,25 @@ 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. If none are set, defaults
133
- * to Inference Snaps at http://localhost:9090/v1 (Ubuntu local) and emits a
134
- * one-line stderr warning so the implicit default is discoverable in logs.
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 (base URL defaults to http://localhost:9090/v1)
153
+ * inference-snaps → gemma3 (base URL defaults to http://localhost:9090/v1)
144
154
  * groq → qwen/qwen3-32b
145
- * ollama → gemma4:e2b (base URL defaults to http://localhost:11434)
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)
158
+ * xai → grok-4.5 (base URL defaults to https://api.x.ai/v1)
146
159
  */
147
160
  export declare function createLLMClientFromEnv(): LLMClient;
148
161
  /**
@@ -155,8 +168,16 @@ export declare function createLLMClientFromEnv(): LLMClient;
155
168
  * Returns `null` if the user has no stored keys (callers should fall back
156
169
  * to `createLLMClientFromEnv()` or return a 402/feature-unavailable error).
157
170
  *
171
+ * When `opts.hostedViableOnly` is set, a stored key whose provider is not
172
+ * hosted-viable (ollama / inference-snaps are localhost-only) resolves to
173
+ * `null` instead of a client. The resolver passes this on hosted deployments so
174
+ * a localhost-only BYOK key can never yield a localhost client — the exact
175
+ * silent-localhost defect GAP-360 closes (spec §6.5, fail-closed).
176
+ *
158
177
  * @param userId - The user's ID from the `users` table
159
178
  * @param db - A Drizzle NeonDB client instance
160
179
  */
161
- export declare function createLLMClientForUser(userId: string, db: Database, auditStore?: AuditStore): Promise<LLMClient | null>;
180
+ export declare function createLLMClientForUser(userId: string, db: Database, auditStore?: Pick<AuditStore, 'append'>, opts?: {
181
+ hostedViableOnly?: boolean;
182
+ }): Promise<LLMClient | null>;
162
183
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/llm/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/llm/client.ts"],"names":[],"mappings":"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"}
@@ -3,16 +3,44 @@
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';
19
+ import { XaiProvider } from './providers/xai.js';
13
20
  import { ResponseCache } from './response-cache.js';
14
21
  import { SemanticCache, } from './semantic-cache.js';
15
22
  import { estimateRequest as _estimateRequestTokens } from './token-counter.js';
23
+ /**
24
+ * Providers reachable from a hosted (serverless) deployment. Localhost-only
25
+ * providers are false. Consumed by PR-2/PR-3 (resolver + settings UI) to pick
26
+ * which providers a hosted account may configure; no runtime consumer yet.
27
+ */
28
+ export const hostedViable = {
29
+ anthropic: true,
30
+ openai: true,
31
+ groq: true,
32
+ huggingface: true,
33
+ ollama: false,
34
+ 'inference-snaps': false,
35
+ xai: true,
36
+ };
37
+ /** True when the provider can serve a hosted (serverless) deployment. */
38
+ export function isHostedViable(provider) {
39
+ return hostedViable[provider];
40
+ }
41
+ /** Emitted once per process when the zero-config localhost default is selected. */
42
+ let warnedLocalhostDefault = false;
43
+ const envFactoryLogger = createLogger({ component: 'createLLMClientFromEnv' });
16
44
  export class LLMClient {
17
45
  provider;
18
46
  fallbackProvider;
@@ -78,12 +106,24 @@ export class LLMClient {
78
106
  }
79
107
  createProvider(type, config) {
80
108
  switch (type) {
109
+ case 'anthropic':
110
+ return new AnthropicProvider(config);
111
+ case 'openai':
112
+ return new OpenAIProvider(config);
81
113
  case 'groq':
82
114
  return new GroqProvider(config);
83
115
  case 'ollama':
84
116
  return new OllamaProvider(config);
85
117
  case 'inference-snaps':
86
118
  return new InferenceSnapsProvider(config);
119
+ case 'xai':
120
+ return new XaiProvider(config);
121
+ case 'huggingface':
122
+ // HuggingFace exposes an OpenAI-compatible inference endpoint; baseURL is
123
+ // per-model (HF_MODEL_URL), so it has no dedicated wrapper — the compat
124
+ // base serves it directly. Fixes the latent defect where the env factory
125
+ // accepted 'huggingface' but createProvider threw 'Unknown provider type'.
126
+ return new OpenAICompatProvider(config);
87
127
  default:
88
128
  throw new Error(`Unknown provider type: ${String(type)}`);
89
129
  }
@@ -412,23 +452,30 @@ export class LLMClient {
412
452
  * Create an LLM client from environment variables.
413
453
  *
414
454
  * When LLM_PROVIDER is not set, auto-detects the provider by checking env vars
415
- * in priority order: INFERENCE_SNAPS → GROQ → OLLAMA. If none are set, defaults
416
- * to Inference Snaps at http://localhost:9090/v1 (Ubuntu local) and emits a
417
- * one-line stderr warning so the implicit default is discoverable in logs.
455
+ * in priority order: INFERENCE_SNAPS → GROQ → OLLAMA ANTHROPIC_API_KEY
456
+ * OPENAI_API_KEY. If none are set, defaults to Inference Snaps at
457
+ * http://localhost:9090/v1 (Ubuntu local) and emits a one-line stderr warning so
458
+ * the implicit localhost default is discoverable in logs.
418
459
  *
419
- * All providers use OpenAI-compatible APIs. No proprietary provider SDKs.
460
+ * All providers use OpenAI-compatible APIs. No proprietary provider SDKs
461
+ * (Anthropic + OpenAI ride their OpenAI-compatible endpoints).
420
462
  *
421
463
  * Canonical Inference Snaps is the reference local provider on Ubuntu — offline,
422
464
  * silicon-optimized, no API key required. See `providers/inference-snaps.ts` for
423
465
  * install docs (`sudo snap install gemma3`, etc.).
424
466
  *
425
467
  * Provider defaults:
426
- * inference-snaps → gemma3 (base URL defaults to http://localhost:9090/v1)
468
+ * inference-snaps → gemma3 (base URL defaults to http://localhost:9090/v1)
427
469
  * groq → qwen/qwen3-32b
428
- * ollama → gemma4:e2b (base URL defaults to http://localhost:11434)
470
+ * ollama → gemma4:e2b (base URL defaults to http://localhost:11434)
471
+ * anthropic → claude-sonnet-4-6 (base URL defaults to https://api.anthropic.com/v1)
472
+ * openai → gpt-4o (base URL defaults to https://api.openai.com/v1)
473
+ * xai → grok-4.5 (base URL defaults to https://api.x.ai/v1)
429
474
  */
430
475
  export function createLLMClientFromEnv() {
431
- // Auto-detect provider when LLM_PROVIDER is not explicitly set
476
+ // Auto-detect provider when LLM_PROVIDER is not explicitly set. The existing
477
+ // priority order (INFERENCE_SNAPS → GROQ → OLLAMA) is preserved so existing
478
+ // deployments resolve identically; the frontier providers are appended after.
432
479
  let provider;
433
480
  if (process.env.LLM_PROVIDER) {
434
481
  provider = process.env.LLM_PROVIDER;
@@ -442,15 +489,46 @@ export function createLLMClientFromEnv() {
442
489
  else if (process.env.OLLAMA_BASE_URL) {
443
490
  provider = 'ollama';
444
491
  }
492
+ else if (process.env.ANTHROPIC_API_KEY) {
493
+ provider = 'anthropic';
494
+ }
495
+ else if (process.env.OPENAI_API_KEY) {
496
+ provider = 'openai';
497
+ }
498
+ else if (process.env.XAI_API_KEY) {
499
+ provider = 'xai';
500
+ }
445
501
  else {
446
- // Zero-config Ubuntu default: assume Inference Snaps on the standard
447
- // local port. Operator sees the warning once at boot.
502
+ // Zero-config Ubuntu default: assume Inference Snaps on the standard local
503
+ // port. This localhost default is unreachable inside a hosted serverless
504
+ // function, so warn once per process to make the implicit choice visible.
448
505
  provider = 'inference-snaps';
506
+ if (!warnedLocalhostDefault) {
507
+ warnedLocalhostDefault = true;
508
+ envFactoryLogger.warn('No LLM provider env var set — defaulting to inference-snaps at ' +
509
+ 'http://localhost:9090/v1. This localhost endpoint is unreachable on a ' +
510
+ 'hosted deployment; set LLM_PROVIDER or a provider key.');
511
+ }
449
512
  }
450
513
  let apiKey;
451
514
  let baseURL;
452
515
  let defaultModel;
453
- if (provider === 'huggingface') {
516
+ if (provider === 'anthropic') {
517
+ apiKey = process.env.ANTHROPIC_API_KEY;
518
+ baseURL = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com/v1';
519
+ defaultModel = 'claude-sonnet-4-6';
520
+ }
521
+ else if (provider === 'openai') {
522
+ apiKey = process.env.OPENAI_API_KEY;
523
+ baseURL = process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1';
524
+ defaultModel = 'gpt-4o';
525
+ }
526
+ else if (provider === 'xai') {
527
+ apiKey = process.env.XAI_API_KEY;
528
+ baseURL = process.env.XAI_BASE_URL ?? 'https://api.x.ai/v1';
529
+ defaultModel = 'grok-4.5';
530
+ }
531
+ else if (provider === 'huggingface') {
454
532
  apiKey = process.env.HF_TOKEN;
455
533
  baseURL = process.env.HF_MODEL_URL;
456
534
  }
@@ -475,7 +553,8 @@ export function createLLMClientFromEnv() {
475
553
  }
476
554
  if (!apiKey) {
477
555
  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, or HF_TOKEN).`);
556
+ `(INFERENCE_SNAPS_BASE_URL, GROQ_API_KEY, OLLAMA_BASE_URL, HF_TOKEN, ` +
557
+ `ANTHROPIC_API_KEY, OPENAI_API_KEY, or XAI_API_KEY).`);
479
558
  }
480
559
  return new LLMClient({
481
560
  provider,
@@ -501,10 +580,19 @@ export function createLLMClientFromEnv() {
501
580
  * Returns `null` if the user has no stored keys (callers should fall back
502
581
  * to `createLLMClientFromEnv()` or return a 402/feature-unavailable error).
503
582
  *
583
+ * When `opts.hostedViableOnly` is set, a stored key whose provider is not
584
+ * hosted-viable (ollama / inference-snaps are localhost-only) resolves to
585
+ * `null` instead of a client. The resolver passes this on hosted deployments so
586
+ * a localhost-only BYOK key can never yield a localhost client — the exact
587
+ * silent-localhost defect GAP-360 closes (spec §6.5, fail-closed).
588
+ *
504
589
  * @param userId - The user's ID from the `users` table
505
590
  * @param db - A Drizzle NeonDB client instance
506
591
  */
507
- export async function createLLMClientForUser(userId, db, auditStore) {
592
+ export async function createLLMClientForUser(userId, db,
593
+ // Only `append` is used; the narrow type lets a persistent store whose entry
594
+ // type widens eventType/severity to `string` (e.g. DrizzleAuditStore) fit.
595
+ auditStore, opts) {
508
596
  // Find the user's preferred provider config
509
597
  const [preferredConfig] = await db
510
598
  .select()
@@ -522,8 +610,12 @@ export async function createLLMClientForUser(userId, db, auditStore) {
522
610
  const [keyRow] = await keyQuery;
523
611
  if (!keyRow)
524
612
  return null;
525
- const plaintext = decryptApiKey(keyRow.encryptedKey);
526
613
  const provider = keyRow.provider;
614
+ // Fail-closed hosted filter (§6.5): reject a localhost-only provider before
615
+ // decrypting, so no plaintext is touched for a key we will not use.
616
+ if (opts?.hostedViableOnly && !isHostedViable(provider))
617
+ return null;
618
+ const plaintext = decryptApiKey(keyRow.encryptedKey);
527
619
  const model = preferredConfig?.model ?? undefined;
528
620
  // Fire-and-forget: record when this key was last used (best-effort, never blocks)
529
621
  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;CAAG;AAqDhE,qBAAa,oBAAqB,YAAW,WAAW;IACtD,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAAS;gBAEZ,MAAM,EAAE,kBAAkB;IAUtC,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"}
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 fetch(`${this.baseURL}/chat/completions`, {
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 fetch(`${this.baseURL}/embeddings`, {
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 fetch(`${this.baseURL}/chat/completions`, {
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,29 @@
1
+ /**
2
+ * xAI (Grok) Provider
3
+ *
4
+ * Thin wrapper over OpenAICompatProvider using xAI's OpenAI-compatible
5
+ * surface at https://api.x.ai/v1. No proprietary xAI SDK — the fleet posture
6
+ * is "No proprietary provider SDKs" (see client.ts). BYOK only: the user
7
+ * brings their own xAI API key, RevealUI never hosts one.
8
+ *
9
+ * Docs: https://docs.x.ai/developers/grok-4-5
10
+ */
11
+ import type { Embedding, LLMChatOptions, LLMChunk, LLMEmbedOptions, LLMProvider, LLMProviderConfig, LLMResponse, LLMStreamOptions, Message, ReasonerCapabilities } from './base.js';
12
+ export interface XaiProviderConfig extends Omit<LLMProviderConfig, 'apiKey'> {
13
+ apiKey: string;
14
+ /** Defaults to https://api.x.ai/v1 */
15
+ baseURL?: string;
16
+ /** Defaults to grok-4.5 */
17
+ model?: string;
18
+ timeout?: number;
19
+ maxRetries?: number;
20
+ }
21
+ export declare class XaiProvider implements LLMProvider {
22
+ private inner;
23
+ constructor(config: XaiProviderConfig);
24
+ capabilities(): ReasonerCapabilities;
25
+ chat(messages: Message[], options?: LLMChatOptions): Promise<LLMResponse>;
26
+ stream(messages: Message[], options?: LLMStreamOptions): AsyncIterable<LLMChunk>;
27
+ embed(_text: string | string[], _options?: LLMEmbedOptions): Promise<Embedding | Embedding[]>;
28
+ }
29
+ //# sourceMappingURL=xai.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xai.d.ts","sourceRoot":"","sources":["../../../src/llm/providers/xai.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;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,iBAAkB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,WAAY,YAAW,WAAW;IAC7C,OAAO,CAAC,KAAK,CAAuB;gBAExB,MAAM,EAAE,iBAAiB;IAQrC,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,44 @@
1
+ /**
2
+ * xAI (Grok) Provider
3
+ *
4
+ * Thin wrapper over OpenAICompatProvider using xAI's OpenAI-compatible
5
+ * surface at https://api.x.ai/v1. No proprietary xAI SDK — the fleet posture
6
+ * is "No proprietary provider SDKs" (see client.ts). BYOK only: the user
7
+ * brings their own xAI API key, RevealUI never hosts one.
8
+ *
9
+ * Docs: https://docs.x.ai/developers/grok-4-5
10
+ */
11
+ import { OpenAICompatProvider } from './openai-compat.js';
12
+ export class XaiProvider {
13
+ inner;
14
+ constructor(config) {
15
+ this.inner = new OpenAICompatProvider({
16
+ ...config,
17
+ baseURL: config.baseURL ?? 'https://api.x.ai/v1',
18
+ model: config.model ?? 'grok-4.5',
19
+ });
20
+ }
21
+ capabilities() {
22
+ return {
23
+ providerTag: 'xai',
24
+ tools: true,
25
+ parallelToolCalls: false,
26
+ vision: false,
27
+ streaming: true,
28
+ // xAI exposes no embeddings endpoint on its OpenAI-compat surface.
29
+ embeddings: false,
30
+ reasoningEffort: false,
31
+ promptCache: false,
32
+ structuredOutput: false,
33
+ };
34
+ }
35
+ chat(messages, options) {
36
+ return this.inner.chat(messages, options);
37
+ }
38
+ stream(messages, options) {
39
+ return this.inner.stream(messages, options);
40
+ }
41
+ embed(_text, _options) {
42
+ throw new Error('xAI does not expose an embeddings endpoint. Use OpenAI or Ollama.');
43
+ }
44
+ }
@@ -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
+ }
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/llm/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,aAAa,CAAC;AAG5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,uBAAuB,CAAC;AACtC,cAAc,8BAA8B,CAAC;AAI7C,cAAc,gCAAgC,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"}
@@ -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';
@@ -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.6.4",
3
+ "version": "0.8.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.6.2",
29
- "@revealui/core": "0.11.0",
30
- "@revealui/db": "0.7.3",
28
+ "@revealui/core": "0.12.0",
29
+ "@revealui/contracts": "0.8.0",
30
+ "@revealui/db": "0.9.0",
31
31
  "@revealui/resilience": "0.2.4"
32
32
  },
33
33
  "devDependencies": {