agentic-server 0.21.1 → 0.22.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
@@ -9,29 +9,27 @@
9
9
  <img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10
10
  </a>
11
11
  <a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12
- <a href="https://www.npmjs.com/package/agentic-server"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fagentic-server%2Fpackage.json"/></a>
12
+ <a href="https://www.npmjs.com/package/agentic-server"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=agentic%2Fagentic-server%2Fpackage.json"/></a>
13
13
  </p>
14
14
 
15
- Standalone Express LLM service — agent threads, chat streaming, billing metering, and inference logging via `@constructive-io/express-context`.
15
+ Standalone, OpenAI-compatible LLM gateway — a stateless multi-provider proxy with pluggable inference metering.
16
16
 
17
17
  ## Overview
18
18
 
19
- `agentic-server` is the Express-only equivalent of what `graphile-llm` does inside PostGraphile. It provides the same capabilities (agent threads, chat, embeddings, billing, inference logging) but as a standalone Express router that uses `@constructive-io/express-context` for tenant-scoped database access.
19
+ `agentic-server` is a small Express app that accepts OpenAI-compatible requests, routes them to a configured LLM provider (OpenAI, Anthropic, or Ollama), normalizes the response back to the OpenAI shape, and records usage through an injected sink. It is **backend-agnostic**: the gateway never imports a concrete telemetry/billing implementation — callers inject an `InferenceSink`, so the same gateway can meter to `compute_log`, a billing service, or nothing at all.
20
20
 
21
21
  ## Usage
22
22
 
23
23
  ```typescript
24
- import express from 'express';
25
- import { createContextMiddleware } from '@constructive-io/express-context';
26
- import { createAgenticRouter } from 'agentic-server';
27
-
28
- const app = express();
29
-
30
- // Tenant context middleware (domain resolution, JWT, pgSettings, withPgClient)
31
- app.use(createContextMiddleware());
32
-
33
- // Mount the agentic router
34
- app.use(createAgenticRouter());
24
+ import { createAgenticServer } from 'agentic-server';
25
+
26
+ const app = createAgenticServer({
27
+ providerType: 'ollama',
28
+ providerBaseUrl: 'http://localhost:11434',
29
+ defaultModel: 'llama3',
30
+ // Optional: record usage. Fire-and-forget; implementations must never throw.
31
+ inferenceSink: { logInference: (entry) => myBackend.record(entry) },
32
+ });
35
33
 
36
34
  app.listen(3001, () => {
37
35
  console.log('agentic-server running on :3001');
@@ -42,46 +40,41 @@ app.listen(3001, () => {
42
40
 
43
41
  | Method | Path | Description |
44
42
  |--------|------|-------------|
45
- | POST | `/v1/threads` | Create a new conversation thread |
46
- | POST | `/v1/threads/:thread_id/messages` | Send messages + get AI response (streaming SSE) |
47
- | POST | `/v1/orgs/:entity_id/threads` | Create thread (entity-scoped) |
48
- | POST | `/v1/orgs/:entity_id/threads/:thread_id/messages` | Send message (entity-scoped) |
49
- | POST | `/v1/embed` | Generate embeddings |
50
-
51
- ## Environment Variables
52
-
53
- | Variable | Default | Description |
54
- |----------|---------|-------------|
55
- | `CHAT_PROVIDER` | `ollama` | Chat LLM provider |
56
- | `CHAT_MODEL` | `llama3` | Default chat model |
57
- | `CHAT_BASE_URL` | `http://localhost:11434` | Chat provider URL |
58
- | `EMBEDDER_PROVIDER` | `ollama` | Embedding provider |
59
- | `EMBEDDER_MODEL` | `nomic-embed-text` | Default embedding model |
60
- | `EMBEDDER_BASE_URL` | `http://localhost:11434` | Embedding provider URL |
61
-
62
- ## Features
63
-
64
- - **Thread management**: Create and manage conversation threads with system prompts
65
- - **Streaming chat**: SSE streaming responses (OpenAI-compatible format)
66
- - **Batch chat**: Non-streaming JSON responses
67
- - **Embeddings**: Generate vector embeddings via `/v1/embed`
68
- - **Billing integration**: Automatic quota checks and usage recording (when billing module is provisioned)
69
- - **Inference logging**: Automatic token usage logging (when inference_log module is provisioned)
70
- - **RLS enforcement**: All database operations run within tenant-scoped RLS transactions
43
+ | POST | `/v1/chat/completions` | OpenAI-compatible chat completion (multi-provider) |
44
+ | POST | `/v1/embeddings` | OpenAI-compatible embeddings (multi-provider) |
45
+ | POST | `/v1/usage` | Submit an external usage report to the metering sink |
46
+ | GET | `/v1/providers` | List configured providers |
47
+ | GET | `/healthz` | Health check |
48
+
49
+ ## Identity & tenancy
50
+
51
+ Requests carry tenant identity via headers: `X-Database-Id` (**required** — requests without it are rejected `400`), `X-Entity-Id`, and `X-Actor-Id`. Routing to a specific provider can be forced with `X-LLM-Provider`.
52
+
53
+ `isPublic` controls trust: when `false` (default) the server sits on a private network and trusts these headers directly; when `true` the identity headers are stripped from incoming requests (external clients cannot set tenant context), so such requests fail loud rather than being mis-attributed.
54
+
55
+ ## Metering
56
+
57
+ Metering is injected, not built in:
58
+
59
+ ```typescript
60
+ interface InferenceSink {
61
+ logInference(entry: InferenceEntry): void; // fire-and-forget; must not throw
62
+ }
63
+ ```
64
+
65
+ The gateway calls `logInference` after each chat/embedding (and for `/v1/usage` reports). Consumers own the concrete implementation — e.g. `constructive-db` injects a `compute_log`-backed sink built on its `ModuleLoader`.
71
66
 
