@wtfalch/ai 0.1.0 → 0.2.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 +7 -0
- package/dist/client.d.ts +16 -3
- package/dist/client.js +7 -0
- package/dist/estimate.d.ts +21 -0
- package/dist/estimate.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/transport.d.ts +11 -1
- package/dist/transport.js +75 -30
- package/dist/types.d.ts +39 -0
- package/dist/webhook-signing.d.ts +1 -1
- package/dist/webhook-signing.js +19 -8
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -45,6 +45,13 @@ settles in time, HTTP 202 with the run's current state (usually still
|
|
|
45
45
|
keeps going after the deadline even if the caller did not wait for it.
|
|
46
46
|
Without `wait`, `submit` returns as soon as the run is queued, as before.
|
|
47
47
|
|
|
48
|
+
`submitBatch(runs)` queues up to 100 runs in one request (`POST /v1/runs/batch`).
|
|
49
|
+
Each item is an ordinary queued submit with its own `requestId`, rate-limit slot
|
|
50
|
+
and budget reservation. The result array is in request order, and each item is
|
|
51
|
+
either `{ run }` or `{ error: { code, message } }`. A refused item does not affect
|
|
52
|
+
the others, and resending the same batch replays the runs already created. Batch
|
|
53
|
+
items cannot stream or `wait`, and the whole request body is capped at 256 KB.
|
|
54
|
+
|
|
48
55
|
A fal image offering (seedream-v4, seedream-v4-edit, birefnet) settles the same way and
|
|
49
56
|
returns its files in `output.files`:
|
|
50
57
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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';
|
|
2
|
+
import type { BalanceReadingView, BatchRunResult, BudgetView, ConnectionView, ConsumptionRecordView, CreateConnection, CreateOffering, OfferingAccessView, OfferingAdminView, OfferingView, RunRequest, RunStreamEvent, RunView, SetOfferingAccess, SetWebhookAddress, SetWebhookResult, UpdateBudget, UpdateOffering, UsageCostView, UsageView, WebhookDeliveryView, WebhookProvisioning, WebhookView } from './types.js';
|
|
3
3
|
export { ApiError, type ClientOptions } from './transport.js';
|
|
4
4
|
export declare function createAiClient(options: ClientOptions): {
|
|
5
5
|
createConnection: (input: CreateConnection) => Promise<ConnectionView>;
|
|
@@ -16,6 +16,11 @@ export declare function createAiClient(options: ClientOptions): {
|
|
|
16
16
|
submit: (input: RunRequest, options?: {
|
|
17
17
|
wait?: number;
|
|
18
18
|
}) => Promise<RunView>;
|
|
19
|
+
/** Submits up to 100 runs in one request. Each is an ordinary queued submit with
|
|
20
|
+
* its own `requestId`, so resending a batch replays the runs already created.
|
|
21
|
+
* Results are in request order; a refused item carries its error instead of a run.
|
|
22
|
+
* Streaming and `wait` are not available here. */
|
|
23
|
+
submitBatch: (runs: RunRequest[]) => Promise<BatchRunResult[]>;
|
|
19
24
|
/** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
|
|
20
25
|
* a `delta` per piece of text the provider streamed, then exactly one `done`
|
|
21
26
|
* carrying the run's final view, however the stream ended. Stop iterating
|
|
@@ -47,6 +52,14 @@ export declare function createAiClient(options: ClientOptions): {
|
|
|
47
52
|
after?: string;
|
|
48
53
|
limit?: number;
|
|
49
54
|
}) => Promise<UsageView[]>;
|
|
55
|
+
listConsumption: (page?: {
|
|
56
|
+
after?: string;
|
|
57
|
+
limit?: number;
|
|
58
|
+
}) => Promise<ConsumptionRecordView[]>;
|
|
59
|
+
exportConsumption: (range: {
|
|
60
|
+
from: string;
|
|
61
|
+
to: string;
|
|
62
|
+
}) => Promise<string>;
|
|
50
63
|
listCostUsage: (page?: {
|
|
51
64
|
after?: string;
|
|
52
65
|
limit?: number;
|
|
@@ -59,8 +72,8 @@ export declare function createAiClient(options: ClientOptions): {
|
|
|
59
72
|
limit?: number;
|
|
60
73
|
}) => Promise<BalanceReadingView[]>;
|
|
61
74
|
getWebhook: () => Promise<WebhookView>;
|
|
62
|
-
setWebhookAddress: (input: SetWebhookAddress) => Promise<
|
|
63
|
-
rotateWebhookSecret: (revision: number) => Promise<
|
|
75
|
+
setWebhookAddress: (input: SetWebhookAddress) => Promise<SetWebhookResult>;
|
|
76
|
+
rotateWebhookSecret: (revision: number) => Promise<WebhookProvisioning>;
|
|
64
77
|
listWebhookDeliveries: (page?: {
|
|
65
78
|
after?: string;
|
|
66
79
|
limit?: number;
|
package/dist/client.js
CHANGED
|
@@ -20,6 +20,11 @@ export function createAiClient(options) {
|
|
|
20
20
|
: `/v1/runs?${new URLSearchParams({ wait: String(options.wait) })}`, input, 'json',
|
|
21
21
|
// The server may hold the request for the whole wait.
|
|
22
22
|
30000 + (options.wait ?? 0) * 1000),
|
|
23
|
+
/** Submits up to 100 runs in one request. Each is an ordinary queued submit with
|
|
24
|
+
* its own `requestId`, so resending a batch replays the runs already created.
|
|
25
|
+
* Results are in request order; a refused item carries its error instead of a run.
|
|
26
|
+
* Streaming and `wait` are not available here. */
|
|
27
|
+
submitBatch: (runs) => call('POST', '/v1/runs/batch', { runs }),
|
|
23
28
|
/** Reads `POST /v1/runs` as server-sent events instead of one JSON body: yields
|
|
24
29
|
* a `delta` per piece of text the provider streamed, then exactly one `done`
|
|
25
30
|
* carrying the run's final view, however the stream ended. Stop iterating
|
|
@@ -39,6 +44,8 @@ export function createAiClient(options) {
|
|
|
39
44
|
listBudgets: (page = {}) => call('GET', `/v1/budgets?${pageQuery(page)}`),
|
|
40
45
|
updateBudget: (input) => call('PUT', '/v1/budgets', input),
|
|
41
46
|
listUsage: (page = {}) => call('GET', `/v1/usage?${pageQuery(page)}`),
|
|
47
|
+
listConsumption: (page = {}) => call('GET', `/v1/consumption?${pageQuery(page)}`),
|
|
48
|
+
exportConsumption: (range) => call('GET', `/v1/consumption/export?${new URLSearchParams(range)}`, undefined, 'text'),
|
|
42
49
|
listCostUsage: (page = {}) => call('GET', `/v1/usage/cost?${pageQuery(page)}`),
|
|
43
50
|
exportUsage: (range) => call('GET', `/v1/usage/export?${new URLSearchParams(range)}`, undefined, 'text'),
|
|
44
51
|
listBalances: (page = {}) => call('GET', `/v1/balances?${pageQuery(page)}`),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Local, network-free preview of spend before a run is submitted. Neither function is
|
|
2
|
+
* authoritative: the service still bounds real work by the model's own configured
|
|
3
|
+
* `maximumInputTokens`, and the real charge always comes from the provider's reported
|
|
4
|
+
* usage, settled through the service's own `costMicros`. These exist so a caller can
|
|
5
|
+
* preview spend before submitting, not to replace that settlement.
|
|
6
|
+
*/
|
|
7
|
+
/** A fast, provider-agnostic approximation - roughly 4 characters per token for English
|
|
8
|
+
* text, the same rule of thumb OpenAI and Anthropic publish for a rough estimate. It is
|
|
9
|
+
* not any provider's real tokenizer and can be off by a wide margin for other scripts,
|
|
10
|
+
* code, or unusual token boundaries. */
|
|
11
|
+
export declare function estimateTokens(text: string): number;
|
|
12
|
+
/** Estimated cost in micros for a set of token counts (or any other billed units) against
|
|
13
|
+
* an offering's or connection's own rates. Uses the exact same per-category round-up-then-
|
|
14
|
+
* sum arithmetic as the service's `costMicros` (`packages/service/src/provider.ts`), so a
|
|
15
|
+
* preview never reads as cheaper than what those same units would actually be charged.
|
|
16
|
+
* Throws on a unit with no rate, or a negative/non-integer/empty amount, exactly like the
|
|
17
|
+
* service does - never silently returns a wrong or negative estimate. */
|
|
18
|
+
export declare function estimateCostMicros(units: Record<string, string>, rates: Record<string, {
|
|
19
|
+
micros: string;
|
|
20
|
+
perUnits: string;
|
|
21
|
+
}>): string;
|
package/dist/estimate.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Local, network-free preview of spend before a run is submitted. Neither function is
|
|
2
|
+
* authoritative: the service still bounds real work by the model's own configured
|
|
3
|
+
* `maximumInputTokens`, and the real charge always comes from the provider's reported
|
|
4
|
+
* usage, settled through the service's own `costMicros`. These exist so a caller can
|
|
5
|
+
* preview spend before submitting, not to replace that settlement.
|
|
6
|
+
*/
|
|
7
|
+
/** A fast, provider-agnostic approximation - roughly 4 characters per token for English
|
|
8
|
+
* text, the same rule of thumb OpenAI and Anthropic publish for a rough estimate. It is
|
|
9
|
+
* not any provider's real tokenizer and can be off by a wide margin for other scripts,
|
|
10
|
+
* code, or unusual token boundaries. */
|
|
11
|
+
export function estimateTokens(text) {
|
|
12
|
+
return Math.ceil(text.length / 4);
|
|
13
|
+
}
|
|
14
|
+
// Same shape the service's own amountSchema requires (packages/service/src/money.ts):
|
|
15
|
+
// digits only, no sign, no decimal point, no leading zero. `BigInt('-1')` and
|
|
16
|
+
// `BigInt('1e5')` do not agree on that - the former silently parses to a negative
|
|
17
|
+
// value and the latter throws only by luck of syntax - so every amount is checked
|
|
18
|
+
// against this pattern before use rather than handed straight to `BigInt`.
|
|
19
|
+
const amountPattern = /^(0|[1-9][0-9]*)$/;
|
|
20
|
+
function amount(value, label) {
|
|
21
|
+
if (!amountPattern.test(value))
|
|
22
|
+
throw new Error(`Not a valid non-negative amount for ${label}: ${value}`);
|
|
23
|
+
return BigInt(value);
|
|
24
|
+
}
|
|
25
|
+
/** Estimated cost in micros for a set of token counts (or any other billed units) against
|
|
26
|
+
* an offering's or connection's own rates. Uses the exact same per-category round-up-then-
|
|
27
|
+
* sum arithmetic as the service's `costMicros` (`packages/service/src/provider.ts`), so a
|
|
28
|
+
* preview never reads as cheaper than what those same units would actually be charged.
|
|
29
|
+
* Throws on a unit with no rate, or a negative/non-integer/empty amount, exactly like the
|
|
30
|
+
* service does - never silently returns a wrong or negative estimate. */
|
|
31
|
+
export function estimateCostMicros(units, rates) {
|
|
32
|
+
let total = 0n;
|
|
33
|
+
for (const [unit, quantity] of Object.entries(units)) {
|
|
34
|
+
const rate = rates[unit];
|
|
35
|
+
if (rate === undefined)
|
|
36
|
+
throw new Error(`Unpriced consumption unit: ${unit}`);
|
|
37
|
+
const numerator = amount(quantity, unit) * amount(rate.micros, `${unit}.micros`);
|
|
38
|
+
const denominator = amount(rate.perUnits, `${unit}.perUnits`);
|
|
39
|
+
if (denominator === 0n)
|
|
40
|
+
throw new Error(`Rate denominator must be positive: ${unit}`);
|
|
41
|
+
// Round each category up to one micro-dollar only after multiplication, same as the
|
|
42
|
+
// service, so the two never disagree by a rounding direction.
|
|
43
|
+
total += (numerator + denominator - 1n) / denominator;
|
|
44
|
+
}
|
|
45
|
+
return total.toString();
|
|
46
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/transport.d.ts
CHANGED
|
@@ -8,7 +8,17 @@ export interface ClientOptions {
|
|
|
8
8
|
export declare class ApiError extends Error {
|
|
9
9
|
readonly status: number;
|
|
10
10
|
readonly code: string;
|
|
11
|
-
|
|
11
|
+
/** Seconds until the caller may retry, read off the response's `Retry-After`
|
|
12
|
+
* header (service/http.ts sends it only on `rate_limited`). `undefined` when
|
|
13
|
+
* absent or not a plain positive integer of at most a week - this never retries
|
|
14
|
+
* on its own. */
|
|
15
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
16
|
+
constructor(status: number, code: string,
|
|
17
|
+
/** Seconds until the caller may retry, read off the response's `Retry-After`
|
|
18
|
+
* header (service/http.ts sends it only on `rate_limited`). `undefined` when
|
|
19
|
+
* absent or not a plain positive integer of at most a week - this never retries
|
|
20
|
+
* on its own. */
|
|
21
|
+
retryAfterSeconds?: number | undefined);
|
|
12
22
|
}
|
|
13
23
|
export declare function clientCall(options: ClientOptions): <T>(method: string, path: string, input?: unknown, format?: "json" | "text", timeoutMs?: number) => Promise<T>;
|
|
14
24
|
/** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
|
package/dist/transport.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
export class ApiError extends Error {
|
|
2
2
|
status;
|
|
3
3
|
code;
|
|
4
|
-
|
|
4
|
+
retryAfterSeconds;
|
|
5
|
+
constructor(status, code,
|
|
6
|
+
/** Seconds until the caller may retry, read off the response's `Retry-After`
|
|
7
|
+
* header (service/http.ts sends it only on `rate_limited`). `undefined` when
|
|
8
|
+
* absent or not a plain positive integer of at most a week - this never retries
|
|
9
|
+
* on its own. */
|
|
10
|
+
retryAfterSeconds) {
|
|
5
11
|
super(code);
|
|
6
12
|
this.status = status;
|
|
7
13
|
this.code = code;
|
|
14
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
8
15
|
}
|
|
9
16
|
}
|
|
17
|
+
/** A week. A hint further out than this is not a delay a caller can wait for, so it
|
|
18
|
+
* is read as a broken header instead. */
|
|
19
|
+
const maxRetryAfterSeconds = 604_800;
|
|
20
|
+
/** A sane positive-integer `Retry-After` in seconds, or `undefined` for a missing,
|
|
21
|
+
* fractional, non-numeric, non-positive or implausibly distant value (never `NaN`).
|
|
22
|
+
* The header comes from whatever server the SDK is pointed at, so only plain decimal
|
|
23
|
+
* digits count: `Number` alone would read `0x2A` as 42 and twenty-five nines as 1e25. */
|
|
24
|
+
function retryAfter(headers) {
|
|
25
|
+
const raw = headers.get('retry-after');
|
|
26
|
+
if (raw === null || !/^[0-9]+$/.test(raw))
|
|
27
|
+
return undefined;
|
|
28
|
+
const seconds = Number(raw);
|
|
29
|
+
return seconds > 0 && seconds <= maxRetryAfterSeconds ? seconds : undefined;
|
|
30
|
+
}
|
|
10
31
|
function validatedBase(url) {
|
|
11
32
|
const base = new URL(url);
|
|
12
33
|
if (base.username ||
|
|
@@ -19,6 +40,17 @@ function validatedBase(url) {
|
|
|
19
40
|
throw new Error('Use an HTTPS service origin (HTTP is allowed only for localhost)');
|
|
20
41
|
return base;
|
|
21
42
|
}
|
|
43
|
+
function sleep(ms) {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
/** Bounds for the SDK's own automatic retry, independent of the much larger
|
|
47
|
+
* `maxRetryAfterSeconds` sanity bound `retryAfter` validates against: a caller who
|
|
48
|
+
* gets `ApiError` back (because the server asked for a longer wait, or attempts ran
|
|
49
|
+
* out) can still retry by hand using `retryAfterSeconds`, exactly as before this
|
|
50
|
+
* existed. This only automates the short, common rate-limited case so every consumer
|
|
51
|
+
* stops hand-rolling that loop themselves. */
|
|
52
|
+
const maxAutoRetries = 2;
|
|
53
|
+
const maxAutoRetryDelaySeconds = 5;
|
|
22
54
|
function errorCode(value) {
|
|
23
55
|
return typeof value === 'object' &&
|
|
24
56
|
value !== null &&
|
|
@@ -33,34 +65,47 @@ function errorCode(value) {
|
|
|
33
65
|
export function clientCall(options) {
|
|
34
66
|
const base = validatedBase(options.url);
|
|
35
67
|
return async (method, path, input, format = 'json', timeoutMs = 30000) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
for (let retries = 0;; retries++) {
|
|
69
|
+
const response = await (options.fetch ?? fetch)(new URL(path, base), {
|
|
70
|
+
method,
|
|
71
|
+
redirect: 'error',
|
|
72
|
+
credentials: 'omit',
|
|
73
|
+
headers: {
|
|
74
|
+
...(options.organisationId ? { 'x-organisation-id': options.organisationId } : {}),
|
|
75
|
+
authorization: `Bearer ${await options.credential()}`,
|
|
76
|
+
...(input === undefined ? {} : { 'content-type': 'application/json' }),
|
|
77
|
+
},
|
|
78
|
+
body: input === undefined ? undefined : JSON.stringify(input),
|
|
79
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
80
|
+
});
|
|
81
|
+
if (response.status === 204)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (format === 'text' && response.ok)
|
|
84
|
+
return (await response.text());
|
|
85
|
+
let value;
|
|
86
|
+
try {
|
|
87
|
+
value = await response.json();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const wait = retryAfter(response.headers);
|
|
94
|
+
// Only a server-declared, short `Retry-After` auto-retries: the request is
|
|
95
|
+
// provably unprocessed (service/http.ts only sends this header on
|
|
96
|
+
// `rate_limited`), so replaying it is always safe, regardless of whether this
|
|
97
|
+
// particular call happens to be idempotent on its own. A longer wait, or a
|
|
98
|
+
// request with no such header, surfaces immediately - the caller decides.
|
|
99
|
+
if (wait !== undefined && wait <= maxAutoRetryDelaySeconds && retries < maxAutoRetries) {
|
|
100
|
+
await sleep(wait * 1000);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
throw new ApiError(response.status, errorCode(value), wait);
|
|
104
|
+
}
|
|
105
|
+
if (!value || typeof value !== 'object' || !('data' in value))
|
|
106
|
+
throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
|
|
107
|
+
return value.data;
|
|
58
108
|
}
|
|
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
109
|
};
|
|
65
110
|
}
|
|
66
111
|
/** Reads one SSE frame stream (`event:`/`data:` lines, blank-line separated) off a
|
|
@@ -90,9 +135,9 @@ export function clientStream(options) {
|
|
|
90
135
|
value = await response.json();
|
|
91
136
|
}
|
|
92
137
|
catch {
|
|
93
|
-
throw new ApiError(response.status, 'invalid_response');
|
|
138
|
+
throw new ApiError(response.status, 'invalid_response', retryAfter(response.headers));
|
|
94
139
|
}
|
|
95
|
-
throw new ApiError(response.status, errorCode(value));
|
|
140
|
+
throw new ApiError(response.status, errorCode(value), retryAfter(response.headers));
|
|
96
141
|
}
|
|
97
142
|
const reader = response.body.getReader();
|
|
98
143
|
const decoder = new TextDecoder();
|
package/dist/types.d.ts
CHANGED
|
@@ -15,6 +15,22 @@ export interface RunRequest {
|
|
|
15
15
|
* Refused before anything is reserved if the resolved model cannot stream. */
|
|
16
16
|
stream?: boolean;
|
|
17
17
|
}
|
|
18
|
+
/** Observed consumption, independent of customer price or upstream cost. Omitted
|
|
19
|
+
* categories are unknown, not zero. A partial snapshot may contain lower bounds. */
|
|
20
|
+
export interface ConsumptionView {
|
|
21
|
+
units: Record<string, string>;
|
|
22
|
+
status: 'complete' | 'partial' | 'unavailable';
|
|
23
|
+
source: 'provider_reported' | 'adapter_derived';
|
|
24
|
+
}
|
|
25
|
+
export interface ConsumptionRecordView {
|
|
26
|
+
runId: string;
|
|
27
|
+
keyId: string | null;
|
|
28
|
+
model: string;
|
|
29
|
+
attributionRef: string | null;
|
|
30
|
+
outcome: 'succeeded' | 'failed' | 'cancelled';
|
|
31
|
+
consumption: ConsumptionView;
|
|
32
|
+
recordedAt: string;
|
|
33
|
+
}
|
|
18
34
|
export interface RunView {
|
|
19
35
|
id: string;
|
|
20
36
|
state: 'queued' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'cancelled';
|
|
@@ -30,11 +46,24 @@ export interface RunView {
|
|
|
30
46
|
message: string;
|
|
31
47
|
at: string;
|
|
32
48
|
} | null;
|
|
49
|
+
/** Null before a measured result or for historical/uninstrumented adapters. */
|
|
50
|
+
consumption: ConsumptionView | null;
|
|
33
51
|
}
|
|
34
52
|
/** One event of a `stream: true` run, as `AiClient.stream` yields them: `delta` for
|
|
35
53
|
* each piece of text the provider produced, and exactly one terminal `done` carrying
|
|
36
54
|
* the same view `GET /v1/runs/:id` would show once the stream ends, however it ends
|
|
37
55
|
* (full completion, a disconnect that still settled, or a provider error mid-stream). */
|
|
56
|
+
/** One item of `POST /v1/runs/batch`, in request order: the submitted (queued or
|
|
57
|
+
* replayed) run, or the same `{ code, message }` a single `POST /v1/runs` would have
|
|
58
|
+
* failed with. One item's error never affects the others. */
|
|
59
|
+
export type BatchRunResult = {
|
|
60
|
+
run: RunView;
|
|
61
|
+
} | {
|
|
62
|
+
error: {
|
|
63
|
+
code: string;
|
|
64
|
+
message: string;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
38
67
|
export type RunStreamEvent = {
|
|
39
68
|
type: 'delta';
|
|
40
69
|
text: string;
|
|
@@ -85,6 +114,9 @@ export interface CreateOffering {
|
|
|
85
114
|
micros: string;
|
|
86
115
|
perUnits: string;
|
|
87
116
|
}>;
|
|
117
|
+
/** Platform-only forge binding selected by the operator. The binding's
|
|
118
|
+
* endpoint, account and transport secret are never part of this request. */
|
|
119
|
+
executionBindingId?: string;
|
|
88
120
|
credential: string;
|
|
89
121
|
}
|
|
90
122
|
export interface UpdateOffering {
|
|
@@ -182,6 +214,13 @@ export interface WebhookView {
|
|
|
182
214
|
secretRotatedAt: string;
|
|
183
215
|
previousSecretValidUntil: string | null;
|
|
184
216
|
}
|
|
217
|
+
/** Only creation and rotation disclose a secret. Persist it in the receiver's
|
|
218
|
+
* secret store immediately; ordinary reads cannot recover it. */
|
|
219
|
+
export interface WebhookProvisioning {
|
|
220
|
+
webhook: WebhookView;
|
|
221
|
+
secret: string;
|
|
222
|
+
}
|
|
223
|
+
export type SetWebhookResult = WebhookProvisioning | WebhookView;
|
|
185
224
|
export interface WebhookDeliveryView {
|
|
186
225
|
id: string;
|
|
187
226
|
runId: string;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* A new receiver needs nothing but this header name to verify against a fresh secret.
|
|
4
4
|
*/
|
|
5
5
|
export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
6
|
-
export declare function signWebhookPayload(secret: string, body: string, timestamp: number): string;
|
|
6
|
+
export declare function signWebhookPayload(secret: string | readonly string[], body: string, timestamp: number): string;
|
|
7
7
|
/** Verifies a signature header against every currently-valid secret (the active one,
|
|
8
8
|
* plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
|
|
9
9
|
* on either secret is accepted. Each candidate is compared in constant time.
|
package/dist/webhook-signing.js
CHANGED
|
@@ -6,25 +6,36 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
|
6
6
|
*/
|
|
7
7
|
export const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
8
8
|
export function signWebhookPayload(secret, body, timestamp) {
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const secrets = typeof secret === 'string' ? [secret] : secret;
|
|
10
|
+
if (secrets.length === 0)
|
|
11
|
+
throw new Error('At least one signing secret is required');
|
|
12
|
+
const signatures = secrets.map((key) => `v1=${createHmac('sha256', key).update(`${timestamp}.${body}`).digest('hex')}`);
|
|
13
|
+
return `t=${timestamp},${signatures.join(',')}`;
|
|
11
14
|
}
|
|
12
|
-
|
|
15
|
+
// Require one timestamp and well-formed signatures, preserving repeated v1 keys.
|
|
16
|
+
const HEADER_PATTERN = /^t=(\d+)((?:,v1=[0-9a-f]{64})+)$/;
|
|
13
17
|
/** Verifies a signature header against every currently-valid secret (the active one,
|
|
14
18
|
* plus a rotated-out one still inside its overlap window), so a receiver mid-deploy
|
|
15
19
|
* on either secret is accepted. Each candidate is compared in constant time.
|
|
16
20
|
*/
|
|
17
21
|
export function verifyWebhookSignature(header, secrets, body, now, toleranceSeconds = SIGNATURE_TOLERANCE_SECONDS) {
|
|
18
22
|
const match = HEADER_PATTERN.exec(header);
|
|
19
|
-
if (!match)
|
|
23
|
+
if (!match || match[0] !== header)
|
|
20
24
|
return false;
|
|
21
|
-
const [, timestampText,
|
|
25
|
+
const [, timestampText, signatures] = match;
|
|
22
26
|
const timestamp = Number(timestampText);
|
|
23
|
-
if (!Number.isSafeInteger(timestamp) ||
|
|
27
|
+
if (!Number.isSafeInteger(timestamp) ||
|
|
28
|
+
!Number.isFinite(now) ||
|
|
29
|
+
!Number.isFinite(toleranceSeconds) ||
|
|
30
|
+
toleranceSeconds < 0 ||
|
|
31
|
+
Math.abs(now - timestamp) > toleranceSeconds)
|
|
24
32
|
return false;
|
|
25
|
-
const provided =
|
|
33
|
+
const provided = signatures
|
|
34
|
+
.slice(1)
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((part) => Buffer.from(part.slice(3), 'hex'));
|
|
26
37
|
return secrets.some((secret) => {
|
|
27
38
|
const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'), 'hex');
|
|
28
|
-
return provided.
|
|
39
|
+
return provided.some((candidate) => timingSafeEqual(candidate, expected));
|
|
29
40
|
});
|
|
30
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"dist/index.d.ts",
|
|
17
17
|
"dist/client.js",
|
|
18
18
|
"dist/client.d.ts",
|
|
19
|
+
"dist/estimate.js",
|
|
20
|
+
"dist/estimate.d.ts",
|
|
19
21
|
"dist/transport.js",
|
|
20
22
|
"dist/transport.d.ts",
|
|
21
23
|
"dist/types.js",
|