@runappsai/sdk 0.0.0-stage → 0.3.1

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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/dist/activity-tracker.d.ts +113 -0
  4. package/dist/activity-tracker.d.ts.map +1 -0
  5. package/dist/activity-tracker.js +176 -0
  6. package/dist/activity-tracker.js.map +1 -0
  7. package/dist/audio.d.ts +129 -0
  8. package/dist/audio.d.ts.map +1 -0
  9. package/dist/audio.js +267 -0
  10. package/dist/audio.js.map +1 -0
  11. package/dist/browser-auth.d.ts +179 -0
  12. package/dist/browser-auth.d.ts.map +1 -0
  13. package/dist/browser-auth.js +1361 -0
  14. package/dist/browser-auth.js.map +1 -0
  15. package/dist/chat.d.ts +221 -0
  16. package/dist/chat.d.ts.map +1 -0
  17. package/dist/chat.js +155 -0
  18. package/dist/chat.js.map +1 -0
  19. package/dist/client.d.ts +155 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +157 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/computer.d.ts +57 -0
  24. package/dist/computer.d.ts.map +1 -0
  25. package/dist/computer.js +21 -0
  26. package/dist/computer.js.map +1 -0
  27. package/dist/embeddings.d.ts +64 -0
  28. package/dist/embeddings.d.ts.map +1 -0
  29. package/dist/embeddings.js +30 -0
  30. package/dist/embeddings.js.map +1 -0
  31. package/dist/errors.d.ts +24 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +29 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/event-wrap.d.ts +59 -0
  36. package/dist/event-wrap.d.ts.map +1 -0
  37. package/dist/event-wrap.js +142 -0
  38. package/dist/event-wrap.js.map +1 -0
  39. package/dist/events.d.ts +119 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +87 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/files.d.ts +218 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +212 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/image.d.ts +185 -0
  48. package/dist/image.d.ts.map +1 -0
  49. package/dist/image.js +148 -0
  50. package/dist/image.js.map +1 -0
  51. package/dist/index.d.ts +30 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +26 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/media.d.ts +56 -0
  56. package/dist/media.d.ts.map +1 -0
  57. package/dist/media.js +133 -0
  58. package/dist/media.js.map +1 -0
  59. package/dist/model_options.d.ts +165 -0
  60. package/dist/model_options.d.ts.map +1 -0
  61. package/dist/model_options.js +59 -0
  62. package/dist/model_options.js.map +1 -0
  63. package/dist/models.d.ts +88 -0
  64. package/dist/models.d.ts.map +1 -0
  65. package/dist/models.js +55 -0
  66. package/dist/models.js.map +1 -0
  67. package/dist/sdk.umd.js +4 -0
  68. package/dist/sdk.umd.js.map +1 -0
  69. package/dist/transport.d.ts +120 -0
  70. package/dist/transport.d.ts.map +1 -0
  71. package/dist/transport.js +214 -0
  72. package/dist/transport.js.map +1 -0
  73. package/dist/types.d.ts +31 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +2 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/validate.d.ts +16 -0
  78. package/dist/validate.d.ts.map +1 -0
  79. package/dist/validate.js +329 -0
  80. package/dist/validate.js.map +1 -0
  81. package/dist/video.d.ts +134 -0
  82. package/dist/video.d.ts.map +1 -0
  83. package/dist/video.js +60 -0
  84. package/dist/video.js.map +1 -0
  85. package/package.json +54 -4
  86. package/src/activity-tracker.ts +251 -0
  87. package/src/audio.ts +440 -0
  88. package/src/browser-auth.ts +1529 -0
  89. package/src/chat.ts +368 -0
  90. package/src/client.ts +250 -0
  91. package/src/computer.ts +80 -0
  92. package/src/embeddings.ts +97 -0
  93. package/src/errors.ts +29 -0
  94. package/src/event-wrap.ts +168 -0
  95. package/src/events.ts +172 -0
  96. package/src/files.ts +347 -0
  97. package/src/image.ts +339 -0
  98. package/src/index.ts +151 -0
  99. package/src/media.ts +147 -0
  100. package/src/model_options.ts +220 -0
  101. package/src/models.ts +121 -0
  102. package/src/transport.ts +292 -0
  103. package/src/types.ts +31 -0
  104. package/src/validate.ts +364 -0
  105. package/src/video.ts +215 -0
@@ -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
+ }
@@ -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
+ }