72
67
  ## Architecture
73
68
 
74
69
  ```
75
- Cloud Function → POST /v1/threads/:id/messages → agentic-server
76
- │
77
- ┌───────────────────┼───────────────────┐
78
- │ │ │
79
- express-context OllamaAdapter Billing
80
- (tenant DB context) (LLM provider) (quota + usage)
70
+ Client → POST /v1/chat/completions → agentic-server
71
+ │
72
+ ┌─────────────────┼─────────────────┐
73
+ │ │ │
74
+ resolveProvider LLM provider InferenceSink
75
+ + transforms (OpenAI/Ollama/…) (injected metering)
81
76
  ```
82
77
 
83
- The cloud function doesn't need to know about databases, billing, or LLM providers. It just POSTs `{ messages, model }` and gets back a streamed response.
84
-
85
78
  ---
86
79
 
87
80
  ## Education and Tutorials
package/esm/index.js CHANGED
@@ -1,26 +1,26 @@
1
1
  /**
2
- * agentic-server — Standalone Express LLM service
2
+ * agentic-server — Standalone OpenAI-compatible LLM gateway
3
3
  *
4
- * Express-only equivalent of graphile-llm: agent threads, chat streaming,
5
- * billing metering, and inference logging. Uses @constructive-io/express-context
6
- * for tenant-scoped database access.
7
- *
8
- * LLM provider config is resolved per-database via `ctx.useLlm()` (from the
9
- * llm_module table), falling back to env vars from @constructive-io/llm-env when the module
10
- * is not provisioned. Discovery and billing are handled by the shared loaders
11
- * in express-context — no custom caching here.
4
+ * A stateless multi-provider proxy that:
5
+ * 1. Accepts OpenAI-compatible requests (`/v1/chat/completions`, `/v1/embeddings`)
6
+ * 2. Enforces tenant isolation via X-Database-Id / X-Entity-Id / X-Actor-Id headers
7
+ * 3. Routes to configured LLM provider(s) (OpenAI, Anthropic, Ollama)
8
+ * 4. Records inference usage through an injected {@link InferenceSink}
9
+ * (fire-and-forget) — the gateway is backend-agnostic and never imports a
10
+ * concrete metering implementation.
12
11
  *
13
12
  * @example
14
13
  * ```typescript
15
- * import express from 'express';
16
- * import { createContextMiddleware } from '@constructive-io/express-context';
17
- * import { createAgenticRouter } from 'agentic-server';
14
+ * import { createAgenticServer } from 'agentic-server';
18
15
  *
19
- * const app = express();
20
- * app.use(createContextMiddleware());
21
- * app.use(createAgenticRouter());
16
+ * const app = createAgenticServer({
17
+ * providerType: 'ollama',
18
+ * providerBaseUrl: 'http://localhost:11434',
19
+ * inferenceSink: { logInference: (entry) => myBackend.record(entry) },
20
+ * });
22
21
  * app.listen(3001);
23
22
  * ```
24
23
  */
