@wtfalch/ai 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @wtfalch/ai
2
+
3
+ Public client SDK and API types for the AI service. Requires Node 22+ or a
4
+ browser with Fetch and AbortSignal.timeout support. The client has no runtime
5
+ package dependencies. It is an ES module package; use `import` in Node.js.
6
+ The optional `@wtfalch/ai/webhooks` entrypoint requires Node.js.
7
+
8
+ ```sh
9
+ npm install @wtfalch/ai
10
+ ```
11
+
12
+ ```ts
13
+ import { createAiClient, ApiError } from '@wtfalch/ai';
14
+
15
+ const ai = createAiClient({
16
+ organisationId: 'your-organisation-id',
17
+ url: serviceOrigin,
18
+ credential: () => serviceKey,
19
+ });
20
+
21
+ const run = await ai.submit({
22
+ source: { mode: 'offering', id: offeringId },
23
+ input: { prompt: 'Hello', maxOutputTokens: 256 },
24
+ requestId: crypto.randomUUID(),
25
+ });
26
+ const status = await ai.get(run.id);
27
+ ```
28
+
29
+ Use the deployed service's HTTPS origin and a scoped service credential. The
30
+ credential callback runs for every request, so rotation does not require a new
31
+ client. The client refuses redirects and omits browser cookies. An HTTP origin
32
+ is accepted only for localhost development. Do not embed a privileged service
33
+ credential in public browser code.
34
+
35
+ Requests require current service permissions and configured budgets. A retry
36
+ of the same operation should retain its request ID. API failures throw
37
+ `ApiError` with `status` and `code`; the SDK does not expose raw upstream error
38
+ bodies. A 202 AI response means queued, not completed.
39
+
40
+ `submit(input, { wait })` waits up to `wait` seconds (integer, 1-60) for the
41
+ run to finish before responding: HTTP 200 with the finished `RunView` if it
42
+ settles in time, HTTP 202 with the run's current state (usually still
43
+ `queued`) if `wait` elapses first. Either way the SDK returns the same
44
+ `RunView` `get` returns, so check `state` to tell the two apart. Execution
45
+ keeps going after the deadline even if the caller did not wait for it.
46
+ Without `wait`, `submit` returns as soon as the run is queued, as before.
47
+
48
+ A fal image offering (seedream-v4, seedream-v4-edit, birefnet) settles the same way and
49
+ returns its files in `output.files`:
50
+
51
+ ```ts
52
+ const run = await ai.submit(
53
+ {
54
+ source: { mode: 'offering', id: falOfferingId },
55
+ input: { prompt: 'a red cube' },
56
+ requestId: crypto.randomUUID(),
57
+ },
58
+ { wait: 60 },
59
+ );
60
+ if (run.state === 'succeeded') {
61
+ for (const file of (run.output as { files: { url: string }[] }).files) console.log(file.url);
62
+ }
63
+ ```
64
+
65
+ Each file's `url` is an https URL fal itself serves; the SDK neither downloads nor re-hosts it.
66
+
67
+ For a text model that supports streaming, `stream` returns an async iterable:
68
+
69
+ ```ts
70
+ for await (const event of ai.stream({
71
+ source: { mode: 'offering', id: textOfferingId },
72
+ input: { prompt: 'Hello', maxOutputTokens: 256 },
73
+ requestId: crypto.randomUUID(),
74
+ })) {
75
+ if (event.type === 'delta') console.log(event.text);
76
+ else console.log(event.run.state);
77
+ }
78
+ ```
79
+
80
+ Pass `{ signal: controller.signal }` as the second argument to abort a stream,
81
+ or break out of the loop to disconnect. A normal stream ends with a `done`
82
+ event carrying the final run. A disconnect may prevent receipt of that event;
83
+ use `get` when the run ID is known to check its state. Disconnecting does not
84
+ guarantee cancellation or prevent charges for work already performed. Use
85
+ `stream` instead of passing `stream: true` to `submit`, which expects JSON.
86
+
87
+ Webhook receivers can verify the `x-wtfalch-signature` header using the separate
88
+ Node.js entrypoint. Verify the exact raw request body before parsing JSON:
89
+
90
+ ```ts
91
+ import { verifyWebhookSignature } from '@wtfalch/ai/webhooks';
92
+
93
+ const rawBody = await request.text();
94
+ const valid = verifyWebhookSignature(
95
+ request.headers.get('x-wtfalch-signature') ?? '',
96
+ [webhookSecret],
97
+ rawBody,
98
+ Math.floor(Date.now() / 1000),
99
+ );
100
+ if (!valid) throw new Error('Invalid webhook signature');
101
+ const event = JSON.parse(rawBody);
102
+ ```
103
+
104
+ Timestamps are Unix seconds, with a default tolerance of 300 seconds. During
105
+ secret rotation, include the previous secret only while its overlap window is
106
+ valid. `signWebhookPayload` and `SIGNATURE_TOLERANCE_SECONDS` are also exported
107
+ from `/webhooks`. Signature verification does not deduplicate deliveries;
108
+ receivers should handle repeated events idempotently.
109
+
110
+ `listConnections`, `listRuns`, `listOfferings`, `listBudgets` and `listUsage`
111
+ each take an optional `{ after, limit }` page (limit 1–100, default 50) and
112
+ return only the rows the credential's grants allow in the selected
113
+ organisation. Connections and offerings page by id; runs and usage page
114
+ newest first, `after` being the last id seen. Budgets page by key id and
115
+ cover the current period only, with a null key id for the organisation-wide
116
+ row. On `listRuns` and `listUsage`, an `after` id the credential cannot read,
117
+ or one that does not exist, throws `ApiError` with `status: 400`.
118
+
119
+ `updateBudget({ keyId, limitMicros })` sets the organisation's cap (`keyId: null`) or one
120
+ key's, needing `ai.budgets:update` reaching whichever it targets; zero blocks spending, and
121
+ no caller can remove a cap. `exportUsage({ from, to })` returns usage as CSV text (ISO
122
+ timestamps, at most 92 days), needing an `ai.usage:export` grant that itself reaches the
123
+ organisation — an owner- or key-scoped grant throws `403`; use `listUsage` instead.
124
+ `createOffering`, `updateOffering`, `disableOffering` and `setOfferingAccess` administer
125
+ platform offerings and need `ai.offerings:create`/`:update`/`:disable`, platform-boundary
126
+ permissions no customer organisation's grants can reach. `createOffering` throws
127
+ `ApiError` with `status: 503` while the host has no provider key store configured.
128
+
129
+ The root export provides the client and public request/response types.
130
+ `@wtfalch/ai/client` is also supported. There are no server, database,
131
+ migration, provider-adapter or authorization-engine exports. The service
132
+ implementation is maintained in a private repository.
133
+
134
+ The first 0.1.0 SDK targets the new service API. Production migration from Valet
135
+ is a separate rollout; installing the SDK does not migrate accounts or files.
@@ -0,0 +1,68 @@
1
+ import { type ClientOptions } from './transport.js';
2
+ import type { BalanceReadingView, BudgetView, ConnectionView, CreateConnection, CreateOffering, OfferingAccessView, OfferingAdminView, OfferingView, RunRequest, RunStreamEvent, RunView, SetOfferingAccess, SetWebhookAddress, UpdateBudget, UpdateOffering, UsageCostView, UsageView, WebhookDeliveryView, WebhookView } from './types.js';
3
+ export { ApiError, type ClientOptions } from './transport.js';
4
+ export declare function createAiClient(options: ClientOptions): {
5
+ createConnection: (input: CreateConnection) => Promise<ConnectionView>;
6
+ getConnection: (id: string) => Promise<ConnectionView>;
7
+ rotateConnection: (id: string, input: {
8
+ revision: number;
9
+ credential: string;
10
+ }) => Promise<ConnectionView>;
11
+ disableConnection: (id: string, revision: number) => Promise<ConnectionView>;
12
+ listConnections: (page?: {
13
+ after?: string;
14
+ limit?: number;
15
+ }) => Promise<ConnectionView[]>;
16
+ submit: (input: RunRequest, options?: {
17
+ wait?: number;
18
+ }) => Promise<RunView>;
19
+ /** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
20
+ * a `delta` per piece of text the provider streamed, then exactly one `done`
21
+ * carrying the run's final view, however the stream ended. Stop iterating
22
+ * (`break`, or abort `options.signal`) to disconnect - the server still settles
23
+ * whatever was streamed before that. */
24
+ stream: (input: RunRequest, options?: {
25
+ signal?: AbortSignal;
26
+ }) => AsyncGenerator<RunStreamEvent, void, undefined>;
27
+ get: (id: string) => Promise<RunView>;
28
+ cancel: (id: string) => Promise<RunView>;
29
+ listRuns: (page?: {
30
+ after?: string;
31
+ limit?: number;
32
+ }) => Promise<RunView[]>;
33
+ listOfferings: (page?: {
34
+ after?: string;
35
+ limit?: number;
36
+ }) => Promise<OfferingView[]>;
37
+ createOffering: (input: CreateOffering) => Promise<OfferingAdminView>;
38
+ updateOffering: (id: string, input: UpdateOffering) => Promise<OfferingAdminView>;
39
+ disableOffering: (id: string, revision: number) => Promise<OfferingAdminView>;
40
+ setOfferingAccess: (id: string, input: SetOfferingAccess) => Promise<OfferingAccessView>;
41
+ listBudgets: (page?: {
42
+ after?: string;
43
+ limit?: number;
44
+ }) => Promise<BudgetView[]>;
45
+ updateBudget: (input: UpdateBudget) => Promise<BudgetView>;
46
+ listUsage: (page?: {
47
+ after?: string;
48
+ limit?: number;
49
+ }) => Promise<UsageView[]>;
50
+ listCostUsage: (page?: {
51
+ after?: string;
52
+ limit?: number;
53
+ }) => Promise<UsageCostView[]>;
54
+ exportUsage: (range: {
55
+ from: string;
56
+ to: string;
57
+ }) => Promise<string>;
58
+ listBalances: (page?: {
59
+ limit?: number;
60
+ }) => Promise<BalanceReadingView[]>;
61
+ getWebhook: () => Promise<WebhookView>;
62
+ setWebhookAddress: (input: SetWebhookAddress) => Promise<WebhookView>;
63
+ rotateWebhookSecret: (revision: number) => Promise<WebhookView>;
64
+ listWebhookDeliveries: (page?: {
65
+ after?: string;
66
+ limit?: number;
67
+ }) => Promise<WebhookDeliveryView[]>;
68
+ };
package/dist/client.js ADDED
@@ -0,0 +1,50 @@
1
+ import { clientCall, clientStream } from './transport.js';
2
+ export { ApiError } from './transport.js';
3
+ const pageQuery = (page) => new URLSearchParams({
4
+ ...(page.after ? { after: page.after } : {}),
5
+ ...(page.limit !== undefined ? { limit: String(page.limit) } : {}),
6
+ });
7
+ export function createAiClient(options) {
8
+ const call = clientCall(options);
9
+ const stream = clientStream(options);
10
+ return {
11
+ createConnection: (input) => call('POST', '/v1/connections', input),
12
+ getConnection: (id) => call('GET', `/v1/connections/${encodeURIComponent(id)}`),
13
+ rotateConnection: (id, input) => call('POST', `/v1/connections/${encodeURIComponent(id)}/rotate`, input),
14
+ disableConnection: (id, revision) => call('POST', `/v1/connections/${encodeURIComponent(id)}/disable`, {
15
+ revision,
16
+ }),
17
+ listConnections: (page = {}) => call('GET', `/v1/connections?${pageQuery(page)}`),
18
+ submit: (input, options = {}) => call('POST', options.wait === undefined
19
+ ? '/v1/runs'
20
+ : `/v1/runs?${new URLSearchParams({ wait: String(options.wait) })}`, input, 'json',
21
+ // The server may hold the request for the whole wait.
22
+ 30000 + (options.wait ?? 0) * 1000),
23
+ /** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
24
+ * a `delta` per piece of text the provider streamed, then exactly one `done`
25
+ * carrying the run's final view, however the stream ended. Stop iterating
26
+ * (`break`, or abort `options.signal`) to disconnect - the server still settles
27
+ * whatever was streamed before that. */
28
+ stream: (input, options = {}) => stream('/v1/runs', { ...input, stream: true }, options.signal),
29
+ get: (id) => call('GET', `/v1/runs/${encodeURIComponent(id)}`),
30
+ cancel: (id) => call('POST', `/v1/runs/${encodeURIComponent(id)}/cancel`),
31
+ listRuns: (page = {}) => call('GET', `/v1/runs?${pageQuery(page)}`),
32
+ listOfferings: (page = {}) => call('GET', `/v1/offerings?${pageQuery(page)}`),
33
+ createOffering: (input) => call('POST', '/v1/offerings', input),
34
+ updateOffering: (id, input) => call('PATCH', `/v1/offerings/${encodeURIComponent(id)}`, input),
35
+ disableOffering: (id, revision) => call('POST', `/v1/offerings/${encodeURIComponent(id)}/disable`, {
36
+ revision,
37
+ }),
38
+ setOfferingAccess: (id, input) => call('PUT', `/v1/offerings/${encodeURIComponent(id)}/access`, input),
39
+ listBudgets: (page = {}) => call('GET', `/v1/budgets?${pageQuery(page)}`),
40
+ updateBudget: (input) => call('PUT', '/v1/budgets', input),
41
+ listUsage: (page = {}) => call('GET', `/v1/usage?${pageQuery(page)}`),
42
+ listCostUsage: (page = {}) => call('GET', `/v1/usage/cost?${pageQuery(page)}`),
43
+ exportUsage: (range) => call('GET', `/v1/usage/export?${new URLSearchParams(range)}`, undefined, 'text'),
44
+ listBalances: (page = {}) => call('GET', `/v1/balances?${pageQuery(page)}`),
45
+ getWebhook: () => call('GET', '/v1/webhooks'),
46
+ setWebhookAddress: (input) => call('PUT', '/v1/webhooks', input),
47
+ rotateWebhookSecret: (revision) => call('POST', '/v1/webhooks/rotate', { revision }),
48
+ listWebhookDeliveries: (page = {}) => call('GET', `/v1/webhooks/deliveries?${pageQuery(page)}`),
49
+ };
50
+ }
@@ -0,0 +1,2 @@
1
+ export * from './client.js';
2
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './client.js';
@@ -0,0 +1,19 @@
1
+ import type { RunStreamEvent } from './types.js';
2
+ export interface ClientOptions {
3
+ url: string;
4
+ organisationId?: string;
5
+ credential: () => string | Promise<string>;
6
+ fetch?: typeof fetch;
7
+ }
8
+ export declare class ApiError extends Error {
9
+ readonly status: number;
10
+ readonly code: string;
11
+ constructor(status: number, code: string);
12
+ }
13
+ export declare function clientCall(options: ClientOptions): <T>(method: string, path: string, input?: unknown, format?: "json" | "text", timeoutMs?: number) => Promise<T>;
14
+ /** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
15
+ * response body and turns it into `RunStreamEvent`s, for `POST /v1/runs` with
16
+ * `stream: true`. Web-standard APIs only, so this stays reachable from the SDK's
17
+ * main entry with zero runtime dependencies (`pnpm check:package`).
18
+ */
19
+ export declare function clientStream(options: ClientOptions): (path: string, input: unknown, signal?: AbortSignal) => AsyncGenerator<RunStreamEvent, void, undefined>;
@@ -0,0 +1,140 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ code;
4
+ constructor(status, code) {
5
+ super(code);
6
+ this.status = status;
7
+ this.code = code;
8
+ }
9
+ }
10
+ function validatedBase(url) {
11
+ const base = new URL(url);
12
+ if (base.username ||
13
+ base.password ||
14
+ base.search ||
15
+ base.hash ||
16
+ base.pathname !== '/' ||
17
+ (base.protocol !== 'https:' &&
18
+ !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))))
19
+ throw new Error('Use an HTTPS service origin (HTTP is allowed only for localhost)');
20
+ return base;
21
+ }
22
+ function errorCode(value) {
23
+ return typeof value === 'object' &&
24
+ value !== null &&
25
+ 'error' in value &&
26
+ typeof value.error === 'object' &&
27
+ value.error !== null &&
28
+ 'code' in value.error &&
29
+ typeof value.error.code === 'string'
30
+ ? value.error.code
31
+ : 'request_failed';
32
+ }
33
+ export function clientCall(options) {
34
+ const base = validatedBase(options.url);
35
+ return async (method, path, input, format = 'json', timeoutMs = 30000) => {
36
+ const response = await (options.fetch ?? fetch)(new URL(path, base), {
37
+ method,
38
+ redirect: 'error',
39
+ credentials: 'omit',
40
+ headers: {
41
+ ...(options.organisationId ? { 'x-organisation-id': options.organisationId } : {}),
42
+ authorization: `Bearer ${await options.credential()}`,
43
+ ...(input === undefined ? {} : { 'content-type': 'application/json' }),
44
+ },
45
+ body: input === undefined ? undefined : JSON.stringify(input),
46
+ signal: AbortSignal.timeout(timeoutMs),
47
+ });
48
+ if (response.status === 204)
49
+ return undefined;
50
+ if (format === 'text' && response.ok)
51
+ return (await response.text());
52
+ let value;
53
+ try {
54
+ value = await response.json();
55
+ }
56
+ catch {
57
+ throw new ApiError(response.status, 'invalid_response');
58
+ }
59
+ if (!response.ok)
60
+ throw new ApiError(response.status, errorCode(value));
61
+ if (!value || typeof value !== 'object' || !('data' in value))
62
+ throw new ApiError(response.status, 'invalid_response');
63
+ return value.data;
64
+ };
65
+ }
66
+ /** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
67
+ * response body and turns it into `RunStreamEvent`s, for `POST /v1/runs` with
68
+ * `stream: true`. Web-standard APIs only, so this stays reachable from the SDK's
69
+ * main entry with zero runtime dependencies (`pnpm check:package`).
70
+ */
71
+ export function clientStream(options) {
72
+ const base = validatedBase(options.url);
73
+ return async function* (path, input, signal) {
74
+ const response = await (options.fetch ?? fetch)(new URL(path, base), {
75
+ method: 'POST',
76
+ redirect: 'error',
77
+ credentials: 'omit',
78
+ headers: {
79
+ ...(options.organisationId ? { 'x-organisation-id': options.organisationId } : {}),
80
+ authorization: `Bearer ${await options.credential()}`,
81
+ 'content-type': 'application/json',
82
+ accept: 'text/event-stream',
83
+ },
84
+ body: JSON.stringify(input),
85
+ signal,
86
+ });
87
+ if (!response.ok || !response.body) {
88
+ let value;
89
+ try {
90
+ value = await response.json();
91
+ }
92
+ catch {
93
+ throw new ApiError(response.status, 'invalid_response');
94
+ }
95
+ throw new ApiError(response.status, errorCode(value));
96
+ }
97
+ const reader = response.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buffer = '';
100
+ try {
101
+ while (true) {
102
+ const { value, done } = await reader.read();
103
+ if (done)
104
+ break;
105
+ buffer += decoder.decode(value, { stream: true });
106
+ let index;
107
+ // biome-ignore lint/suspicious/noAssignInExpressions: tight SSE frame loop
108
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
109
+ const raw = buffer.slice(0, index);
110
+ buffer = buffer.slice(index + 2);
111
+ let event = 'message';
112
+ const dataLines = [];
113
+ for (const line of raw.split('\n')) {
114
+ if (line.startsWith('event:'))
115
+ event = line.slice(6).trim();
116
+ else if (line.startsWith('data:'))
117
+ dataLines.push(line.slice(5).trim());
118
+ }
119
+ if (!dataLines.length)
120
+ continue;
121
+ const data = JSON.parse(dataLines.join('\n'));
122
+ if (event === 'delta' &&
123
+ typeof data === 'object' &&
124
+ data !== null &&
125
+ 'text' in data &&
126
+ typeof data.text === 'string')
127
+ yield { type: 'delta', text: data.text };
128
+ else if (event === 'done')
129
+ yield { type: 'done', run: data };
130
+ }
131
+ }
132
+ }
133
+ finally {
134
+ // A consumer that stops iterating early (e.g. `break`s the `for await`) drives
135
+ // this via the generator's implicit `return()`: cancelling here is what tells
136
+ // the server the client disconnected.
137
+ await reader.cancel().catch(() => { });
138
+ }
139
+ };
140
+ }
@@ -0,0 +1,195 @@
1
+ /** Public HTTP contracts. No service implementation or database types. */
2
+ export interface RunRequest {
3
+ source: {
4
+ mode: 'connection';
5
+ id: string;
6
+ model: string;
7
+ } | {
8
+ mode: 'offering';
9
+ id: string;
10
+ };
11
+ input: unknown;
12
+ requestId: string;
13
+ attributionRef?: string;
14
+ /** `POST /v1/runs` responds with server-sent events instead of one JSON body.
15
+ * Refused before anything is reserved if the resolved model cannot stream. */
16
+ stream?: boolean;
17
+ }
18
+ export interface RunView {
19
+ id: string;
20
+ state: 'queued' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'cancelled';
21
+ model: string;
22
+ output: unknown;
23
+ error: string | null;
24
+ createdAt: string;
25
+ /** A short, human-readable progress update while the run is in flight (e.g. fal's queue
26
+ * position), and when it was last observed. Null before the first update, and cleared
27
+ * back to null once the run reaches a terminal state, so a finished run never keeps
28
+ * showing a stale message. */
29
+ progress: {
30
+ message: string;
31
+ at: string;
32
+ } | null;
33
+ }
34
+ /** One event of a `stream: true` run, as `AiClient.stream` yields them: `delta` for
35
+ * each piece of text the provider produced, and exactly one terminal `done` carrying
36
+ * the same view `GET /v1/runs/:id` would show once the stream ends, however it ends
37
+ * (full completion, a disconnect that still settled, or a provider error mid-stream). */
38
+ export type RunStreamEvent = {
39
+ type: 'delta';
40
+ text: string;
41
+ } | {
42
+ type: 'done';
43
+ run: RunView;
44
+ };
45
+ export interface CreateConnection {
46
+ name: string;
47
+ provider: string;
48
+ models: string[];
49
+ credential: string;
50
+ }
51
+ export interface ConnectionView {
52
+ id: string;
53
+ name: string;
54
+ provider: string;
55
+ models: string[];
56
+ enabled: boolean;
57
+ revision: number;
58
+ }
59
+ export interface OfferingView {
60
+ id: string;
61
+ name: string;
62
+ model: string;
63
+ rateVersion: string;
64
+ rates: Record<string, {
65
+ micros: string;
66
+ perUnits: string;
67
+ }>;
68
+ /** JSON Schema for this model's input, generated from the adapter's own validation -
69
+ * never hand-written, so it cannot drift from what a run against this offering
70
+ * actually accepts or rejects. */
71
+ inputSchema: Record<string, unknown>;
72
+ }
73
+ export interface CreateOffering {
74
+ name: string;
75
+ model: string;
76
+ provider: string;
77
+ providerModel: string;
78
+ rateVersion: string;
79
+ rates: Record<string, {
80
+ micros: string;
81
+ perUnits: string;
82
+ }>;
83
+ /** What the provider charges us. NULL/omitted means the same as `rates`. Operator-only. */
84
+ upstreamRates?: Record<string, {
85
+ micros: string;
86
+ perUnits: string;
87
+ }>;
88
+ credential: string;
89
+ }
90
+ export interface UpdateOffering {
91
+ revision: number;
92
+ name?: string;
93
+ model?: string;
94
+ providerModel?: string;
95
+ rateVersion?: string;
96
+ rates?: Record<string, {
97
+ micros: string;
98
+ perUnits: string;
99
+ }>;
100
+ /** Omit to leave unchanged; null clears it back to "the same as rates". */
101
+ upstreamRates?: Record<string, {
102
+ micros: string;
103
+ perUnits: string;
104
+ }> | null;
105
+ }
106
+ export interface OfferingAdminView {
107
+ id: string;
108
+ name: string;
109
+ model: string;
110
+ provider: string;
111
+ providerModel: string;
112
+ rateVersion: string;
113
+ rates: Record<string, {
114
+ micros: string;
115
+ perUnits: string;
116
+ }>;
117
+ upstreamRates: Record<string, {
118
+ micros: string;
119
+ perUnits: string;
120
+ }> | null;
121
+ enabled: boolean;
122
+ revision: number;
123
+ }
124
+ export interface SetOfferingAccess {
125
+ organisationId: string;
126
+ enabled: boolean;
127
+ }
128
+ export interface OfferingAccessView {
129
+ offeringId: string;
130
+ organisationId: string;
131
+ enabled: boolean;
132
+ }
133
+ export interface UpdateBudget {
134
+ keyId: string | null;
135
+ limitMicros: string;
136
+ }
137
+ export interface BudgetView {
138
+ keyId: string | null;
139
+ periodStart: string;
140
+ periodEnd: string;
141
+ limitMicros: string | null;
142
+ spentMicros: string;
143
+ reservedMicros: string;
144
+ /** limit minus spent minus reserved, clamped at "0"; null when the row has no limit.
145
+ * For a key's own row this is the lower of its own remaining and its organisation's,
146
+ * since a run spends against both budgets at once. */
147
+ remainingMicros: string | null;
148
+ }
149
+ export interface UsageView {
150
+ id: string;
151
+ keyId: string | null;
152
+ amountMicros: string;
153
+ createdAt: string;
154
+ }
155
+ /** Operator-only: what a run cost us upstream, next to what it charged the customer.
156
+ * Never appears on `UsageView`, a customer's usage export, a run view or offering view. */
157
+ export interface UsageCostView {
158
+ id: string;
159
+ keyId: string | null;
160
+ amountMicros: string;
161
+ upstreamMicros: string | null;
162
+ createdAt: string;
163
+ }
164
+ /** Operator-only: what our own account holds at a provider we pay. `amountMicros` is
165
+ * set only for `kind: 'amount'`; `reason` explains an `'unknown'` (the read failed) or
166
+ * `'not_applicable'` (nothing to read, or no admin key configured) reading. Never
167
+ * appears on any customer-facing view. */
168
+ export interface BalanceReadingView {
169
+ id: string;
170
+ provider: string;
171
+ kind: 'amount' | 'unknown' | 'not_applicable';
172
+ amountMicros: string | null;
173
+ reason: string | null;
174
+ observedAt: string;
175
+ }
176
+ export interface SetWebhookAddress {
177
+ address: string;
178
+ }
179
+ export interface WebhookView {
180
+ address: string;
181
+ revision: number;
182
+ secretRotatedAt: string;
183
+ previousSecretValidUntil: string | null;
184
+ }
185
+ export interface WebhookDeliveryView {
186
+ id: string;
187
+ runId: string;
188
+ eventType: string;
189
+ status: 'pending' | 'delivered' | 'dead';
190
+ attempts: number;
191
+ lastStatusCode: number | null;
192
+ lastError: string | null;
193
+ createdAt: string;
194
+ deliveredAt: string | null;
195
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /** Valet's exact scheme (Valet `src/lib/webhooks/sign.ts:44-68`): a header of
2
+ * `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, 300-second tolerance.
3
+ * A new receiver needs nothing but this header name to verify against a fresh secret.
4
+ */
5
+ export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
6
+ export declare function signWebhookPayload(secret: string, body: string, timestamp: number): string;
7
+ /** Verifies a signature header against every currently-valid secret (the active one,
8
+ * plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
9
+ * on either secret is accepted. Each candidate is compared in constant time.
10
+ */
11
+ export declare function verifyWebhookSignature(header: string, secrets: readonly string[], body: string, now: number, toleranceSeconds?: number): boolean;
@@ -0,0 +1,30 @@
1
+ /// <reference types="node" />
2
+ import { createHmac, timingSafeEqual } from 'node:crypto';
3
+ /** Valet's exact scheme (Valet `src/lib/webhooks/sign.ts:44-68`): a header of
4
+ * `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, 300-second tolerance.
5
+ * A new receiver needs nothing but this header name to verify against a fresh secret.
6
+ */
7
+ export const SIGNATURE_TOLERANCE_SECONDS = 300;
8
+ export function signWebhookPayload(secret, body, timestamp) {
9
+ const mac = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
10
+ return `t=${timestamp},v1=${mac}`;
11
+ }
12
+ const HEADER_PATTERN = /^t=(\d+),v1=([0-9a-f]{64})$/;
13
+ /** Verifies a signature header against every currently-valid secret (the active one,
14
+ * plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
15
+ * on either secret is accepted. Each candidate is compared in constant time.
16
+ */
17
+ export function verifyWebhookSignature(header, secrets, body, now, toleranceSeconds = SIGNATURE_TOLERANCE_SECONDS) {
18
+ const match = HEADER_PATTERN.exec(header);
19
+ if (!match)
20
+ return false;
21
+ const [, timestampText, signatureHex] = match;
22
+ const timestamp = Number(timestampText);
23
+ if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > toleranceSeconds)
24
+ return false;
25
+ const provided = Buffer.from(signatureHex, 'hex');
26
+ return secrets.some((secret) => {
27
+ const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'), 'hex');
28
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
29
+ });
30
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@wtfalch/ai",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "license": "MIT",
8
+ "description": "Public client SDK and API types for the AI service.",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/wtfalch/ai.git",
12
+ "directory": "packages/sdk"
13
+ },
14
+ "files": [
15
+ "dist/index.js",
16
+ "dist/index.d.ts",
17
+ "dist/client.js",
18
+ "dist/client.d.ts",
19
+ "dist/transport.js",
20
+ "dist/transport.d.ts",
21
+ "dist/types.js",
22
+ "dist/types.d.ts",
23
+ "dist/webhook-signing.js",
24
+ "dist/webhook-signing.d.ts"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./client": {
32
+ "types": "./dist/client.d.ts",
33
+ "default": "./dist/client.js"
34
+ },
35
+ "./webhooks": {
36
+ "types": "./dist/webhook-signing.d.ts",
37
+ "default": "./dist/webhook-signing.js"
38
+ }
39
+ },
40
+ "sideEffects": false,
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "engines": {
45
+ "node": ">=22"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run",
51
+ "prepack": "pnpm build",
52
+ "prepublishOnly": "pnpm --workspace-root check:sdk"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22",
56
+ "typescript": "^5.9.0",
57
+ "vitest": "^4.1.6"
58
+ }
59
+ }