modeldispatcher-client 0.6.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.
Files changed (45) hide show
  1. package/dist/client.d.ts +57 -0
  2. package/dist/client.js +90 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/events.d.ts +44 -0
  5. package/dist/events.js +48 -0
  6. package/dist/events.js.map +1 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/index.js +19 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/interceptors/appcheck.d.ts +20 -0
  11. package/dist/interceptors/appcheck.js +29 -0
  12. package/dist/interceptors/appcheck.js.map +1 -0
  13. package/dist/interceptors/auth.d.ts +21 -0
  14. package/dist/interceptors/auth.js +30 -0
  15. package/dist/interceptors/auth.js.map +1 -0
  16. package/dist/interceptors/handoff.d.ts +22 -0
  17. package/dist/interceptors/handoff.js +73 -0
  18. package/dist/interceptors/handoff.js.map +1 -0
  19. package/dist/interceptors/interceptor.d.ts +44 -0
  20. package/dist/interceptors/interceptor.js +20 -0
  21. package/dist/interceptors/interceptor.js.map +1 -0
  22. package/dist/interceptors/retry.d.ts +46 -0
  23. package/dist/interceptors/retry.js +56 -0
  24. package/dist/interceptors/retry.js.map +1 -0
  25. package/dist/interceptors/timeout.d.ts +16 -0
  26. package/dist/interceptors/timeout.js +48 -0
  27. package/dist/interceptors/timeout.js.map +1 -0
  28. package/dist/react/useGateway.d.ts +34 -0
  29. package/dist/react/useGateway.js +36 -0
  30. package/dist/react/useGateway.js.map +1 -0
  31. package/dist/types.d.ts +109 -0
  32. package/dist/types.js +12 -0
  33. package/dist/types.js.map +1 -0
  34. package/package.json +52 -0
  35. package/src/client.ts +148 -0
  36. package/src/events.ts +77 -0
  37. package/src/index.ts +56 -0
  38. package/src/interceptors/appcheck.ts +33 -0
  39. package/src/interceptors/auth.ts +34 -0
  40. package/src/interceptors/handoff.ts +91 -0
  41. package/src/interceptors/interceptor.ts +53 -0
  42. package/src/interceptors/retry.ts +92 -0
  43. package/src/interceptors/timeout.ts +48 -0
  44. package/src/react/useGateway.ts +67 -0
  45. package/src/types.ts +112 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Retry interceptor with exponential backoff (Policy object pattern).