25
- export { createAgenticRouter } from './router';
26
- export { getEnvVars as getLlmEnvVars, getEnvOptions as getLlmEnvOptions, llmDefaults } from '@constructive-io/llm-env';
24
+ export { buildProviderHeaders, resolveProvider, resolveUpstreamUrl } from './providers';
25
+ export { createRouter } from './router';
26
+ export { createAgenticServer } from './server';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resolve which provider to use for a given request.
3
+ * Priority: model-based routing → explicit provider header → default.
4
+ */
5
+ export function resolveProvider(options, requestModel, requestProvider) {
6
+ const providers = options.providers || [];
7
+ if (providers.length > 0) {
8
+ if (requestProvider) {
9
+ const match = providers.find((p) => p.type === requestProvider);
10
+ if (match)
11
+ return match;
12
+ }
13
+ // Route by model prefix: "anthropic/claude-3" → anthropic provider
14
+ if (requestModel && requestModel.includes('/')) {
15
+ const prefix = requestModel.split('/')[0];
16
+ const match = providers.find((p) => p.type === prefix);
17
+ if (match) {
18
+ return { ...match, defaultModel: requestModel.split('/').slice(1).join('/') };
19
+ }
20
+ }
21
+ // Route by known model patterns
22
+ if (requestModel) {
23
+ if (requestModel.startsWith('claude')) {
24
+ const match = providers.find((p) => p.type === 'anthropic');
25
+ if (match)
26
+ return match;
27
+ }
28
+ if (requestModel.startsWith('gpt') || requestModel.startsWith('o1') || requestModel.startsWith('o3')) {
29
+ const match = providers.find((p) => p.type === 'openai');
30
+ if (match)
31
+ return match;
32
+ }
33
+ if (requestModel.startsWith('llama') ||
34
+ requestModel.startsWith('mistral') ||
35
+ requestModel.startsWith('gemma') ||
36
+ requestModel.startsWith('nomic')) {
37
+ const match = providers.find((p) => p.type === 'ollama');
38
+ if (match)
39
+ return match;
40
+ }
41
+ }
42
+ return providers[0];
43
+ }
44
+ // Legacy single-provider mode (default: ollama for local dev)
45
+ return {
46
+ type: options.providerType || 'ollama',
47
+ baseUrl: options.providerBaseUrl || 'http://localhost:11434',
48
+ apiKey: options.providerApiKey,
49
+ defaultModel: options.defaultModel
50
+ };
51
+ }
52
+ export function resolveUpstreamUrl(provider, path) {
53
+ if (provider.type === 'ollama') {
54
+ if (path === '/v1/chat/completions')
55
+ return `${provider.baseUrl}/api/chat`;
56
+ if (path === '/v1/embeddings')
57
+ return `${provider.baseUrl}/api/embed`;
58
+ }
59
+ if (provider.type === 'anthropic') {
60
+ if (path === '/v1/chat/completions')
61
+ return `${provider.baseUrl}/v1/messages`;
62
+ }
63
+ return `${provider.baseUrl}${path}`;
64
+ }
65
+ export function buildProviderHeaders(provider) {
66
+ const headers = { 'Content-Type': 'application/json' };
67
+ if (provider.apiKey) {
68
+ if (provider.type === 'anthropic') {
69
+ headers['x-api-key'] = provider.apiKey;
70
+ headers['anthropic-version'] = '2023-06-01';
71
+ }
72
+ else {
73
+ headers['Authorization'] = `Bearer ${provider.apiKey}`;
74
+ }
75
+ }
76
+ return headers;
77
+ }