@vaxelia/ai-openai 0.1.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 ADDED
@@ -0,0 +1,149 @@
1
+ # @vaxelia/ai-openai
2
+
3
+ A **drop-in, compliance-instrumented** wrapper around the official OpenAI SDK
4
+ ([`openai`](https://www.npmjs.com/package/openai)). Every non-streaming
5
+ `chat.completions.create` call is logged to your tenant's decision-log endpoint
6
+ via [`@vaxelia/ai-core`](../sdk-ai-core/README.md) — with its resilient,
7
+ encrypted-on-disk buffer — so an AI decision is never silently lost. Every other
8
+ method of the OpenAI SDK passes straight through to the real client untouched.
9
+
10
+ `openai` is a regular dependency of this package, so the wrapper is a true
11
+ single-install drop-in: you do not install the OpenAI SDK separately.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @vaxelia/ai-openai
17
+ ```
18
+
19
+ Requires Node.js >= 20.
20
+
21
+ ## The one-line swap
22
+
23
+ Change your import — keep everything else:
24
+
25
+ ```diff
26
+ - import OpenAI from 'openai'
27
+ + import OpenAI from '@vaxelia/ai-openai'
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```ts
33
+ import OpenAI from '@vaxelia/ai-openai'
34
+
35
+ const client = new OpenAI({
36
+ apiKey: process.env.OPENAI_API_KEY,
37
+ // Optional compliance block. Every field falls back to an env var (below),
38
+ // so you can also pass nothing here and configure entirely from the env.
39
+ compliance: {
40
+ aiSystemId: process.env.VAXELIA_AI_SYSTEM_ID,
41
+ tenantApiUrl: process.env.VAXELIA_TENANT_API_URL,
42
+ apiKey: process.env.VAXELIA_API_KEY,
43
+ bufferKey: process.env.VAXELIA_BUFFER_KEY,
44
+ logger: (level, msg, fields) =>
45
+ console[level === 'debug' ? 'log' : level](JSON.stringify({ level, msg, ...fields })),
46
+ },
47
+ })
48
+
49
+ // Identical to the official SDK. The decision is logged for you.
50
+ const completion = await client.chat.completions.create({
51
+ model: 'gpt-4o',
52
+ messages: [{ role: 'user', content: 'Summarize the attached contract.' }],
53
+ })
54
+ ```
55
+
56
+ With env vars set, the `compliance` block can be omitted entirely:
57
+
58
+ ```ts
59
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
60
+ ```
61
+
62
+ ### Reaching the raw client
63
+
64
+ For methods this wrapper does not instrument, the underlying OpenAI client is
65
+ available at `client.raw`, and `client.chat.completions` proxies every other
66
+ completions method (`retrieve`, `update`, `list`, `delete`, `parse`, `runTools`,
67
+ `stream`, …) directly to the real resource.
68
+
69
+ ### Response helpers (`.withResponse()` / `.asResponse()`)
70
+
71
+ The instrumented non-streaming `chat.completions.create` returns the SDK's
72
+ `APIPromise`, not a plain `Promise`, so the official response helpers keep
73
+ working:
74
+
75
+ ```ts
76
+ const { data, response, request_id } = await client.chat.completions.create(params).withResponse()
77
+ const raw = await client.chat.completions.create(params).asResponse()
78
+ ```
79
+
80
+ The compliance decision is logged exactly once after the response is obtained,
81
+ regardless of whether you `await` the call, use `.withResponse()`, or use
82
+ `.asResponse()`.
83
+
84
+ ## Configuration & environment variables
85
+
86
+ The `compliance` block is a `ReporterConfig` from `@vaxelia/ai-core`. Every field
87
+ is optional and falls back to a matching environment variable (12-factor):
88
+
89
+ | `compliance` field | Environment variable | Description |
90
+ | ------------------ | ------------------------ | ------------------------------------------------------------ |
91
+ | `aiSystemId` | `VAXELIA_AI_SYSTEM_ID` | Registered AI system identifier. |
92
+ | `tenantApiUrl` | `VAXELIA_TENANT_API_URL` | Full decision-log POST endpoint URL. |
93
+ | `apiKey` | `VAXELIA_API_KEY` | Bearer token for the tenant API. |
94
+ | `bufferKey` | `VAXELIA_BUFFER_KEY` | 32-byte AES-256-GCM key, base64-encoded. Required to buffer. |
95
+
96
+ For the encrypted disk buffer, the fail-closed buffer-key rule, retention, and
97
+ backoff behaviour, see the [`@vaxelia/ai-core` README](../sdk-ai-core/README.md).
98
+ Note: when buffering is enabled (the default) and no valid 32-byte buffer key is
99
+ resolvable, **construction throws** (`MissingBufferKeyError` /
100
+ `InvalidBufferKeyError`) — this wrapper surfaces that error rather than swallow
101
+ it. To run without a disk buffer, set `compliance.bufferingEnabled: false`.
102
+
103
+ ## What gets logged
104
+
105
+ On each instrumented `chat.completions.create` call, one decision envelope is
106
+ reported:
107
+
108
+ | Field | Source |
109
+ | ------------ | ---------------------------------------------------------------------------- |
110
+ | `aiSystemId` | `compliance.aiSystemId` / `VAXELIA_AI_SYSTEM_ID` |
111
+ | `modelUsed` | the response's `model`, falling back to the request `model` |
112
+ | `input` | the `chat.completions.create` request params |
113
+ | `output` | the provider response (or, on failure, `{ error }` — no raw error/secrets) |
114
+ | `status` | `completed` on success, `failed` when the provider call throws |
115
+ | `decidedAt` | ISO-8601 timestamp at the moment of the call |
116
+
117
+ ## Reporter-error ordering (no silent drops)
118
+
119
+ The provider response is always obtained **first**; only then is the decision
120
+ logged. If `@vaxelia/ai-core` cannot deliver the decision *and* cannot durably
121
+ buffer it, it throws (`DecisionNotRecordedError`) — and this wrapper lets that
122
+ error surface to your caller. This is deliberate: the whole point of the
123
+ compliance layer is that a recorded decision is never silently dropped. If you
124
+ prefer delivery failures to be buffered instead of thrown, keep buffering enabled
125
+ (the default) with a valid buffer key — transient failures (network, 5xx, 429)
126
+ are then retried with backoff and buffered to encrypted disk for later flush.
127
+
128
+ On a **failed** provider call, the `failed` decision is reported first and then
129
+ the original provider error is rethrown unchanged.
130
+
131
+ ## Scope & limitations
132
+
133
+ - **Streaming is not instrumented in v1.** A call with `stream: true` passes
134
+ straight through to the real client uninstrumented, so streaming usage never
135
+ breaks — but no decision is logged for it yet. Streaming compliance capture is
136
+ a planned enhancement.
137
+ - Only `chat.completions.create` is instrumented. All other SDK methods and
138
+ resources (`responses`, `embeddings`, `files`, …) delegate to the real client;
139
+ reach `client.raw` for the underlying object when needed.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ pnpm --filter @vaxelia/ai-openai test # vitest
145
+ pnpm --filter @vaxelia/ai-openai build # tsc -> dist
146
+ pnpm --filter @vaxelia/ai-openai lint # eslint
147
+ ```
148
+
149
+ See [CLAUDE.md](./CLAUDE.md) for AI-specific conventions.
@@ -0,0 +1,37 @@
1
+ import type OpenAISDK from 'openai';
2
+ import { type OpenAIComplianceOptions } from './internal';
3
+ export type { OpenAIComplianceOptions } from './internal';
4
+ /**
5
+ * Drop-in, compliance-instrumented replacement for the official OpenAI client.
6
+ * Swap `import OpenAI from 'openai'` for `import OpenAI from '@vaxelia/ai-openai'`
7
+ * and keep the rest of your code: every non-streaming `chat.completions.create`
8
+ * call is logged to your tenant's decision-log endpoint via `@vaxelia/ai-core`
9
+ * (with the resilient encrypted disk buffer), and all other SDK methods pass
10
+ * through to the real client untouched.
11
+ *
12
+ * Streaming calls (`stream: true`) currently pass through uninstrumented —
13
+ * streaming compliance capture is a planned enhancement (see README).
14
+ *
15
+ * Reporter-error ordering: the provider response is obtained first; only then is
16
+ * the decision logged. If logging cannot deliver *and* cannot buffer the
17
+ * decision, `@vaxelia/ai-core` throws (hard-fail, not silent drop) and that
18
+ * error surfaces to the caller — deliberately, so a compliance decision is never
19
+ * silently lost.
20
+ */
21
+ export declare class OpenAI {
22
+ #private;
23
+ constructor(options?: OpenAIComplianceOptions);
24
+ /**
25
+ * The instrumented `chat` resource. Mirrors the real client's `chat` surface;
26
+ * `chat.completions.create` is wrapped for compliance logging while every
27
+ * other method delegates to the underlying client.
28
+ */
29
+ readonly chat: OpenAISDK['chat'];
30
+ /**
31
+ * The underlying, un-instrumented OpenAI client. Reach for it when you need a
32
+ * method this wrapper does not yet instrument and want the raw provider object
33
+ * (e.g. advanced streaming helpers, the responses or embeddings resources).
34
+ */
35
+ get raw(): OpenAISDK;
36
+ }
37
+ export default OpenAI;
package/dist/index.js ADDED
@@ -0,0 +1,206 @@
1
+ import { ComplianceReporter } from '@vaxelia/ai-core';
2
+ import { RealOpenAI, } from './internal';
3
+ /**
4
+ * Render a thrown provider error as a compliance-safe envelope. We never embed
5
+ * the raw `Error` (it may carry headers, request bodies, or credentials in
6
+ * provider SDK error subclasses) — only the human-readable message.
7
+ */
8
+ function errorOutput(err) {
9
+ if (err instanceof Error)
10
+ return { error: err.message };
11
+ return { error: String(err) };
12
+ }
13
+ /** A request is streaming when `stream: true` is set on the params. */
14
+ function isStreaming(params) {
15
+ return (typeof params === 'object' &&
16
+ params !== null &&
17
+ params.stream === true);
18
+ }
19
+ /**
20
+ * Drop-in, compliance-instrumented replacement for the official OpenAI client.
21
+ * Swap `import OpenAI from 'openai'` for `import OpenAI from '@vaxelia/ai-openai'`
22
+ * and keep the rest of your code: every non-streaming `chat.completions.create`
23
+ * call is logged to your tenant's decision-log endpoint via `@vaxelia/ai-core`
24
+ * (with the resilient encrypted disk buffer), and all other SDK methods pass
25
+ * through to the real client untouched.
26
+ *
27
+ * Streaming calls (`stream: true`) currently pass through uninstrumented —
28
+ * streaming compliance capture is a planned enhancement (see README).
29
+ *
30
+ * Reporter-error ordering: the provider response is obtained first; only then is
31
+ * the decision logged. If logging cannot deliver *and* cannot buffer the
32
+ * decision, `@vaxelia/ai-core` throws (hard-fail, not silent drop) and that
33
+ * error surfaces to the caller — deliberately, so a compliance decision is never
34
+ * silently lost.
35
+ */
36
+ export class OpenAI {
37
+ #client;
38
+ #reporter;
39
+ constructor(options = {}) {
40
+ const { compliance, __clientFactory, ...clientOptions } = options;
41
+ // Construct the reporter first so fail-closed buffer-key validation runs at
42
+ // construction time (matching the bare @vaxelia/ai-core contract).
43
+ this.#reporter = new ComplianceReporter(compliance ?? {});
44
+ const factory = __clientFactory ?? ((opts) => new RealOpenAI(opts));
45
+ this.#client = factory(clientOptions);
46
+ // Build an instrumented `chat` facade. The completion path is nested
47
+ // (`chat` -> `completions` -> `create`), so we proxy `chat` to override only
48
+ // `completions`, and proxy `completions` to override only `create`. Every
49
+ // other property of `chat`/`completions` delegates to the real resource so
50
+ // the rest of the SDK surface (`completions.list`, `chat.completions.parse`,
51
+ // `runTools`, `stream`, ...) stays intact.
52
+ const realChat = this.#client.chat;
53
+ const instrumentedCompletions = this.#buildInstrumentedCompletions(realChat.completions);
54
+ this.chat = new Proxy(realChat, {
55
+ get: (target, prop, receiver) => {
56
+ if (prop === 'completions')
57
+ return instrumentedCompletions;
58
+ const value = Reflect.get(target, prop, receiver);
59
+ return typeof value === 'function' ? value.bind(target) : value;
60
+ },
61
+ });
62
+ }
63
+ /**
64
+ * The instrumented `chat` resource. Mirrors the real client's `chat` surface;
65
+ * `chat.completions.create` is wrapped for compliance logging while every
66
+ * other method delegates to the underlying client.
67
+ */
68
+ chat;
69
+ /**
70
+ * The underlying, un-instrumented OpenAI client. Reach for it when you need a
71
+ * method this wrapper does not yet instrument and want the raw provider object
72
+ * (e.g. advanced streaming helpers, the responses or embeddings resources).
73
+ */
74
+ get raw() {
75
+ return this.#client;
76
+ }
77
+ /**
78
+ * Build the instrumented `completions` resource: a Proxy over the real
79
+ * resource that overrides only `create` and delegates everything else
80
+ * (`retrieve`, `update`, `list`, `delete`, `parse`, `runTools`, `stream`) with
81
+ * `this` bound to the real resource.
82
+ */
83
+ #buildInstrumentedCompletions(realCompletions) {
84
+ const instrumentedCreate = this.#instrumentedCreate.bind(this);
85
+ return new Proxy(realCompletions, {
86
+ get(target, prop, receiver) {
87
+ if (prop === 'create')
88
+ return instrumentedCreate;
89
+ const value = Reflect.get(target, prop, receiver);
90
+ return typeof value === 'function' ? value.bind(target) : value;
91
+ },
92
+ });
93
+ }
94
+ /**
95
+ * Instrumented `chat.completions.create`. Streaming calls pass through
96
+ * unchanged (preserving the SDK return). Non-streaming calls return an
97
+ * `APIPromise` wrapper that fires the compliance `logDecision` (after the
98
+ * response is obtained) on whichever consumption path the caller uses —
99
+ * `await`, `.withResponse()`, or `.asResponse()` — so those helpers keep
100
+ * working and the declared `chat` type is honoured (no plain-Promise type lie).
101
+ * Reporter ordering and hard-fail surfacing match the failure path.
102
+ */
103
+ #instrumentedCreate(params, requestOptions) {
104
+ const realCreate = this.#client.chat.completions.create.bind(this.#client.chat.completions);
105
+ // Streaming is out of scope for v1 compliance capture; pass it through so the
106
+ // wrapper never breaks streaming usage. (Documented limitation.)
107
+ if (isStreaming(params)) {
108
+ return realCreate(params, requestOptions);
109
+ }
110
+ const apiPromise = realCreate(params, requestOptions);
111
+ return this.#instrumentNonStreaming(apiPromise, params);
112
+ }
113
+ /**
114
+ * Wrap a non-streaming `APIPromise` so compliance logging fires once after the
115
+ * response is obtained, regardless of how the caller consumes it, while the
116
+ * `APIPromise`'s `.withResponse()`/`.asResponse()` helpers (and its `Promise`
117
+ * surface) are preserved. We override only the public consumption methods via a
118
+ * `Proxy`; the wrapper still passes `instanceof APIPromise`/`Promise`.
119
+ */
120
+ #instrumentNonStreaming(apiPromise, params) {
121
+ // The provider response has been obtained when this resolves; log the
122
+ // decision exactly once and let any reporter hard-fail surface here. Reuse
123
+ // `withResponse()` so a single underlying parse feeds every consumption path
124
+ // (the SDK memoises the parse), and so `.asResponse()` can log too.
125
+ let logged;
126
+ const reportOnce = () => {
127
+ if (logged === undefined) {
128
+ logged = apiPromise.withResponse().then(async (withResponse) => {
129
+ await this.#logCompleted(withResponse.data, params);
130
+ return withResponse;
131
+ }, async (err) => {
132
+ await this.#logFailed(err, params);
133
+ throw err;
134
+ });
135
+ }
136
+ return logged;
137
+ };
138
+ return new Proxy(apiPromise, {
139
+ get: (target, prop, receiver) => {
140
+ switch (prop) {
141
+ case 'then':
142
+ return (onfulfilled, onrejected) => reportOnce()
143
+ .then((withResponse) => withResponse.data)
144
+ .then(onfulfilled, onrejected);
145
+ case 'catch':
146
+ return (onrejected) => reportOnce()
147
+ .then((withResponse) => withResponse.data)
148
+ .catch(onrejected);
149
+ case 'finally':
150
+ return (onfinally) => reportOnce()
151
+ .then((withResponse) => withResponse.data)
152
+ .finally(onfinally);
153
+ case 'withResponse':
154
+ return () => reportOnce();
155
+ case 'asResponse':
156
+ return () => reportOnce().then((withResponse) => withResponse.response);
157
+ default: {
158
+ const value = Reflect.get(target, prop, receiver);
159
+ return typeof value === 'function' ? value.bind(target) : value;
160
+ }
161
+ }
162
+ },
163
+ });
164
+ }
165
+ /**
166
+ * Log a `completed` decision. If the reporter cannot deliver and cannot buffer,
167
+ * it throws — and we let that surface (after the response was obtained), never
168
+ * silently dropping the decision.
169
+ */
170
+ async #logCompleted(response, params) {
171
+ await this.#reporter.logDecision({
172
+ aiSystemId: '',
173
+ modelUsed: this.#responseModel(response, params),
174
+ input: params,
175
+ output: response,
176
+ status: 'completed',
177
+ decidedAt: new Date().toISOString(),
178
+ });
179
+ }
180
+ /**
181
+ * Log a `failed` decision for a provider error. The caller still receives the
182
+ * ORIGINAL provider error (rethrown by the consumption path), and the error is
183
+ * captured as `{ error }` only — never the raw `Error` (it may carry secrets).
184
+ */
185
+ async #logFailed(err, params) {
186
+ await this.#reporter.logDecision({
187
+ aiSystemId: '',
188
+ modelUsed: this.#requestModel(params),
189
+ input: params,
190
+ output: errorOutput(err),
191
+ status: 'failed',
192
+ decidedAt: new Date().toISOString(),
193
+ });
194
+ }
195
+ #responseModel(response, params) {
196
+ const fromResponse = response?.model;
197
+ if (typeof fromResponse === 'string' && fromResponse !== '')
198
+ return fromResponse;
199
+ return this.#requestModel(params);
200
+ }
201
+ #requestModel(params) {
202
+ const fromRequest = params?.model;
203
+ return typeof fromRequest === 'string' ? fromRequest : '';
204
+ }
205
+ }
206
+ export default OpenAI;
@@ -0,0 +1,40 @@
1
+ import OpenAISDK from 'openai';
2
+ import type { ClientOptions } from 'openai';
3
+ import type { ReporterConfig } from '@vaxelia/ai-core';
4
+ /**
5
+ * The subset of the underlying OpenAI client surface this wrapper needs to
6
+ * reach directly: the nested `chat.completions.create` completion path. The full
7
+ * client exposes far more; the wrapper preserves the rest by proxying the real
8
+ * resources (see {@link OpenAI}). This type exists so tests can inject a fake
9
+ * without depending on the entire SDK shape.
10
+ */
11
+ export interface FakeOpenAIClient {
12
+ chat: {
13
+ completions: {
14
+ create: (params: unknown, options?: unknown) => Promise<unknown>;
15
+ };
16
+ };
17
+ }
18
+ /**
19
+ * Options accepted by the compliance-instrumented {@link OpenAI} client: every
20
+ * option the official SDK accepts, plus an optional `compliance` block that
21
+ * configures the shared {@link ComplianceReporter}. The `compliance` block is
22
+ * stripped before the remaining options are handed to the real SDK, so this is a
23
+ * true drop-in replacement.
24
+ */
25
+ export interface OpenAIComplianceOptions extends ClientOptions {
26
+ /**
27
+ * Compliance reporter configuration. Every field falls back to the matching
28
+ * environment variable (see `@vaxelia/ai-core`), so zero-config-from-env
29
+ * works. Omit entirely to configure the reporter purely from the environment.
30
+ */
31
+ compliance?: ReporterConfig;
32
+ /**
33
+ * Test-only seam: supply the underlying OpenAI client instead of letting the
34
+ * wrapper construct one. Never used in production; not part of the public
35
+ * contract. Underscore-prefixed to signal "internal".
36
+ */
37
+ __clientFactory?: (options: ClientOptions) => unknown;
38
+ }
39
+ /** The concrete underlying-client constructor, narrowed for reuse. */
40
+ export declare const RealOpenAI: typeof OpenAISDK;
@@ -0,0 +1,3 @@
1
+ import OpenAISDK from 'openai';
2
+ /** The concrete underlying-client constructor, narrowed for reuse. */
3
+ export const RealOpenAI = OpenAISDK;
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@vaxelia/ai-openai",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in, compliance-instrumented wrapper around the official OpenAI SDK (openai). Logs every AI decision via @vaxelia/ai-core.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "openai": "^6.39.1",
26
+ "@vaxelia/ai-core": "^0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.1.0",
30
+ "eslint": "^9.32.0",
31
+ "typescript": "^5.3.0",
32
+ "typescript-eslint": "^8.59.4",
33
+ "vitest": "^1.6.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "lint": "eslint src test --ext .ts"
43
+ }
44
+ }