@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 +21 -0
- package/README.md +135 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/transport.d.ts +19 -0
- package/dist/transport.js +140 -0
- package/dist/types.d.ts +195 -0
- package/dist/types.js +1 -0
- package/dist/webhook-signing.d.ts +11 -0
- package/dist/webhook-signing.js +30 -0
- package/package.json +59 -0
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|