@runappsai/sdk 0.0.0-stage → 0.3.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 +274 -2
- package/dist/activity-tracker.d.ts +113 -0
- package/dist/activity-tracker.d.ts.map +1 -0
- package/dist/activity-tracker.js +176 -0
- package/dist/activity-tracker.js.map +1 -0
- package/dist/audio.d.ts +129 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +267 -0
- package/dist/audio.js.map +1 -0
- package/dist/browser-auth.d.ts +179 -0
- package/dist/browser-auth.d.ts.map +1 -0
- package/dist/browser-auth.js +1361 -0
- package/dist/browser-auth.js.map +1 -0
- package/dist/chat.d.ts +221 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +155 -0
- package/dist/chat.js.map +1 -0
- package/dist/client.d.ts +155 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +157 -0
- package/dist/client.js.map +1 -0
- package/dist/computer.d.ts +57 -0
- package/dist/computer.d.ts.map +1 -0
- package/dist/computer.js +21 -0
- package/dist/computer.js.map +1 -0
- package/dist/embeddings.d.ts +64 -0
- package/dist/embeddings.d.ts.map +1 -0
- package/dist/embeddings.js +30 -0
- package/dist/embeddings.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-wrap.d.ts +59 -0
- package/dist/event-wrap.d.ts.map +1 -0
- package/dist/event-wrap.js +142 -0
- package/dist/event-wrap.js.map +1 -0
- package/dist/events.d.ts +119 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +87 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +218 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +212 -0
- package/dist/files.js.map +1 -0
- package/dist/image.d.ts +185 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +148 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/media.d.ts +56 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +133 -0
- package/dist/media.js.map +1 -0
- package/dist/model_options.d.ts +165 -0
- package/dist/model_options.d.ts.map +1 -0
- package/dist/model_options.js +59 -0
- package/dist/model_options.js.map +1 -0
- package/dist/models.d.ts +88 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +55 -0
- package/dist/models.js.map +1 -0
- package/dist/sdk.umd.js +4 -0
- package/dist/sdk.umd.js.map +1 -0
- package/dist/transport.d.ts +120 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +214 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +16 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +329 -0
- package/dist/validate.js.map +1 -0
- package/dist/video.d.ts +134 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +60 -0
- package/dist/video.js.map +1 -0
- package/package.json +54 -4
- package/src/activity-tracker.ts +251 -0
- package/src/audio.ts +440 -0
- package/src/browser-auth.ts +1529 -0
- package/src/chat.ts +368 -0
- package/src/client.ts +250 -0
- package/src/computer.ts +80 -0
- package/src/embeddings.ts +97 -0
- package/src/errors.ts +29 -0
- package/src/event-wrap.ts +168 -0
- package/src/events.ts +172 -0
- package/src/files.ts +347 -0
- package/src/image.ts +339 -0
- package/src/index.ts +151 -0
- package/src/media.ts +147 -0
- package/src/model_options.ts +220 -0
- package/src/models.ts +121 -0
- package/src/transport.ts +292 -0
- package/src/types.ts +31 -0
- package/src/validate.ts +364 -0
- package/src/video.ts +215 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { APIError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal — shared HTTP transport used by every service. Not part of the
|
|
5
|
+
* public API; do not import from outside `src/`.
|
|
6
|
+
*
|
|
7
|
+
* Uses the global `fetch` (Node 18+ / browsers / Deno / Bun all ship it).
|
|
8
|
+
* Errors from the gateway (status >= 400) are parsed and surfaced as
|
|
9
|
+
* `APIError`. Network errors propagate as the underlying fetch reject.
|
|
10
|
+
*/
|
|
11
|
+
export interface TransportOptions {
|
|
12
|
+
baseURL: string;
|
|
13
|
+
/** Static bearer token. Required unless `apiKeyResolver` is supplied. */
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
/** Dynamic resolver — called immediately before each request. Use this
|
|
16
|
+
* when the token is short-lived and refreshed externally (e.g. running
|
|
17
|
+
* inside a runjobs resource bundle that exposes
|
|
18
|
+
* `window.runjobs.getToken()`). The result is awaited per request, so
|
|
19
|
+
* the caller can return a Promise that hits a token endpoint. Wins
|
|
20
|
+
* over `apiKey` when both are supplied. */
|
|
21
|
+
apiKeyResolver?: () => string | Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Hook invoked on 401 responses, *before* the request is automatically
|
|
24
|
+
* retried once. Use this to invalidate any cached token so the next
|
|
25
|
+
* `apiKeyResolver` call fetches a fresh one. Throwing here cancels
|
|
26
|
+
* the retry — the original 401 propagates.
|
|
27
|
+
*
|
|
28
|
+
* Typical wiring (browser-auth):
|
|
29
|
+
* onUnauthorized: () => auth.invalidate()
|
|
30
|
+
* which clears the in-memory + persisted token without flipping the
|
|
31
|
+
* sticky "signed out" flag. The retry's resolveToken() then triggers
|
|
32
|
+
* the standard signIn() redirect-grant flow.
|
|
33
|
+
*/
|
|
34
|
+
onUnauthorized?: () => void | Promise<void>;
|
|
35
|
+
/** Optional fetch override (e.g. node-fetch with custom agent). */
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Transport {
|
|
40
|
+
readonly baseURL: string;
|
|
41
|
+
private readonly apiKey: string | undefined;
|
|
42
|
+
private readonly apiKeyResolver: (() => string | Promise<string>) | undefined;
|
|
43
|
+
private readonly onUnauthorized: (() => void | Promise<void>) | undefined;
|
|
44
|
+
private readonly fetchImpl: typeof fetch;
|
|
45
|
+
|
|
46
|
+
constructor(opts: TransportOptions) {
|
|
47
|
+
if (!opts.apiKey && !opts.apiKeyResolver) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"RunApps: pass either `apiKey` or `apiKeyResolver` when constructing the client",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
this.baseURL = opts.baseURL.replace(/\/+$/, "");
|
|
53
|
+
this.apiKey = opts.apiKey;
|
|
54
|
+
this.apiKeyResolver = opts.apiKeyResolver;
|
|
55
|
+
this.onUnauthorized = opts.onUnauthorized;
|
|
56
|
+
// Bind to globalThis when using the platform's native fetch.
|
|
57
|
+
// Without binding, `this.fetchImpl(...)` invokes with `this`
|
|
58
|
+
// pointing at the Transport instance, and the browser's
|
|
59
|
+
// `Window.fetch` rejects that with "Illegal invocation".
|
|
60
|
+
// Caller-supplied `fetchImpl` is used as-is — they're responsible
|
|
61
|
+
// for any binding their wrapper needs.
|
|
62
|
+
this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve the bearer token for the next request. */
|
|
66
|
+
private async resolveToken(): Promise<string> {
|
|
67
|
+
if (this.apiKeyResolver) return await this.apiKeyResolver();
|
|
68
|
+
return this.apiKey ?? "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Wrap a fetch call with single-attempt 401 retry. `build` produces a
|
|
73
|
+
* fresh RequestInit each call (so headers can re-resolve a fresh token,
|
|
74
|
+
* and bodies can be re-streamed for retry). On 401, invokes
|
|
75
|
+
* `onUnauthorized()` (giving the caller a chance to invalidate cached
|
|
76
|
+
* auth state) then retries once with a fresh init. No retry if
|
|
77
|
+
* `onUnauthorized` is absent or already retried.
|
|
78
|
+
*/
|
|
79
|
+
private async fetchWithAuthRetry(
|
|
80
|
+
path: string,
|
|
81
|
+
build: () => Promise<RequestInit>,
|
|
82
|
+
): Promise<Response> {
|
|
83
|
+
const url = this.baseURL + path;
|
|
84
|
+
let resp = await this.fetchImpl(url, await this.buildInit(build));
|
|
85
|
+
if (resp.status !== 401 || !this.onUnauthorized) return resp;
|
|
86
|
+
try {
|
|
87
|
+
await this.onUnauthorized();
|
|
88
|
+
} catch {
|
|
89
|
+
// Hook bailed out — surface the original 401.
|
|
90
|
+
return resp;
|
|
91
|
+
}
|
|
92
|
+
resp = await this.fetchImpl(url, await this.buildInit(build));
|
|
93
|
+
return resp;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every request goes out uncacheable.
|
|
98
|
+
*
|
|
99
|
+
* Our identity lives in the Authorization header, never in the URL — so
|
|
100
|
+
* two different apps calling e.g. `/v1/files/projects/index.json` hit the
|
|
101
|
+
* *same* URL with different tokens. The browser's HTTP cache doesn't key
|
|
102
|
+
* on request headers, and it is partitioned by the top-level registrable
|
|
103
|
+
* domain, which every `*.runapps.dev` bundle shares. One cacheable
|
|
104
|
+
* response was therefore enough for app A to read app B's file and then
|
|
105
|
+
* save it back as its own. The server no longer marks these cacheable;
|
|
106
|
+
* this is the client-side half of that fix, and it also protects bundles
|
|
107
|
+
* pinned to an older backend.
|
|
108
|
+
*/
|
|
109
|
+
private async buildInit(build: () => Promise<RequestInit>): Promise<RequestInit> {
|
|
110
|
+
const init = await build();
|
|
111
|
+
return init.cache ? init : { ...init, cache: "no-store" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** POST JSON body; parse JSON response. */
|
|
115
|
+
async postJSON<T>(
|
|
116
|
+
path: string,
|
|
117
|
+
body: unknown,
|
|
118
|
+
init?: { signal?: AbortSignal },
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: await this.jsonHeaders(),
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
signal: init?.signal,
|
|
125
|
+
}));
|
|
126
|
+
if (!resp.ok) {
|
|
127
|
+
throw await this.parseError(resp);
|
|
128
|
+
}
|
|
129
|
+
return (await resp.json()) as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** GET path; parse JSON response. */
|
|
133
|
+
async getJSON<T>(
|
|
134
|
+
path: string,
|
|
135
|
+
init?: { signal?: AbortSignal },
|
|
136
|
+
): Promise<T> {
|
|
137
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
138
|
+
method: "GET",
|
|
139
|
+
headers: await this.authHeaders(),
|
|
140
|
+
signal: init?.signal,
|
|
141
|
+
}));
|
|
142
|
+
if (!resp.ok) {
|
|
143
|
+
throw await this.parseError(resp);
|
|
144
|
+
}
|
|
145
|
+
return (await resp.json()) as T;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** GET path; return raw bytes + content-type (for video/audio downloads)
|
|
149
|
+
* and the ETag of those bytes, when the server sends one. */
|
|
150
|
+
async getRaw(
|
|
151
|
+
path: string,
|
|
152
|
+
init?: { signal?: AbortSignal },
|
|
153
|
+
): Promise<{ data: Uint8Array; contentType: string; etag?: string }> {
|
|
154
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
155
|
+
method: "GET",
|
|
156
|
+
headers: await this.authHeaders(),
|
|
157
|
+
signal: init?.signal,
|
|
158
|
+
}));
|
|
159
|
+
if (!resp.ok) {
|
|
160
|
+
throw await this.parseError(resp);
|
|
161
|
+
}
|
|
162
|
+
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
163
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
164
|
+
const etag = (resp.headers.get("etag") ?? "").replace(/"/g, "") || undefined;
|
|
165
|
+
return { data: buf, contentType, etag };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** POST a multipart/form-data body. Used by audio.transcribe and image.edit. */
|
|
169
|
+
async postMultipart<T>(
|
|
170
|
+
path: string,
|
|
171
|
+
form: FormData,
|
|
172
|
+
init?: { signal?: AbortSignal },
|
|
173
|
+
): Promise<T> {
|
|
174
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: await this.authHeaders(), // do NOT set Content-Type — fetch sets it with boundary
|
|
177
|
+
body: form,
|
|
178
|
+
signal: init?.signal,
|
|
179
|
+
}));
|
|
180
|
+
if (!resp.ok) {
|
|
181
|
+
throw await this.parseError(resp);
|
|
182
|
+
}
|
|
183
|
+
return (await resp.json()) as T;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* POST JSON body; return the raw streaming Response. Used by chat
|
|
188
|
+
* streaming. Caller is responsible for consuming the SSE body.
|
|
189
|
+
*/
|
|
190
|
+
async postJSONStream(
|
|
191
|
+
path: string,
|
|
192
|
+
body: unknown,
|
|
193
|
+
init?: { signal?: AbortSignal },
|
|
194
|
+
): Promise<Response> {
|
|
195
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
...(await this.jsonHeaders()),
|
|
199
|
+
"Accept-Encoding": "identity", // SSE lines arrive immediately, no gzip
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
signal: init?.signal,
|
|
203
|
+
}));
|
|
204
|
+
if (!resp.ok) {
|
|
205
|
+
throw await this.parseError(resp);
|
|
206
|
+
}
|
|
207
|
+
return resp;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** PUT raw bytes with caller-supplied content type and headers. */
|
|
211
|
+
async putBytes<T>(
|
|
212
|
+
path: string,
|
|
213
|
+
body: BodyInit,
|
|
214
|
+
opts?: {
|
|
215
|
+
contentType?: string;
|
|
216
|
+
headers?: Record<string, string>;
|
|
217
|
+
signal?: AbortSignal;
|
|
218
|
+
parse?: "json" | "none";
|
|
219
|
+
},
|
|
220
|
+
): Promise<T> {
|
|
221
|
+
const resp = await this.fetchWithAuthRetry(path, async () => {
|
|
222
|
+
const headers: Record<string, string> = {
|
|
223
|
+
...(await this.authHeaders()),
|
|
224
|
+
"Content-Type": opts?.contentType ?? "application/octet-stream",
|
|
225
|
+
};
|
|
226
|
+
if (opts?.headers) Object.assign(headers, opts.headers);
|
|
227
|
+
return {
|
|
228
|
+
method: "PUT",
|
|
229
|
+
headers,
|
|
230
|
+
body,
|
|
231
|
+
signal: opts?.signal,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
if (!resp.ok) throw await this.parseError(resp);
|
|
235
|
+
if (opts?.parse === "none") return undefined as unknown as T;
|
|
236
|
+
return (await resp.json()) as T;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** DELETE path; parse JSON response (or no body). */
|
|
240
|
+
async deletePath<T>(
|
|
241
|
+
path: string,
|
|
242
|
+
init?: { signal?: AbortSignal; parse?: "json" | "none" },
|
|
243
|
+
): Promise<T> {
|
|
244
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
245
|
+
method: "DELETE",
|
|
246
|
+
headers: await this.authHeaders(),
|
|
247
|
+
signal: init?.signal,
|
|
248
|
+
}));
|
|
249
|
+
if (!resp.ok) throw await this.parseError(resp);
|
|
250
|
+
if (init?.parse === "none") return undefined as unknown as T;
|
|
251
|
+
return (await resp.json()) as T;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** HEAD path; surface status + selected response headers. Used by
|
|
255
|
+
* exists / stat where the body would just be an opaque blob. */
|
|
256
|
+
async head(
|
|
257
|
+
path: string,
|
|
258
|
+
init?: { signal?: AbortSignal },
|
|
259
|
+
): Promise<{ status: number; headers: Headers }> {
|
|
260
|
+
const resp = await this.fetchWithAuthRetry(path, async () => ({
|
|
261
|
+
method: "HEAD",
|
|
262
|
+
headers: await this.authHeaders(),
|
|
263
|
+
signal: init?.signal,
|
|
264
|
+
}));
|
|
265
|
+
return { status: resp.status, headers: resp.headers };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async jsonHeaders(): Promise<Record<string, string>> {
|
|
269
|
+
return {
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
Authorization: `Bearer ${await this.resolveToken()}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async authHeaders(): Promise<Record<string, string>> {
|
|
276
|
+
return { Authorization: `Bearer ${await this.resolveToken()}` };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async parseError(resp: Response): Promise<APIError> {
|
|
280
|
+
const text = await resp.text();
|
|
281
|
+
let type = "error";
|
|
282
|
+
let message = text || resp.statusText;
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(text) as { type?: string; message?: string };
|
|
285
|
+
if (parsed.type) type = parsed.type;
|
|
286
|
+
if (parsed.message) message = parsed.message;
|
|
287
|
+
} catch {
|
|
288
|
+
// Body wasn't JSON — fall back to raw text.
|
|
289
|
+
}
|
|
290
|
+
return new APIError(resp.status, type, message);
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage carries token consumption and cost information returned with every
|
|
3
|
+
* gateway response. `total_cost` is the gateway's billing total in USD —
|
|
4
|
+
* always present, while token counts are only populated when the upstream
|
|
5
|
+
* provider surfaces them.
|
|
6
|
+
*
|
|
7
|
+
* For chat completions that invoked server-executed tools (via the
|
|
8
|
+
* `server_tools` field), `total_cost` includes BOTH the model's raw
|
|
9
|
+
* upstream cost (summed across iterations) AND the post-markup USD price
|
|
10
|
+
* of every tool invocation. The per-tool breakdown lives in `tool_costs`
|
|
11
|
+
* so callers can attribute charges without having to re-run the loop.
|
|
12
|
+
*/
|
|
13
|
+
export interface Usage {
|
|
14
|
+
prompt_tokens?: number;
|
|
15
|
+
completion_tokens?: number;
|
|
16
|
+
total_tokens?: number;
|
|
17
|
+
total_cost: number;
|
|
18
|
+
tool_costs?: ToolCost[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-tool spend for a single chat completion that used server-executed
|
|
23
|
+
* tools. `cost` is in USD and already post-markup — it matches the
|
|
24
|
+
* amount actually deducted from the user's balance for that tool's
|
|
25
|
+
* invocations during the request.
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolCost {
|
|
28
|
+
name: string;
|
|
29
|
+
count: number;
|
|
30
|
+
cost: number;
|
|
31
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Generic validator for the gateway's options schema. Mirrors
|
|
2
|
+
// /Users/zero/repos/sdk-go/validate.go — three-pass algorithm,
|
|
3
|
+
// same field/constraint vocabulary, same parse logic.
|
|
4
|
+
|
|
5
|
+
import { normalizeGroups } from "./model_options.js";
|
|
6
|
+
import type { Schema, FieldSchema, Constraint, Group } from "./model_options.js";
|
|
7
|
+
|
|
8
|
+
export interface ValidationError {
|
|
9
|
+
field: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Three-pass validator: per-field type/enum/bounds → per-field
|
|
15
|
+
* presence → cross-field constraints. Unknown fields in `req`
|
|
16
|
+
* (vendor passthroughs) are silently ignored — the gateway still
|
|
17
|
+
* validates them server-side if it cares.
|
|
18
|
+
*
|
|
19
|
+
* Returns an empty array on success, or a flat list of all
|
|
20
|
+
* validation failures so the UI can surface them at once.
|
|
21
|
+
*/
|
|
22
|
+
export function validateRequest(
|
|
23
|
+
schema: Schema | null | undefined,
|
|
24
|
+
req: Record<string, unknown> = {},
|
|
25
|
+
): ValidationError[] {
|
|
26
|
+
if (!schema) return [];
|
|
27
|
+
const errors: ValidationError[] = [];
|
|
28
|
+
|
|
29
|
+
// Pass 1+2: per-field.
|
|
30
|
+
if (schema.inputs) {
|
|
31
|
+
for (const [name, field] of Object.entries(schema.inputs)) {
|
|
32
|
+
const set = name in req;
|
|
33
|
+
const val = req[name];
|
|
34
|
+
if (set && !isEmpty(val)) {
|
|
35
|
+
errors.push(...validateFieldValue(name, field, val));
|
|
36
|
+
} else if (set && field.min_items !== undefined) {
|
|
37
|
+
// Empty array trips min_items even when isEmpty() short-
|
|
38
|
+
// circuits validateFieldValue. Mirrors the sdk-go fix: bound
|
|
39
|
+
// was declared explicitly, dropping it silently would let a
|
|
40
|
+
// client POST `groups: []` to a schema that requires >=1.
|
|
41
|
+
if (Array.isArray(val) && val.length < field.min_items) {
|
|
42
|
+
errors.push({ field: name, reason: `at least ${field.min_items} items` });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
errors.push(...validatePresence(name, field, val, set));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pass 3: cross-field.
|
|
50
|
+
for (const c of schema.constraints ?? []) {
|
|
51
|
+
errors.push(...validateConstraint(c, req));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateFieldValue(
|
|
58
|
+
name: string,
|
|
59
|
+
field: FieldSchema,
|
|
60
|
+
val: unknown,
|
|
61
|
+
): ValidationError[] {
|
|
62
|
+
const errors: ValidationError[] = [];
|
|
63
|
+
|
|
64
|
+
if (!typeMatches(field.type, val)) {
|
|
65
|
+
errors.push({
|
|
66
|
+
field: name,
|
|
67
|
+
reason: `expected type ${field.type}, got ${describeType(val)}`,
|
|
68
|
+
});
|
|
69
|
+
return errors; // bail early — bound/enum checks need correct type
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (field.enum && field.enum.length > 0 && !inEnum(val, field.enum)) {
|
|
73
|
+
errors.push({
|
|
74
|
+
field: name,
|
|
75
|
+
reason: `must be one of ${JSON.stringify(field.enum)}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (field.type === "int" || field.type === "float") {
|
|
80
|
+
const n = numberValue(val);
|
|
81
|
+
if (n !== null) {
|
|
82
|
+
if (field.min !== undefined && n < field.min) {
|
|
83
|
+
errors.push({ field: name, reason: `must be >= ${field.min}` });
|
|
84
|
+
}
|
|
85
|
+
if (field.max !== undefined && n > field.max) {
|
|
86
|
+
errors.push({ field: name, reason: `must be <= ${field.max}` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Array bounds — apply to scalar array Types ("url[]", "string[]")
|
|
92
|
+
// AND to `repeatable` object groups whose value is a JSON array.
|
|
93
|
+
if (Array.isArray(val)) {
|
|
94
|
+
if (field.max_items !== undefined && val.length > field.max_items) {
|
|
95
|
+
errors.push({
|
|
96
|
+
field: name,
|
|
97
|
+
reason: `at most ${field.max_items} items`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (field.min_items !== undefined && val.length < field.min_items) {
|
|
101
|
+
errors.push({
|
|
102
|
+
field: name,
|
|
103
|
+
reason: `at least ${field.min_items} items`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Nested object recursion. `field.fields` declares a structured
|
|
109
|
+
// sub-object; with `repeatable: true` the wire value is an array of
|
|
110
|
+
// such objects. Errors carry a dotted path like
|
|
111
|
+
// "groups[1].prompt: required" so callers see the offending row.
|
|
112
|
+
// `ui.show_when` is deliberately NOT honoured at validation time —
|
|
113
|
+
// it's a UI hint only; conditionally-hidden fields are still
|
|
114
|
+
// subject to the same checks if a client sends them.
|
|
115
|
+
if (field.fields) {
|
|
116
|
+
if (field.repeatable) {
|
|
117
|
+
if (!Array.isArray(val)) {
|
|
118
|
+
errors.push({
|
|
119
|
+
field: name,
|
|
120
|
+
reason: `expected array of objects, got ${describeType(val)}`,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
val.forEach((item, i) => {
|
|
124
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
125
|
+
errors.push({
|
|
126
|
+
field: `${name}[${i}]`,
|
|
127
|
+
reason: `expected object, got ${describeType(item)}`,
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
errors.push(...validateObject(`${name}[${i}]`, field.fields!, item as Record<string, unknown>));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
if (typeof val !== "object" || val === null || Array.isArray(val)) {
|
|
136
|
+
errors.push({
|
|
137
|
+
field: name,
|
|
138
|
+
reason: `expected object, got ${describeType(val)}`,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
errors.push(...validateObject(name, field.fields, val as Record<string, unknown>));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return errors;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** validateObject runs per-field validation against a sub-schema's
|
|
150
|
+
* `fields` map for one object value. Errors return with
|
|
151
|
+
* `<pathPrefix>.<innerName>` so the caller sees the full nested path. */
|
|
152
|
+
function validateObject(
|
|
153
|
+
pathPrefix: string,
|
|
154
|
+
fields: Record<string, FieldSchema>,
|
|
155
|
+
obj: Record<string, unknown>,
|
|
156
|
+
): ValidationError[] {
|
|
157
|
+
const errors: ValidationError[] = [];
|
|
158
|
+
for (const [innerName, innerField] of Object.entries(fields)) {
|
|
159
|
+
const childPath = `${pathPrefix}.${innerName}`;
|
|
160
|
+
const val = obj[innerName];
|
|
161
|
+
const set = innerName in obj;
|
|
162
|
+
if (set && !isEmpty(val)) {
|
|
163
|
+
errors.push(...validateFieldValue(childPath, innerField, val));
|
|
164
|
+
}
|
|
165
|
+
errors.push(...validatePresence(childPath, innerField, val, set));
|
|
166
|
+
}
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validatePresence(
|
|
171
|
+
name: string,
|
|
172
|
+
field: FieldSchema,
|
|
173
|
+
val: unknown,
|
|
174
|
+
set: boolean,
|
|
175
|
+
): ValidationError[] {
|
|
176
|
+
const presence = field.presence ?? "optional";
|
|
177
|
+
if (presence === "required" && (!set || isEmpty(val))) {
|
|
178
|
+
return [{ field: name, reason: "required" }];
|
|
179
|
+
}
|
|
180
|
+
if (presence === "forbidden" && set && !isEmpty(val)) {
|
|
181
|
+
return [{ field: name, reason: "must not be set for this model" }];
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateConstraint(
|
|
187
|
+
c: Constraint,
|
|
188
|
+
req: Record<string, unknown>,
|
|
189
|
+
): ValidationError[] {
|
|
190
|
+
switch (c.kind) {
|
|
191
|
+
case "any_of_required": {
|
|
192
|
+
const fields = c.fields ?? [];
|
|
193
|
+
const anySet = fields.some((f) => f in req && !isEmpty(req[f]));
|
|
194
|
+
if (anySet) return [];
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
field: fields.join("/"),
|
|
198
|
+
reason: `at least one of ${fields.join(", ")} is required`,
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case "mutually_exclusive": {
|
|
204
|
+
const fields = c.fields ?? [];
|
|
205
|
+
const setFields = fields.filter((f) => f in req && !isEmpty(req[f]));
|
|
206
|
+
if (setFields.length > 1) {
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
field: setFields.join("/"),
|
|
210
|
+
reason: `at most one of ${fields.join(", ")} may be set (got: ${setFields.join(", ")})`,
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "group_mutex": {
|
|
218
|
+
// Best-effort: enforce only the anonymous "at most one group
|
|
219
|
+
// active" rule for both shapes. The strict discriminator-based
|
|
220
|
+
// check (validating `c.name`'s value + rejecting non-active
|
|
221
|
+
// group fields) lives in the gateway only — SDK validation
|
|
222
|
+
// stays a fast pre-flight hint, not a contract enforcer, so we
|
|
223
|
+
// avoid doubling the maintenance surface across SDKs.
|
|
224
|
+
const groups: Group[] = normalizeGroups(c.groups);
|
|
225
|
+
const activeGroups: Group[] = [];
|
|
226
|
+
const activeFields: string[] = [];
|
|
227
|
+
for (const g of groups) {
|
|
228
|
+
const setHere = g.fields.filter((f) => f in req && !isEmpty(req[f]));
|
|
229
|
+
if (setHere.length > 0) {
|
|
230
|
+
activeGroups.push(g);
|
|
231
|
+
activeFields.push(...setHere);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (activeGroups.length > 1) {
|
|
235
|
+
const labels = groups.map((g) => `[${g.fields.join("+")}]`).join(" or ");
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
field: activeFields.join("/"),
|
|
239
|
+
reason: `at most one of ${labels} may be used (got: ${activeFields.join(", ")})`,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case "requires_all": {
|
|
247
|
+
const whenName = c.when;
|
|
248
|
+
if (!whenName) return [];
|
|
249
|
+
const whenSet = whenName in req && !isEmpty(req[whenName]);
|
|
250
|
+
if (!whenSet) return [];
|
|
251
|
+
const missing = (c.then ?? []).filter(
|
|
252
|
+
(f) => !(f in req) || isEmpty(req[f]),
|
|
253
|
+
);
|
|
254
|
+
if (missing.length === 0) return [];
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
field: missing.join("/"),
|
|
258
|
+
reason: `required when ${whenName} is set`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case "pixel_bounds": {
|
|
264
|
+
const fieldName = c.fields?.[0];
|
|
265
|
+
if (!fieldName) return [];
|
|
266
|
+
const raw = req[fieldName];
|
|
267
|
+
if (typeof raw !== "string" || raw === "") return [];
|
|
268
|
+
const dims = parsePixelDims(raw);
|
|
269
|
+
if (!dims) {
|
|
270
|
+
return [{ field: fieldName, reason: `expected WxH dimensions, got "${raw}"` }];
|
|
271
|
+
}
|
|
272
|
+
const [w, h] = dims;
|
|
273
|
+
const px = w * h;
|
|
274
|
+
const errs: ValidationError[] = [];
|
|
275
|
+
if (c.min !== undefined && px < c.min) {
|
|
276
|
+
errs.push({
|
|
277
|
+
field: fieldName,
|
|
278
|
+
reason: `image too small: ${w}x${h} = ${px} pixels, minimum ${c.min}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (c.max !== undefined && px > c.max) {
|
|
282
|
+
errs.push({
|
|
283
|
+
field: fieldName,
|
|
284
|
+
reason: `image too large: ${w}x${h} = ${px} pixels, maximum ${c.max}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return errs;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Unknown constraint kind — skip (forward-compat).
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Type helpers ────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function typeMatches(typ: string, val: unknown): boolean {
|
|
297
|
+
switch (typ) {
|
|
298
|
+
case "string":
|
|
299
|
+
return typeof val === "string";
|
|
300
|
+
case "int":
|
|
301
|
+
case "float":
|
|
302
|
+
return numberValue(val) !== null;
|
|
303
|
+
case "bool":
|
|
304
|
+
return typeof val === "boolean";
|
|
305
|
+
case "url":
|
|
306
|
+
return typeof val === "string" && isURLLike(val);
|
|
307
|
+
case "url[]":
|
|
308
|
+
return Array.isArray(val) && val.every((v) => typeof v === "string" && isURLLike(v));
|
|
309
|
+
case "string[]":
|
|
310
|
+
return Array.isArray(val) && val.every((v) => typeof v === "string");
|
|
311
|
+
case "file":
|
|
312
|
+
return true; // multipart — can't validate
|
|
313
|
+
default:
|
|
314
|
+
return true; // unknown type — forward-compat
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function describeType(val: unknown): string {
|
|
319
|
+
if (val === null) return "null";
|
|
320
|
+
if (Array.isArray(val)) return "array";
|
|
321
|
+
return typeof val;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function numberValue(val: unknown): number | null {
|
|
325
|
+
if (typeof val === "number" && Number.isFinite(val)) return val;
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isURLLike(s: string): boolean {
|
|
330
|
+
return s.startsWith("http://") || s.startsWith("https://") || s.startsWith("data:");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isEmpty(val: unknown): boolean {
|
|
334
|
+
if (val === undefined || val === null) return true;
|
|
335
|
+
if (typeof val === "string") return val === "";
|
|
336
|
+
if (Array.isArray(val)) return val.length === 0;
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function inEnum(val: unknown, enumVals: unknown[]): boolean {
|
|
341
|
+
for (const e of enumVals) {
|
|
342
|
+
if (equalLoose(val, e)) return true;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function equalLoose(a: unknown, b: unknown): boolean {
|
|
348
|
+
// Numbers compared as JS numbers (already float64-equivalent);
|
|
349
|
+
// strings/bools by ===.
|
|
350
|
+
return a === b;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parsePixelDims(s: string): [number, number] | null {
|
|
354
|
+
for (const sep of ["x", "X", "*", "×"]) {
|
|
355
|
+
const idx = s.indexOf(sep);
|
|
356
|
+
if (idx <= 0) continue;
|
|
357
|
+
const w = parseInt(s.slice(0, idx).trim(), 10);
|
|
358
|
+
const h = parseInt(s.slice(idx + sep.length).trim(), 10);
|
|
359
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
360
|
+
return [w, h];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|