@302ai/media-studio-core 0.1.0-beta.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.
@@ -0,0 +1,58 @@
1
+ import { type MediaResult } from "../media/media-result.js";
2
+ import { type ChatStreamEvent } from "./events.js";
3
+ export type ChatStreamSource = ReadableStream<Uint8Array> | (() => Promise<Response>);
4
+ export interface ChatStreamOptions {
5
+ maxRetries?: number | undefined;
6
+ initialRetryDelayMs?: number | undefined;
7
+ signal?: AbortSignal | undefined;
8
+ onFinalResponse?: ((res: ChatFinalResponse) => void) | undefined;
9
+ onError?: ((err: unknown) => void) | undefined;
10
+ }
11
+ export interface ChatFinalResponse {
12
+ text: string;
13
+ reasoning?: string;
14
+ toolCalls: Array<{
15
+ toolCallId: string;
16
+ toolName: string;
17
+ args?: unknown;
18
+ }>;
19
+ toolResults: Array<{
20
+ toolCallId: string;
21
+ result?: unknown;
22
+ }>;
23
+ mediaResults: MediaResult[];
24
+ finishReason?: string;
25
+ }
26
+ /**
27
+ * Isomorphic async stream wrapper that transforms raw SSE bytes into typed
28
+ * ChatStreamEvents. Supports async iteration (`for await (const ev of stream)`),
29
+ * connection-establishment exponential backoff retries, and automatic media result synthesis.
30
+ */
31
+ export declare class ChatStream implements AsyncIterable<ChatStreamEvent> {
32
+ private readonly source;
33
+ private readonly options;
34
+ private readonly accumulator;
35
+ private hasStartedIterating;
36
+ private resolveCompletion;
37
+ private rejectCompletion;
38
+ private readonly completionPromise;
39
+ private isCompleted;
40
+ constructor(source: ChatStreamSource, options?: ChatStreamOptions);
41
+ private establishConnection;
42
+ private drainQueue;
43
+ [Symbol.asyncIterator](): AsyncIterator<ChatStreamEvent>;
44
+ /**
45
+ * Resolves with the aggregated final response.
46
+ * Awaits stream completion whether called before, during, or after iteration.
47
+ */
48
+ finalResponse(): Promise<ChatFinalResponse>;
49
+ /**
50
+ * Convenience helper that consumes the stream and resolves with just the full text.
51
+ */
52
+ finalText(): Promise<string>;
53
+ /**
54
+ * Settles when the stream completes, is cancelled, or encounters an error.
55
+ * Unlike finalResponse(), awaiting settled does not automatically start background iteration.
56
+ */
57
+ get settled(): Promise<void>;
58
+ }
@@ -0,0 +1,325 @@
1
+ import { attempt, delay } from "es-toolkit";
2
+ import { isObject } from "es-toolkit/compat";
3
+ import { isFunction, isNumber, isString } from "es-toolkit/predicate";
4
+ import { createParser } from "eventsource-parser";
5
+ import { match, P } from "ts-pattern";
6
+ import { extractMediaResult } from "../media/media-result.js";
7
+ import { ChatUpstreamError, extractUpstreamErrorMessage, } from "../transport/errors.js";
8
+ import { ChatStreamEventSchema } from "./events.js";
9
+ function abortException(signal) {
10
+ const reason = signal?.reason;
11
+ if (reason instanceof Error)
12
+ return reason;
13
+ return new DOMException("This operation was aborted", "AbortError");
14
+ }
15
+ class ResponseAccumulator {
16
+ text = "";
17
+ reasoning = "";
18
+ toolCalls = [];
19
+ toolResults = [];
20
+ mediaResults = [];
21
+ finishReason;
22
+ record(event) {
23
+ match(event)
24
+ .with({ type: "text-delta" }, ({ delta }) => {
25
+ this.text += delta;
26
+ })
27
+ .with({ type: "reasoning-delta" }, ({ delta }) => {
28
+ this.reasoning += delta;
29
+ })
30
+ .with({ type: "tool-call" }, ({ toolCallId, toolName, args }) => {
31
+ this.toolCalls.push({ toolCallId, toolName, args });
32
+ })
33
+ .with({ type: "tool-result" }, ({ toolCallId, result }) => {
34
+ this.toolResults.push({ toolCallId, result });
35
+ })
36
+ .with({ type: "media-result" }, ({ result }) => {
37
+ this.mediaResults.push(result);
38
+ })
39
+ .with({ type: "finish" }, ({ finishReason }) => {
40
+ this.finishReason = finishReason;
41
+ })
42
+ .with({ type: "error" }, () => {
43
+ // Error events are upstream SSE domain data (e.g. rate limits).
44
+ // They are yielded to the consumer as-is; no accumulator-level side effect.
45
+ })
46
+ .exhaustive();
47
+ }
48
+ toResponse() {
49
+ return {
50
+ text: this.text,
51
+ ...(this.reasoning ? { reasoning: this.reasoning } : {}),
52
+ toolCalls: [...this.toolCalls],
53
+ toolResults: [...this.toolResults],
54
+ mediaResults: [...this.mediaResults],
55
+ ...(this.finishReason ? { finishReason: this.finishReason } : {}),
56
+ };
57
+ }
58
+ }
59
+ /**
60
+ * Isomorphic async stream wrapper that transforms raw SSE bytes into typed
61
+ * ChatStreamEvents. Supports async iteration (`for await (const ev of stream)`),
62
+ * connection-establishment exponential backoff retries, and automatic media result synthesis.
63
+ */
64
+ export class ChatStream {
65
+ source;
66
+ options;
67
+ accumulator = new ResponseAccumulator();
68
+ hasStartedIterating = false;
69
+ resolveCompletion;
70
+ rejectCompletion;
71
+ completionPromise;
72
+ isCompleted = false;
73
+ constructor(source, options = {}) {
74
+ this.source = source;
75
+ this.options = options;
76
+ const { promise, resolve, reject } = Promise.withResolvers();
77
+ this.completionPromise = promise;
78
+ this.resolveCompletion = (res) => {
79
+ this.isCompleted = true;
80
+ resolve(res);
81
+ };
82
+ this.rejectCompletion = (err) => {
83
+ this.isCompleted = true;
84
+ reject(err);
85
+ };
86
+ // Prevent unhandled rejection when the iterator throws and nobody awaits completionPromise
87
+ this.completionPromise.catch(() => { });
88
+ }
89
+ async establishConnection() {
90
+ if (!isFunction(this.source)) {
91
+ const reader = this.source.getReader();
92
+ return { reader, firstChunk: null };
93
+ }
94
+ let attemptCount = 0;
95
+ const maxRetries = this.options.maxRetries ?? 0;
96
+ const initialDelay = this.options.initialRetryDelayMs ?? 1000;
97
+ while (true) {
98
+ if (this.options.signal?.aborted) {
99
+ throw abortException(this.options.signal);
100
+ }
101
+ let activeReader = null;
102
+ try {
103
+ const response = await this.source();
104
+ if (!response.ok) {
105
+ const status = response.status;
106
+ const rawBody = await response.text();
107
+ const message = extractUpstreamErrorMessage(rawBody) ||
108
+ `Connection establishment failed with status ${status}`;
109
+ throw new ChatUpstreamError(message, status, rawBody);
110
+ }
111
+ if (!response.body) {
112
+ throw new Error("Response body is empty");
113
+ }
114
+ activeReader = response.body.getReader();
115
+ // Connection establishment boundary: succeeds only when the first byte arrives!
116
+ const firstChunk = await activeReader.read();
117
+ if (firstChunk.done) {
118
+ throw new Error("Connection closed before first chunk arrived");
119
+ }
120
+ return { reader: activeReader, firstChunk };
121
+ }
122
+ catch (err) {
123
+ if (activeReader) {
124
+ activeReader.releaseLock();
125
+ }
126
+ // Never retry 4xx client errors or abort signals
127
+ const isClientError = (err instanceof ChatUpstreamError &&
128
+ err.status >= 400 &&
129
+ err.status < 500) ||
130
+ (isObject(err) &&
131
+ "status" in err &&
132
+ isNumber(err.status) &&
133
+ err.status >= 400 &&
134
+ err.status < 500);
135
+ if (isClientError || attemptCount >= maxRetries) {
136
+ throw err;
137
+ }
138
+ const retryDelay = initialDelay * 2 ** attemptCount;
139
+ attemptCount++;
140
+ await delay(retryDelay, { signal: this.options.signal });
141
+ }
142
+ }
143
+ }
144
+ *drainQueue(queue) {
145
+ while (queue.length > 0) {
146
+ const item = queue.shift();
147
+ if (item) {
148
+ this.accumulator.record(item);
149
+ yield item;
150
+ }
151
+ }
152
+ }
153
+ async *[Symbol.asyncIterator]() {
154
+ if (this.hasStartedIterating) {
155
+ throw new Error("ChatStream can only be iterated once directly; use finalResponse() to read accumulated output.");
156
+ }
157
+ this.hasStartedIterating = true;
158
+ let reader;
159
+ let abortCleanup;
160
+ try {
161
+ const conn = await this.establishConnection();
162
+ reader = conn.reader;
163
+ const firstChunk = conn.firstChunk;
164
+ if (this.options.signal?.aborted) {
165
+ throw abortException(this.options.signal);
166
+ }
167
+ const decoder = new TextDecoder();
168
+ const queue = [];
169
+ const parser = createParser({
170
+ onEvent: (event) => {
171
+ if (!event.data || event.data === "[DONE]")
172
+ return;
173
+ const [jsonErr, raw] = attempt(() => JSON.parse(event.data));
174
+ if (jsonErr || !isObject(raw))
175
+ return;
176
+ let normalized = raw;
177
+ if ("type" in raw &&
178
+ raw.type === "tool-input-available" &&
179
+ "toolCallId" in raw &&
180
+ isString(raw.toolCallId) &&
181
+ "toolName" in raw &&
182
+ isString(raw.toolName)) {
183
+ normalized = {
184
+ type: "tool-call",
185
+ toolCallId: raw.toolCallId,
186
+ toolName: raw.toolName,
187
+ args: "input" in raw ? raw.input : undefined,
188
+ };
189
+ }
190
+ else if ("type" in raw &&
191
+ raw.type === "tool-output-available" &&
192
+ "toolCallId" in raw &&
193
+ isString(raw.toolCallId)) {
194
+ normalized = {
195
+ type: "tool-result",
196
+ toolCallId: raw.toolCallId,
197
+ result: "output" in raw ? raw.output : undefined,
198
+ };
199
+ }
200
+ else if ("type" in raw &&
201
+ raw.type === "text-delta" &&
202
+ !("delta" in raw) &&
203
+ "textDelta" in raw &&
204
+ isString(raw.textDelta)) {
205
+ normalized = {
206
+ type: "text-delta",
207
+ delta: raw.textDelta,
208
+ id: "id" in raw && isString(raw.id) ? raw.id : undefined,
209
+ };
210
+ }
211
+ else if ("type" in raw &&
212
+ raw.type === "reasoning-delta" &&
213
+ !("delta" in raw) &&
214
+ "reasoningDelta" in raw &&
215
+ isString(raw.reasoningDelta)) {
216
+ normalized = {
217
+ type: "reasoning-delta",
218
+ delta: raw.reasoningDelta,
219
+ id: "id" in raw && isString(raw.id) ? raw.id : undefined,
220
+ };
221
+ }
222
+ const parsed = ChatStreamEventSchema.safeParse(normalized);
223
+ if (!parsed.success)
224
+ return;
225
+ const ev = parsed.data;
226
+ queue.push(ev);
227
+ match(ev)
228
+ .with({ type: "tool-result", result: P.nonNullable }, ({ result }) => {
229
+ const media = extractMediaResult(result);
230
+ if (media) {
231
+ queue.push({
232
+ type: "media-result",
233
+ result: media,
234
+ });
235
+ }
236
+ })
237
+ .with(P._, () => { })
238
+ .exhaustive();
239
+ },
240
+ });
241
+ const abortPromise = new Promise((_, reject) => {
242
+ if (this.options.signal) {
243
+ const onAbort = () => {
244
+ reader?.cancel().catch(() => { });
245
+ reject(abortException(this.options.signal));
246
+ };
247
+ this.options.signal.addEventListener("abort", onAbort, {
248
+ once: true,
249
+ });
250
+ abortCleanup = () => this.options.signal?.removeEventListener("abort", onAbort);
251
+ }
252
+ });
253
+ if (firstChunk) {
254
+ if (firstChunk.done) {
255
+ throw new Error("Connection closed before first chunk arrived");
256
+ }
257
+ parser.feed(decoder.decode(firstChunk.value, { stream: true }));
258
+ yield* this.drainQueue(queue);
259
+ }
260
+ while (true) {
261
+ if (this.options.signal?.aborted) {
262
+ throw abortException(this.options.signal);
263
+ }
264
+ const readPromise = reader.read();
265
+ const { done, value } = await Promise.race([readPromise, abortPromise]);
266
+ if (done)
267
+ break;
268
+ parser.feed(decoder.decode(value, { stream: true }));
269
+ yield* this.drainQueue(queue);
270
+ }
271
+ parser.feed(decoder.decode());
272
+ yield* this.drainQueue(queue);
273
+ const finalRes = this.accumulator.toResponse();
274
+ this.resolveCompletion(finalRes);
275
+ this.options.onFinalResponse?.(finalRes);
276
+ }
277
+ catch (err) {
278
+ this.rejectCompletion(err);
279
+ this.options.onError?.(err);
280
+ throw err;
281
+ }
282
+ finally {
283
+ abortCleanup?.();
284
+ reader?.cancel().catch(() => { });
285
+ reader?.releaseLock();
286
+ if (!this.isCompleted) {
287
+ const finalRes = this.accumulator.toResponse();
288
+ this.resolveCompletion(finalRes);
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * Resolves with the aggregated final response.
294
+ * Awaits stream completion whether called before, during, or after iteration.
295
+ */
296
+ async finalResponse() {
297
+ if (!this.hasStartedIterating) {
298
+ (async () => {
299
+ try {
300
+ for await (const _ of this) {
301
+ // iterator consumption records into accumulator and resolves completionPromise
302
+ }
303
+ }
304
+ catch {
305
+ // errors are already caught and routed to rejectCompletion inside iterator
306
+ }
307
+ })();
308
+ }
309
+ return this.completionPromise;
310
+ }
311
+ /**
312
+ * Convenience helper that consumes the stream and resolves with just the full text.
313
+ */
314
+ async finalText() {
315
+ const response = await this.finalResponse();
316
+ return response.text;
317
+ }
318
+ /**
319
+ * Settles when the stream completes, is cancelled, or encounters an error.
320
+ * Unlike finalResponse(), awaiting settled does not automatically start background iteration.
321
+ */
322
+ get settled() {
323
+ return this.completionPromise.then(() => { }, () => { });
324
+ }
325
+ }
@@ -0,0 +1,30 @@
1
+ /** Marker used by the host app to distinguish local session expiry from upstream 401s. */
2
+ export declare const ERR_SESSION_EXPIRED = "SESSION_EXPIRED";
3
+ export declare class ChatUpstreamError extends Error {
4
+ readonly status: number;
5
+ readonly body: string;
6
+ constructor(message: string, status: number, body: string);
7
+ }
8
+ export type ChatUpstreamErrorBody = {
9
+ error?: {
10
+ code?: string | number;
11
+ message?: string;
12
+ err_code?: string | number;
13
+ message_cn?: string;
14
+ message_en?: string;
15
+ message_ja?: string;
16
+ };
17
+ msg?: string;
18
+ message?: string;
19
+ };
20
+ /**
21
+ * Parses the raw upstream response body and extracts the human-readable error message.
22
+ *
23
+ * Implements the 5-level fallback priority:
24
+ * 1. Localized messages (`message_cn`, `message_en`, `message_ja`)
25
+ * 2. Standard nested message (`error.message`)
26
+ * 3. Top-level `msg`
27
+ * 4. Top-level `message`
28
+ * 5. String body fallback
29
+ */
30
+ export declare function extractUpstreamErrorMessage(body: unknown): string | null;
@@ -0,0 +1,47 @@
1
+ import { attempt } from "es-toolkit";
2
+ import { isString } from "es-toolkit/predicate";
3
+ import { match, P } from "ts-pattern";
4
+ /** Marker used by the host app to distinguish local session expiry from upstream 401s. */
5
+ export const ERR_SESSION_EXPIRED = "SESSION_EXPIRED";
6
+ export class ChatUpstreamError extends Error {
7
+ status;
8
+ body;
9
+ constructor(message, status, body) {
10
+ super(message);
11
+ this.name = "ChatUpstreamError";
12
+ this.status = status;
13
+ this.body = body;
14
+ }
15
+ }
16
+ /**
17
+ * Parses the raw upstream response body and extracts the human-readable error message.
18
+ *
19
+ * Implements the 5-level fallback priority:
20
+ * 1. Localized messages (`message_cn`, `message_en`, `message_ja`)
21
+ * 2. Standard nested message (`error.message`)
22
+ * 3. Top-level `msg`
23
+ * 4. Top-level `message`
24
+ * 5. String body fallback
25
+ */
26
+ export function extractUpstreamErrorMessage(body) {
27
+ if (!body)
28
+ return null;
29
+ if (isString(body)) {
30
+ const trimmed = body.trim();
31
+ if (!trimmed)
32
+ return null;
33
+ const [err, parsed] = attempt(() => JSON.parse(trimmed));
34
+ if (!err) {
35
+ return extractUpstreamErrorMessage(parsed);
36
+ }
37
+ return trimmed;
38
+ }
39
+ return match(body)
40
+ .with({ error: { message_cn: P.select(P.string) } }, (msg) => msg.trim() || null)
41
+ .with({ error: { message: P.select(P.string) } }, (msg) => msg.trim() || null)
42
+ .with({ message_cn: P.select(P.string) }, (msg) => msg.trim() || null)
43
+ .with({ message: P.select(P.string) }, (msg) => msg !== ERR_SESSION_EXPIRED ? msg.trim() || null : null)
44
+ .with({ error: P.select(P.string) }, (msg) => msg !== ERR_SESSION_EXPIRED ? msg.trim() || null : null)
45
+ .with(P._, () => null)
46
+ .exhaustive();
47
+ }
@@ -0,0 +1,44 @@
1
+ /** Text part of an OpenAI-format message content array. */
2
+ export type ChatContentPartText = {
3
+ type: "text";
4
+ text: string;
5
+ };
6
+ /** Image part of an OpenAI-format message content array. */
7
+ export type ChatContentPartImage = {
8
+ type: "image_url";
9
+ image_url: {
10
+ url: string;
11
+ };
12
+ };
13
+ /** Tool call emitted by an assistant message. */
14
+ export type ChatToolCall = {
15
+ id: string;
16
+ type: "function";
17
+ function: {
18
+ name: string;
19
+ arguments: string;
20
+ };
21
+ };
22
+ /** A single OpenAI-format chat message. Core never accepts AI SDK message types. */
23
+ export type ChatUpstreamMessage = {
24
+ role: "system" | "user" | "assistant" | "tool";
25
+ content: string | Array<ChatContentPartText | ChatContentPartImage> | null;
26
+ name?: string;
27
+ tool_call_id?: string;
28
+ tool_calls?: ChatToolCall[];
29
+ };
30
+ /** Request payload sent to the 302.AI async completions endpoint. */
31
+ export type ChatUpstreamRequest = {
32
+ apiBaseUrl: string;
33
+ apiKey: string;
34
+ sessionId: string;
35
+ messages: ChatUpstreamMessage[];
36
+ locale?: string;
37
+ modelParams?: unknown;
38
+ skillUrls?: string[];
39
+ chatTaskId?: string;
40
+ sinceSeq?: number | string;
41
+ structuredOutput?: boolean;
42
+ signal?: AbortSignal;
43
+ fetch?: typeof fetch;
44
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { ChatUpstreamRequest } from "./types.js";
2
+ /**
3
+ * Sends one chat turn to the 302.AI async completions endpoint and returns the
4
+ * raw streaming response. Callers own their own SSE pipeline; core applies none.
5
+ *
6
+ * @throws {ChatUpstreamError} when the upstream status is not ok.
7
+ */
8
+ export declare function sendChatUpstream(req: ChatUpstreamRequest): Promise<Response>;
@@ -0,0 +1,63 @@
1
+ import { isString } from "es-toolkit/predicate";
2
+ import { ChatUpstreamError, extractUpstreamErrorMessage } from "./errors.js";
3
+ /** Fixed by the upstream contract; not caller-controllable. */
4
+ const ASYNC_COMPLETIONS_PATH = "/302/media-studio/chat/completions/async";
5
+ const SESSION_ID_HEADER = "X-User-Session-Id";
6
+ const UI_LOCALE_HEADER = "X-UI-Locale";
7
+ function buildHeaders(req) {
8
+ const headers = {
9
+ "Content-Type": "application/json",
10
+ Authorization: `Bearer ${req.apiKey}`,
11
+ [SESSION_ID_HEADER]: req.sessionId,
12
+ };
13
+ if (req.locale) {
14
+ headers[UI_LOCALE_HEADER] = req.locale;
15
+ }
16
+ return headers;
17
+ }
18
+ function buildBody(req) {
19
+ const body = {
20
+ stream: true,
21
+ messages: req.messages,
22
+ session_id: req.sessionId,
23
+ structured_output: req.structuredOutput ?? false,
24
+ };
25
+ if (req.modelParams !== undefined) {
26
+ body.model_params = isString(req.modelParams)
27
+ ? req.modelParams
28
+ : JSON.stringify(req.modelParams);
29
+ }
30
+ if (req.skillUrls?.length) {
31
+ body.skill_urls = req.skillUrls;
32
+ }
33
+ if (req.chatTaskId) {
34
+ body.chat_task_id = req.chatTaskId;
35
+ if (req.sinceSeq !== undefined) {
36
+ body.since_seq = req.sinceSeq;
37
+ }
38
+ }
39
+ return body;
40
+ }
41
+ /**
42
+ * Sends one chat turn to the 302.AI async completions endpoint and returns the
43
+ * raw streaming response. Callers own their own SSE pipeline; core applies none.
44
+ *
45
+ * @throws {ChatUpstreamError} when the upstream status is not ok.
46
+ */
47
+ export async function sendChatUpstream(req) {
48
+ const url = `${req.apiBaseUrl.replace(/\/+$/, "")}${ASYNC_COMPLETIONS_PATH}`;
49
+ const fetchFn = req.fetch ?? fetch;
50
+ const response = await fetchFn(url, {
51
+ method: "POST",
52
+ headers: buildHeaders(req),
53
+ body: JSON.stringify(buildBody(req)),
54
+ signal: req.signal,
55
+ });
56
+ if (!response.ok) {
57
+ const rawBody = await response.text();
58
+ const message = extractUpstreamErrorMessage(rawBody) ||
59
+ `Upstream request failed with ${response.status}`;
60
+ throw new ChatUpstreamError(message, response.status, rawBody);
61
+ }
62
+ return response;
63
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@302ai/media-studio-core",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Outbound client for the 302.AI media-studio async chat completions SSE endpoint",
5
+ "author": "302.AI",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://302.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/302ai/AI-Media-Studio.git",
11
+ "directory": "packages/media-studio-core"
12
+ },
13
+ "keywords": [
14
+ "302.ai",
15
+ "chat",
16
+ "sse",
17
+ "streaming",
18
+ "media-studio"
19
+ ],
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json && bun scripts/fix-dist.ts",
39
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
40
+ "prepublishOnly": "bun run build"
41
+ },
42
+ "dependencies": {
43
+ "es-toolkit": "^1.46.1",
44
+ "eventsource-parser": "^4.1.0",
45
+ "nanoid": "^5.1.11",
46
+ "ts-pattern": "^5.9.0",
47
+ "zod": "^4.5.4"
48
+ },
49
+ "devDependencies": {
50
+ "typescript": "^7"
51
+ }
52
+ }