3
+ *
4
+ * Re-issues the request when the response status is in
5
+ * {@link RetryPolicy.retryableStatuses} — the 5xx family — sleeping
6
+ * `min(maxDelayMs, baseDelayMs * 2 ** attempt)` between tries, optionally with
7
+ * full jitter to avoid a thundering herd. It deliberately does **not** retry
8
+ * `402`/`429`: those are structured quota handoffs that must reach the UI intact,
9
+ * not transient faults to paper over. Transport errors thrown by inner layers
10
+ * (e.g. a timeout) propagate unchanged so the client can classify them.
11
+ */
12
+ /** Sensible defaults for a Vercel ↔ serverless-Python round trip. */
13
+ export const DEFAULT_RETRY_POLICY = {
14
+ maxRetries: 3,
15
+ baseDelayMs: 250,
16
+ maxDelayMs: 4_000,
17
+ jitter: true,
18
+ retryableStatuses: [500, 502, 503, 504],
19
+ };
20
+ /** Real-time scheduler backing the default client. */
21
+ export const REAL_SCHEDULER = {
22
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
23
+ now: () => Date.now(),
24
+ };
25
+ /**
26
+ * Compute the backoff for a given zero-based attempt index.
27
+ *
28
+ * @param policy - The active {@link RetryPolicy}.
29
+ * @param attempt - Number of retries already performed (0 for the first retry).
30
+ * @param random - Source of `[0, 1)` randomness (injected for deterministic tests).
31
+ */
32
+ export function backoffDelayMs(policy, attempt, random = Math.random) {
33
+ const uncapped = policy.baseDelayMs * 2 ** attempt;
34
+ const capped = Math.min(policy.maxDelayMs, uncapped);
35
+ return policy.jitter ? Math.floor(random() * capped) : capped;
36
+ }
37
+ /** Build the backoff/retry {@link Interceptor}. */
38
+ export function retryInterceptor(options = {}) {
39
+ const policy = options.policy ?? DEFAULT_RETRY_POLICY;
40
+ const scheduler = options.scheduler ?? REAL_SCHEDULER;
41
+ const random = options.random ?? Math.random;
42
+ return {
43
+ async intercept(config, next) {
44
+ let response = await next(config);
45
+ let attempt = 0;
46
+ while (policy.retryableStatuses.includes(response.status) &&
47
+ attempt < policy.maxRetries) {
48
+ await scheduler.sleep(backoffDelayMs(policy, attempt, random));
49
+ attempt += 1;
50
+ response = await next(config);
51
+ }
52
+ return response;
53
+ },
54
+ };
55
+ }
56
+ //# sourceMappingURL=retry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/interceptors/retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAmBH,qEAAqE;AACrE,MAAM,CAAC,MAAM,oBAAoB,GAAgB;IAC/C,UAAU,EAAE,CAAC;IACb,WAAW,EAAE,GAAG;IAChB,UAAU,EAAE,KAAK;IACjB,MAAM,EAAE,IAAI;IACZ,iBAAiB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;CACxC,CAAC;AAEF,sDAAsD;AACtD,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,KAAK,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACxE,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CACtB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,MAAmB,EACnB,OAAe,EACf,SAAuB,IAAI,CAAC,MAAM;IAElC,MAAM,QAAQ,GAAG,MAAM,CAAC,WAAW,GAAG,CAAC,IAAI,OAAO,CAAC;IACnD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IACrD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AAChE,CAAC;AASD,mDAAmD;AACnD,MAAM,UAAU,gBAAgB,CAAC,UAAwB,EAAE;IACzD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,oBAAoB,CAAC;IACtD,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,cAAc,CAAC;IACtD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC;IAE7C,OAAO;QACL,KAAK,CAAC,SAAS,CAAC,MAA4B,EAAE,IAAU;YACtD,IAAI,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,CAAC;YAClC,IAAI,OAAO,GAAG,CAAC,CAAC;YAEhB,OACE,MAAM,CAAC,iBAAiB,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAClD,OAAO,GAAG,MAAM,CAAC,UAAU,EAC3B,CAAC;gBACD,MAAM,SAAS,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;gBAC/D,OAAO,IAAI,CAAC,CAAC;gBACb,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,CAAC;YAChC,CAAC;YAED,OAAO,QAAQ,CAAC;QAClB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Timeout interceptor.
3
+ *
4
+ * Arms an {@link AbortController} that fires after `config.timeoutMs`, and also
5
+ * forwards any caller-supplied `config.signal`, so a slow serverless cold-start
6
+ * cannot hang the UI. When the deadline trips it re-labels the resulting
7
+ * `AbortError` as a {@link TimeoutError} (an external cancellation keeps its
8
+ * original error), which the client maps to a `{ kind: "network" }` outcome.
9
+ */
10
+ import type { Interceptor } from "./interceptor.js";
11
+ /** Thrown when a single transport attempt exceeds its deadline. */
12
+ export declare class TimeoutError extends Error {
13
+ constructor(timeoutMs: number);
14
+ }
15
+ /** Build the {@link Interceptor} that enforces a per-attempt deadline. */
16
+ export declare function timeoutInterceptor(): Interceptor;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Timeout interceptor.
3
+ *
4
+ * Arms an {@link AbortController} that fires after `config.timeoutMs`, and also
5
+ * forwards any caller-supplied `config.signal`, so a slow serverless cold-start
6
+ * cannot hang the UI. When the deadline trips it re-labels the resulting
7
+ * `AbortError` as a {@link TimeoutError} (an external cancellation keeps its
8
+ * original error), which the client maps to a `{ kind: "network" }` outcome.
9
+ */
10
+ /** Thrown when a single transport attempt exceeds its deadline. */
11
+ export class TimeoutError extends Error {
12
+ constructor(timeoutMs) {
13
+ super(`request exceeded ${timeoutMs}ms timeout`);
14
+ this.name = "TimeoutError";
15
+ }
16
+ }
17
+ /** Build the {@link Interceptor} that enforces a per-attempt deadline. */
18
+ export function timeoutInterceptor() {
19
+ return {
20
+ async intercept(config, next) {
21
+ const controller = new AbortController();
22
+ let timedOut = false;
23
+ const timer = setTimeout(() => {
24
+ timedOut = true;
25
+ controller.abort();
26
+ }, config.timeoutMs);
27
+ const external = config.signal;
28
+ if (external) {
29
+ if (external.aborted)
30
+ controller.abort();
31
+ else
32
+ external.addEventListener("abort", () => controller.abort(), { once: true });
33
+ }
34
+ try {
35
+ return await next({ ...config, signal: controller.signal });
36
+ }
37
+ catch (error) {
38
+ if (timedOut)
39
+ throw new TimeoutError(config.timeoutMs);
40
+ throw error;
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ },
46
+ };
47
+ }
48
+ //# sourceMappingURL=timeout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeout.js","sourceRoot":"","sources":["../../src/interceptors/timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,mEAAmE;AACnE,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,SAAiB;QAC3B,KAAK,CAAC,oBAAoB,SAAS,YAAY,CAAC,CAAC;QACjD,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED,0EAA0E;AAC1E,MAAM,UAAU,kBAAkB;IAChC,OAAO;QACL,KAAK,CAAC,SAAS,CAAC,MAA4B,EAAE,IAAU;YACtD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,IAAI,QAAQ,GAAG,KAAK,CAAC;YACrB,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,QAAQ,GAAG,IAAI,CAAC;gBAChB,UAAU,CAAC,KAAK,EAAE,CAAC;YACrB,CAAC,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;YAErB,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC;YAC/B,IAAI,QAAQ,EAAE,CAAC;gBACb,IAAI,QAAQ,CAAC,OAAO;oBAAE,UAAU,CAAC,KAAK,EAAE,CAAC;;oBACpC,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACpF,CAAC;YAED,IAAI,CAAC;gBACH,OAAO,MAAM,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;YAC9D,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAI,QAAQ;oBAAE,MAAM,IAAI,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;gBACvD,MAAM,KAAK,CAAC;YACd,CAAC;oBAAS,CAAC;gBACT,YAAY,CAAC,KAAK,CAAC,CAAC;YACtB,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * React binding for {@link GatewayClient}.
3
+ *
4
+ * Subscribes to the client's event bus and lifts any `trigger_key_wizard`
5
+ * handoff into local state, so a component can render the API-key wizard
6
+ * declaratively without threading the dispatch outcome down through props. The
7
+ * `dispatch` it returns still yields the typed {@link DispatchOutcome} for
8
+ * call-site handling — both paths, one decode.
9
+ */
10
+ import type { GatewayClient, DispatchOptions } from "../client.js";
11
+ import type { DispatchOutcome, GatewayResult, HandoffAction } from "../types.js";
12
+ /** UI-facing snapshot of an active key-wizard handoff. */
13
+ export interface WizardState {
14
+ readonly provider: string;
15
+ readonly status: number;
16
+ readonly action: HandoffAction;
17
+ readonly detail?: string;
18
+ }
19
+ /** Return value of {@link useGateway}. */
20
+ export interface UseGatewayResult<T> {
21
+ /** Dispatch a request; resolves to a typed outcome (never rejects). */
22
+ dispatch: (body: unknown, options?: DispatchOptions) => Promise<DispatchOutcome<T>>;
23
+ /** Non-null when the gateway asked the UI to open the key wizard. */
24
+ wizard: WizardState | null;
25
+ /** Dismiss the wizard (e.g. after the user saves a key). */
26
+ dismissWizard: () => void;
27
+ }
28
+ /**
29
+ * Bind a {@link GatewayClient} to React state.
30
+ *
31
+ * @param client - A stable client instance (create it once, e.g. in a module or
32
+ * a context provider, not inside render).
33
+ */
34
+ export declare function useGateway<T = GatewayResult>(client: GatewayClient): UseGatewayResult<T>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * React binding for {@link GatewayClient}.
3
+ *
4
+ * Subscribes to the client's event bus and lifts any `trigger_key_wizard`
5
+ * handoff into local state, so a component can render the API-key wizard
6
+ * declaratively without threading the dispatch outcome down through props. The
7
+ * `dispatch` it returns still yields the typed {@link DispatchOutcome} for
8
+ * call-site handling — both paths, one decode.
9
+ */
10
+ import { useCallback, useEffect, useState } from "react";
11
+ /**
12
+ * Bind a {@link GatewayClient} to React state.
13
+ *
14
+ * @param client - A stable client instance (create it once, e.g. in a module or
15
+ * a context provider, not inside render).
16
+ */
17
+ export function useGateway(client) {
18
+ const [wizard, setWizard] = useState(null);
19
+ useEffect(() => {
20
+ const unsubscribe = client.bus.on("handoff", ({ status, handoff }) => {
21
+ if (handoff.action === "trigger_key_wizard") {
22
+ setWizard({
23
+ provider: handoff.provider,
24
+ status,
25
+ action: handoff.action,
26
+ ...(handoff.detail !== undefined ? { detail: handoff.detail } : {}),
27
+ });
28
+ }
29
+ });
30
+ return unsubscribe;
31
+ }, [client]);
32
+ const dispatch = useCallback((body, options) => client.dispatch(body, options), [client]);
33
+ const dismissWizard = useCallback(() => setWizard(null), []);
34
+ return { dispatch, wizard, dismissWizard };
35
+ }
36
+ //# sourceMappingURL=useGateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGateway.js","sourceRoot":"","sources":["../../src/react/useGateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAuBzD;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CACxB,MAAqB;IAErB,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAqB,IAAI,CAAC,CAAC;IAE/D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;YACnE,IAAI,OAAO,CAAC,MAAM,KAAK,oBAAoB,EAAE,CAAC;gBAC5C,SAAS,CAAC;oBACR,QAAQ,EAAE,OAAO,CAAC,QAAQ;oBAC1B,MAAM;oBACN,MAAM,EAAE,OAAO,CAAC,MAAM;oBACtB,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACpE,CAAC,CAAC;YACL,CAAC;QACH,CAAC,CAAC,CAAC;QACH,OAAO,WAAW,CAAC;IACrB,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAEb,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,IAAa,EAAE,OAAyB,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAI,IAAI,EAAE,OAAO,CAAC,EAC/E,CAAC,MAAM,CAAC,CACT,CAAC;IAEF,MAAM,aAAa,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;IAE7D,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAC7C,CAAC"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Public type surface for the ModelDispatcher client.
3
+ *
4
+ * These interfaces are the strict, versioned contract between a Vercel frontend
5
+ * and the packaged Python gateway. The response-side types intentionally mirror
6
+ * the gateway's `to_payload()` output (see `src/model_dispatcher/exceptions.py`
7
+ * and `onboarding/handoff.py`): the discriminated {@link DispatchOutcome} is the
8
+ * TypeScript view of "either a run result, a structured onboarding handoff, a
9
+ * mapped HTTP error, or a transport failure".
10
+ */
11
+ /** A handoff action the gateway can instruct the UI to take. */
12
+ export type HandoffAction = "trigger_key_wizard" | "upgrade_plan" | "retry_later";
13
+ /**
14
+ * The structured Stage-2 onboarding instruction returned by the gateway.
15
+ *
16
+ * Serialised form (from the Python `HandoffResponse.to_payload`):
17
+ * `{"error": "quota_exceeded", "provider": "openai", "action": "trigger_key_wizard"}`.
18
+ */
19
+ export interface Handoff {
20
+ /** Machine-readable error code, e.g. `"quota_exceeded"`. */
21
+ readonly error: string;
22
+ /** Provider whose limit was hit, so the wizard can pre-select it. */
23
+ readonly provider: string;
24
+ /** What the client UI should do next. */
25
+ readonly action: HandoffAction;
26
+ /** Optional human-readable explanation for display/logging. */
27
+ readonly detail?: string;
28
+ }
29
+ /** A non-handoff error body mapped from a `ModelDispatcherError`. */
30
+ export interface GatewayError {
31
+ /** Stable machine-readable slug, e.g. `"perimeter_violation"`. */
32
+ readonly error: string;
33
+ /** Human-readable description, when the backend supplies one. */
34
+ readonly detail?: string;
35
+ }
36
+ /** Kinds of transport failure that never reached a well-formed HTTP response. */
37
+ export type NetworkErrorKind = "timeout" | "offline" | "retries_exhausted" | "unknown";
38
+ /** A failure to obtain any HTTP response (before status interpretation). */
39
+ export interface NetworkError {
40
+ readonly kind: NetworkErrorKind;
41
+ /** Human-readable summary for logging/telemetry. */
42
+ readonly message: string;
43
+ /** The underlying thrown value, when one is available. */
44
+ readonly cause?: unknown;
45
+ }
46
+ /**
47
+ * The result of a dispatch, as a discriminated union.
48
+ *
49
+ * `T` is the shape of a successful gateway run (default {@link GatewayResult}).
50
+ * Callers narrow on `kind`; there is no other legal way to read the payload.
51
+ */
52
+ export type DispatchOutcome<T = GatewayResult> = {
53
+ readonly kind: "ok";
54
+ readonly result: T;
55
+ } | {
56
+ readonly kind: "handoff";
57
+ readonly status: number;
58
+ readonly handoff: Handoff;
59
+ } | {
60
+ readonly kind: "error";
61
+ readonly status: number;
62
+ readonly error: GatewayError;
63
+ } | {
64
+ readonly kind: "network";
65
+ readonly error: NetworkError;
66
+ };
67
+ /** Token usage reported by a completed run. */
68
+ export interface Usage {
69
+ readonly prompt: number;
70
+ readonly completion: number;
71
+ readonly total: number;
72
+ }
73
+ /** One provider attempt within a run step (mirrors the demo trace shape). */
74
+ export interface Attempt {
75
+ readonly provider: string;
76
+ readonly error: string | null;
77
+ }
78
+ /** One turn of the agent loop. */
79
+ export interface Step {
80
+ readonly message: string | null;
81
+ readonly usage: number;
82
+ readonly attempts: readonly Attempt[];
83
+ }
84
+ /** Default success shape returned by the reference `/api/gateway` wrapper. */
85
+ export interface GatewayResult {
86
+ readonly final: string | null;
87
+ readonly stop_reason: string;
88
+ readonly complexity: string;
89
+ readonly usage: Usage;
90
+ readonly steps: readonly Step[];
91
+ }
92
+ /** Zero-argument async supplier of a Firebase App Check token. */
93
+ export type AppCheckTokenProvider = () => Promise<string | null>;
94
+ /**
95
+ * Zero-argument async supplier of a Firebase Auth ID token.
96
+ *
97
+ * Distinct from {@link AppCheckTokenProvider}: App Check attests the *app*,
98
+ * this attests the *end user* — the wrapper derives the authoritative tenant
99
+ * id (and thus quota isolation) from this token's verified `uid`, not from
100
+ * anything in the request body. See `_lib/auth.py`.
101
+ */
102
+ export type AuthTokenProvider = () => Promise<string | null>;
103
+ /** Injectable clock/timer seam so retry backoff is deterministic under test. */
104
+ export interface Scheduler {
105
+ /** Resolve after `ms` milliseconds. */
106
+ sleep(ms: number): Promise<void>;
107
+ /** Current epoch milliseconds. */
108
+ now(): number;
109
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public type surface for the ModelDispatcher client.
3
+ *
4
+ * These interfaces are the strict, versioned contract between a Vercel frontend
5
+ * and the packaged Python gateway. The response-side types intentionally mirror
6
+ * the gateway's `to_payload()` output (see `src/model_dispatcher/exceptions.py`
7
+ * and `onboarding/handoff.py`): the discriminated {@link DispatchOutcome} is the
8
+ * TypeScript view of "either a run result, a structured onboarding handoff, a
9
+ * mapped HTTP error, or a transport failure".
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "modeldispatcher-client",
3
+ "version": "0.6.0",
4
+ "description": "Resilient TypeScript client for the ModelDispatcher AI gateway: interceptor pipeline with request timeouts, exponential backoff on 5xx, and structured 402/429 key-wizard handoff decoding.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "types": "./dist/index.d.ts",
8
+ "module": "./dist/index.js",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./react": {
15
+ "types": "./dist/react/useGateway.d.ts",
16
+ "import": "./dist/react/useGateway.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run"
28
+ },
29
+ "keywords": [
30
+ "llm",
31
+ "gateway",
32
+ "interceptor",
33
+ "retry",
34
+ "backoff",
35
+ "firebase-app-check",
36
+ "react"
37
+ ],
38
+ "peerDependencies": {
39
+ "react": ">=18"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "react": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/react": "^18.3.0",
48
+ "react": "^18.3.1",
49
+ "typescript": "^5.5.0",
50
+ "vitest": "^2.0.0"
51
+ }
52
+ }
package/src/client.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * {@link GatewayClient}: the facade a Vercel frontend calls.
3
+ *
4
+ * It composes the interceptor chain once (App Check → Auth → retry → timeout →
5
+ * fetch), then `dispatch` runs a request through it and hands the final
6
+ * response to the {@link decodeOutcome} decoder. Every transport failure is
7
+ * normalised into a `{ kind: "network" }` outcome, so callers only ever
8
+ * `switch` on {@link DispatchOutcome.kind} — no thrown errors escape `dispatch`.
9
+ */
10
+
11
+ import { GatewayEventBus } from "./events.js";
12
+ import { appCheckInterceptor } from "./interceptors/appcheck.js";
13
+ import { authInterceptor } from "./interceptors/auth.js";
14
+ import { decodeOutcome } from "./interceptors/handoff.js";
15
+ import {
16
+ compose,
17
+ type GatewayRequestConfig,
18
+ type Interceptor,
19
+ type Send,
20
+ } from "./interceptors/interceptor.js";
21
+ import { type RetryOptions, retryInterceptor } from "./interceptors/retry.js";
22
+ import { TimeoutError, timeoutInterceptor } from "./interceptors/timeout.js";
23
+ import type {
24
+ AppCheckTokenProvider,
25
+ AuthTokenProvider,
26
+ DispatchOutcome,
27
+ GatewayResult,
28
+ NetworkError,
29
+ NetworkErrorKind,
30
+ } from "./types.js";
31
+
32
+ /** Construction options for {@link GatewayClient}. */
33
+ export interface GatewayClientOptions {
34
+ /** Endpoint the wrapper is deployed at. Defaults to `"/api/gateway"`. */
35
+ endpoint?: string;
36
+ /** Per-attempt timeout in milliseconds. Defaults to `15000`. */
37
+ timeoutMs?: number;
38
+ /** Firebase App Check token supplier. Omit to disable the header (dev). */
39
+ appCheckTokenProvider?: AppCheckTokenProvider;
40
+ /**
41
+ * Firebase Auth ID token supplier. Omit to disable the header (dev only —
42
+ * the wrapper's default `MD_AUTH_MODE=enforce` will reject every request
43
+ * with `401` without it; see `_lib/auth.py`).
44
+ */
45
+ authTokenProvider?: AuthTokenProvider;
46
+ /** Retry/backoff tuning and injected clock (for tests). */
47
+ retry?: RetryOptions;
48
+ /** Shared event bus; a fresh one is created when omitted. */
49
+ bus?: GatewayEventBus;
50
+ /** Injectable `fetch` (defaults to the global). */
51
+ fetchImpl?: typeof fetch;
52
+ }
53
+
54
+ /** Per-call overrides for {@link GatewayClient.dispatch}. */
55
+ export interface DispatchOptions {
56
+ /** Caller cancellation, merged with the internal timeout. */
57
+ signal?: AbortSignal;
58
+ /** Extra headers to merge onto this request. */
59
+ headers?: Record<string, string>;
60
+ }
61
+
62
+ const DEFAULT_ENDPOINT = "/api/gateway";
63
+ const DEFAULT_TIMEOUT_MS = 15_000;
64
+
65
+ function classifyNetworkError(error: unknown): NetworkError {
66
+ if (error instanceof TimeoutError) {
67
+ return { kind: "timeout", message: error.message, cause: error };
68
+ }
69
+ // `fetch` throws a TypeError for DNS/connection failures (offline).
70
+ if (error instanceof TypeError) {
71
+ return { kind: "offline", message: error.message, cause: error };
72
+ }
73
+ const kind: NetworkErrorKind = "unknown";
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ return { kind, message, cause: error };
76
+ }
77
+
78
+ /** Resilient client for the ModelDispatcher gateway endpoint. */
79
+ export class GatewayClient {
80
+ /** Event bus other subscribers (e.g. the wizard host) can listen on. */
81
+ readonly bus: GatewayEventBus;
82
+
83
+ readonly #endpoint: string;
84
+ readonly #timeoutMs: number;
85
+ readonly #send: Send;
86
+
87
+ constructor(options: GatewayClientOptions = {}) {
88
+ this.bus = options.bus ?? new GatewayEventBus();
89
+ this.#endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
90
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
91
+
92
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
93
+
94
+ const chain: Interceptor[] = [];
95
+ if (options.appCheckTokenProvider) {
96
+ chain.push(appCheckInterceptor(options.appCheckTokenProvider));
97
+ }
98
+ if (options.authTokenProvider) {
99
+ chain.push(authInterceptor(options.authTokenProvider));
100
+ }
101
+ chain.push(retryInterceptor(options.retry ?? {}));
102
+ chain.push(timeoutInterceptor());
103
+
104
+ const terminal: Send = (config: GatewayRequestConfig) =>
105
+ fetchImpl(config.url, {
106
+ method: config.method,
107
+ headers: config.headers,
108
+ body: JSON.stringify(config.body),
109
+ ...(config.signal ? { signal: config.signal } : {}),
110
+ });
111
+
112
+ this.#send = compose(chain, terminal);
113
+ }
114
+
115
+ /**
116
+ * Dispatch a request body to the gateway and return a typed outcome.
117
+ *
118
+ * Never rejects: transport failures surface as `{ kind: "network" }`.
119
+ *
120
+ * @typeParam T - Expected success shape (defaults to {@link GatewayResult}).
121
+ */
122
+ async dispatch<T = GatewayResult>(
123
+ body: unknown,
124
+ options: DispatchOptions = {},
125
+ ): Promise<DispatchOutcome<T>> {
126
+ const config: GatewayRequestConfig = {
127
+ url: this.#endpoint,
128
+ method: "POST",
129
+ headers: { "content-type": "application/json", ...options.headers },
130
+ body,
131
+ timeoutMs: this.#timeoutMs,
132
+ ...(options.signal ? { signal: options.signal } : {}),
133
+ };
134
+
135
+ let response: Response;
136
+ try {
137
+ response = await this.#send(config);
138
+ } catch (error) {
139
+ return { kind: "network", error: classifyNetworkError(error) };
140
+ }
141
+ return decodeOutcome<T>(response, this.bus);
142
+ }
143
+ }
144
+
145
+ /** Convenience factory mirroring the constructor. */
146
+ export function createGatewayClient(options: GatewayClientOptions = {}): GatewayClient {
147
+ return new GatewayClient(options);
148
+ }
package/src/events.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A tiny, strongly-typed event bus (Observer pattern).
3
+ *
4
+ * It exists to decouple *detecting* a Stage-2 handoff (deep inside the response
5
+ * pipeline) from *reacting* to it (a wizard component that may live anywhere in
6
+ * the tree). The interceptor publishes; {@link GatewayEventBus} fans the event
7
+ * out to every subscriber without either side knowing about the other.
8
+ */
9
+
10
+ import type { Handoff } from "./types.js";
11
+
12
+ /** Payload emitted whenever the gateway returns a structured handoff. */
13
+ export interface HandoffEvent {
14
+ /** HTTP status the gateway chose: `402` (budget wall) or `429` (rate window). */
15
+ readonly status: number;
16
+ /** The decoded handoff instruction. */
17
+ readonly handoff: Handoff;
18
+ }
19
+
20
+ /** The map of event names to their payload types. */
21
+ export interface GatewayEventMap {
22
+ handoff: HandoffEvent;
23
+ }
24
+
25
+ /** Unsubscribe handle returned by {@link GatewayEventBus.on}. */
26
+ export type Unsubscribe = () => void;
27
+
28
+ /** Listener signature for a given event. */
29
+ export type Listener<E extends keyof GatewayEventMap> = (
30
+ event: GatewayEventMap[E],
31
+ ) => void;
32
+
33
+ /**
34
+ * Synchronous, in-memory pub/sub for gateway events.
35
+ *
36
+ * Listener exceptions are swallowed (and forwarded to `onListenerError` when
37
+ * provided) so one bad subscriber can never break delivery to the others.
38
+ */
39
+ export class GatewayEventBus {
40
+ readonly #listeners: {
41
+ [E in keyof GatewayEventMap]: Set<Listener<E>>;
42
+ } = { handoff: new Set() };
43
+
44
+ readonly #onListenerError: (error: unknown) => void;
45
+
46
+ constructor(onListenerError: (error: unknown) => void = () => {}) {
47
+ this.#onListenerError = onListenerError;
48
+ }
49
+
50
+ /**
51
+ * Subscribe to an event.
52
+ *
53
+ * @returns A function that removes this exact listener when called.
54
+ */
55
+ on<E extends keyof GatewayEventMap>(event: E, listener: Listener<E>): Unsubscribe {
56
+ this.#listeners[event].add(listener);
57
+ return () => {
58
+ this.#listeners[event].delete(listener);
59
+ };
60
+ }
61
+
62
+ /** Publish an event to every current subscriber. */
63
+ emit<E extends keyof GatewayEventMap>(event: E, payload: GatewayEventMap[E]): void {
64
+ for (const listener of this.#listeners[event]) {
65
+ try {
66
+ listener(payload);
67
+ } catch (error) {
68
+ this.#onListenerError(error);
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Remove all listeners (useful in test teardown). */
74
+ clear(): void {
75
+ this.#listeners.handoff.clear();
76
+ }
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `modeldispatcher-client` — resilient TypeScript client for the
3
+ * ModelDispatcher AI gateway.
4
+ *
5
+ * Public surface: construct a {@link GatewayClient} with
6
+ * {@link createGatewayClient}, `dispatch` request bodies, and `switch` on the
7
+ * returned {@link DispatchOutcome}. React apps use `useGateway` from the
8
+ * `modeldispatcher-client/react` subpath. All resilience — timeouts, 5xx
9
+ * backoff, and 402/429 key-wizard handoff decoding — is built in.
10
+ */
11
+
12
+ export { GatewayClient, createGatewayClient } from "./client.js";
13
+ export type { GatewayClientOptions, DispatchOptions } from "./client.js";
14
+
15
+ export { GatewayEventBus } from "./events.js";
16
+ export type {
17
+ GatewayEventMap,
18
+ HandoffEvent,
19
+ Listener,
20
+ Unsubscribe,
21
+ } from "./events.js";
22
+
23
+ export { compose } from "./interceptors/interceptor.js";
24
+ export type { GatewayRequestConfig, Interceptor, Send } from "./interceptors/interceptor.js";
25
+
26
+ export { appCheckInterceptor, APP_CHECK_HEADER } from "./interceptors/appcheck.js";
27
+ export { authInterceptor, AUTHORIZATION_HEADER } from "./interceptors/auth.js";
28
+ export { timeoutInterceptor, TimeoutError } from "./interceptors/timeout.js";
29
+ export {
30
+ retryInterceptor,
31
+ backoffDelayMs,
32
+ DEFAULT_RETRY_POLICY,
33
+ REAL_SCHEDULER,
34
+ } from "./interceptors/retry.js";
35
+ export type { RetryPolicy, RetryOptions } from "./interceptors/retry.js";
36
+ export {
37
+ decodeOutcome,
38
+ isHandoffPayload,
39
+ HANDOFF_ACTIONS,
40
+ } from "./interceptors/handoff.js";
41
+
42
+ export type {
43
+ Attempt,
44
+ AppCheckTokenProvider,
45
+ AuthTokenProvider,
46
+ DispatchOutcome,
47
+ GatewayError,
48
+ GatewayResult,
49
+ Handoff,
50
+ HandoffAction,
51
+ NetworkError,
52
+ NetworkErrorKind,
53
+ Scheduler,
54
+ Step,
55
+ Usage,
56
+ } from "./types.js";