modeldispatcher-client 0.6.0 → 0.6.7

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/src/index.ts CHANGED
@@ -1,56 +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";
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";
@@ -1,33 +1,33 @@
1
- /**
2
- * Firebase App Check interceptor (request side).
3
- *
4
- * Mints/attaches the attestation token the Vercel Python wrapper verifies before
5
- * it will invoke the gateway. The token *provider* is injected rather than
6
- * importing the Firebase SDK directly, so this package carries no hard Firebase
7
- * dependency and stays trivially testable — the app wires
8
- * `() => getToken(appCheck).then(r => r.token)` at construction time.
9
- */
10
-
11
- import type { AppCheckTokenProvider } from "../types.js";
12
- import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
13
-
14
- /** HTTP header the wrapper reads (matches `firebase_admin.app_check`). */
15
- export const APP_CHECK_HEADER = "X-Firebase-AppCheck";
16
-
17
- /**
18
- * Build the interceptor that stamps every request with an App Check token.
19
- *
20
- * A `null` token (provider opted out, or dev bypass) is simply not attached;
21
- * the wrapper then decides whether to reject based on its own environment.
22
- */
23
- export function appCheckInterceptor(provider: AppCheckTokenProvider): Interceptor {
24
- return {
25
- async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
26
- const token = await provider();
27
- if (token) {
28
- config.headers[APP_CHECK_HEADER] = token;
29
- }
30
- return next(config);
31
- },
32
- };
33
- }
1
+ /**
2
+ * Firebase App Check interceptor (request side).
3
+ *
4
+ * Mints/attaches the attestation token the Vercel Python wrapper verifies before
5
+ * it will invoke the gateway. The token *provider* is injected rather than
6
+ * importing the Firebase SDK directly, so this package carries no hard Firebase
7
+ * dependency and stays trivially testable — the app wires
8
+ * `() => getToken(appCheck).then(r => r.token)` at construction time.
9
+ */
10
+
11
+ import type { AppCheckTokenProvider } from "../types.js";
12
+ import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
13
+
14
+ /** HTTP header the wrapper reads (matches `firebase_admin.app_check`). */
15
+ export const APP_CHECK_HEADER = "X-Firebase-AppCheck";
16
+
17
+ /**
18
+ * Build the interceptor that stamps every request with an App Check token.
19
+ *
20
+ * A `null` token (provider opted out, or dev bypass) is simply not attached;
21
+ * the wrapper then decides whether to reject based on its own environment.
22
+ */
23
+ export function appCheckInterceptor(provider: AppCheckTokenProvider): Interceptor {
24
+ return {
25
+ async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
26
+ const token = await provider();
27
+ if (token) {
28
+ config.headers[APP_CHECK_HEADER] = token;
29
+ }
30
+ return next(config);
31
+ },
32
+ };
33
+ }
@@ -1,34 +1,34 @@
1
- /**
2
- * Firebase Auth interceptor (request side).
3
- *
4
- * Mints/attaches the ID token the Vercel Python wrapper verifies to derive an
5
- * *authoritative* tenant id (see `_lib/auth.py`). Mirrors
6
- * {@link appCheckInterceptor} exactly: the token provider is injected rather
7
- * than importing the Firebase SDK directly, so this package carries no hard
8
- * Firebase dependency. Without this header, the wrapper's `MD_AUTH_MODE=enforce`
9
- * default rejects the request with `401` before the gateway ever runs.
10
- */
11
-
12
- import type { AuthTokenProvider } from "../types.js";
13
- import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
14
-
15
- /** HTTP header the wrapper reads the bearer token from. */
16
- export const AUTHORIZATION_HEADER = "Authorization";
17
-
18
- /**
19
- * Build the interceptor that stamps every request with a Firebase Auth ID token.
20
- *
21
- * A `null` token (provider opted out, or dev bypass) is simply not attached;
22
- * the wrapper then decides whether to reject based on its own `MD_AUTH_MODE`.
23
- */
24
- export function authInterceptor(provider: AuthTokenProvider): Interceptor {
25
- return {
26
- async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
27
- const token = await provider();
28
- if (token) {
29
- config.headers[AUTHORIZATION_HEADER] = `Bearer ${token}`;
30
- }
31
- return next(config);
32
- },
33
- };
34
- }
1
+ /**
2
+ * Firebase Auth interceptor (request side).
3
+ *
4
+ * Mints/attaches the ID token the Vercel Python wrapper verifies to derive an
5
+ * *authoritative* tenant id (see `_lib/auth.py`). Mirrors
6
+ * {@link appCheckInterceptor} exactly: the token provider is injected rather
7
+ * than importing the Firebase SDK directly, so this package carries no hard
8
+ * Firebase dependency. Without this header, the wrapper's `MD_AUTH_MODE=enforce`
9
+ * default rejects the request with `401` before the gateway ever runs.
10
+ */
11
+
12
+ import type { AuthTokenProvider } from "../types.js";
13
+ import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
14
+
15
+ /** HTTP header the wrapper reads the bearer token from. */
16
+ export const AUTHORIZATION_HEADER = "Authorization";
17
+
18
+ /**
19
+ * Build the interceptor that stamps every request with a Firebase Auth ID token.
20
+ *
21
+ * A `null` token (provider opted out, or dev bypass) is simply not attached;
22
+ * the wrapper then decides whether to reject based on its own `MD_AUTH_MODE`.
23
+ */
24
+ export function authInterceptor(provider: AuthTokenProvider): Interceptor {
25
+ return {
26
+ async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
27
+ const token = await provider();
28
+ if (token) {
29
+ config.headers[AUTHORIZATION_HEADER] = `Bearer ${token}`;
30
+ }
31
+ return next(config);
32
+ },
33
+ };
34
+ }
@@ -1,91 +1,91 @@
1
- /**
2
- * Response decoder: HTTP {@link Response} → {@link DispatchOutcome}.
3
- *
4
- * This is where the gateway's `to_payload()` contract is interpreted. A `2xx`
5
- * body becomes `{ kind: "ok" }`; a non-OK body whose `action` names a known
6
- * handoff becomes `{ kind: "handoff" }` *and* is published on the event bus so a
7
- * decoupled wizard host can react; anything else becomes `{ kind: "error" }`.
8
- * Runs after the retry interceptor, so only *final* responses reach it.
9
- */
10
-
11
- import type { GatewayEventBus } from "../events.js";
12
- import type {
13
- DispatchOutcome,
14
- GatewayError,
15
- Handoff,
16
- HandoffAction,
17
- } from "../types.js";
18
-
19
- /** The actions this client knows how to surface. */
20
- export const HANDOFF_ACTIONS: readonly HandoffAction[] = [
21
- "trigger_key_wizard",
22
- "upgrade_plan",
23
- "retry_later",
24
- ];
25
-
26
- function isRecord(value: unknown): value is Record<string, unknown> {
27
- return typeof value === "object" && value !== null;
28
- }
29
-
30
- /** Type guard: does an arbitrary parsed body match the {@link Handoff} shape? */
31
- export function isHandoffPayload(body: unknown): body is Handoff {
32
- if (!isRecord(body)) return false;
33
- return (
34
- typeof body["error"] === "string" &&
35
- typeof body["provider"] === "string" &&
36
- typeof body["action"] === "string" &&
37
- (HANDOFF_ACTIONS as readonly string[]).includes(body["action"])
38
- );
39
- }
40
-
41
- /** Parse a JSON body without throwing; returns `undefined` on empty/invalid. */
42
- async function readJson(response: Response): Promise<unknown> {
43
- const text = await response.text();
44
- if (!text) return undefined;
45
- try {
46
- return JSON.parse(text) as unknown;
47
- } catch {
48
- return undefined;
49
- }
50
- }
51
-
52
- function toGatewayError(body: unknown, status: number): GatewayError {
53
- if (isRecord(body) && typeof body["error"] === "string") {
54
- const detail = body["detail"];
55
- return {
56
- error: body["error"],
57
- ...(typeof detail === "string" ? { detail } : {}),
58
- };
59
- }
60
- return { error: "unexpected_response", detail: `HTTP ${status}` };
61
- }
62
-
63
- /**
64
- * Decode a final response into a typed outcome.
65
- *
66
- * @param response - The response emerging from the interceptor chain.
67
- * @param bus - Event bus a handoff is published on before returning.
68
- */
69
- export async function decodeOutcome<T>(
70
- response: Response,
71
- bus: GatewayEventBus,
72
- ): Promise<DispatchOutcome<T>> {
73
- const body = await readJson(response);
74
-
75
- if (response.ok) {
76
- return { kind: "ok", result: body as T };
77
- }
78
-
79
- if (isHandoffPayload(body)) {
80
- const handoff: Handoff = {
81
- error: body.error,
82
- provider: body.provider,
83
- action: body.action,
84
- ...(body.detail !== undefined ? { detail: body.detail } : {}),
85
- };
86
- bus.emit("handoff", { status: response.status, handoff });
87
- return { kind: "handoff", status: response.status, handoff };
88
- }
89
-
90
- return { kind: "error", status: response.status, error: toGatewayError(body, response.status) };
91
- }
1
+ /**
2
+ * Response decoder: HTTP {@link Response} → {@link DispatchOutcome}.
3
+ *
4
+ * This is where the gateway's `to_payload()` contract is interpreted. A `2xx`
5
+ * body becomes `{ kind: "ok" }`; a non-OK body whose `action` names a known
6
+ * handoff becomes `{ kind: "handoff" }` *and* is published on the event bus so a
7
+ * decoupled wizard host can react; anything else becomes `{ kind: "error" }`.
8
+ * Runs after the retry interceptor, so only *final* responses reach it.
9
+ */
10
+
11
+ import type { GatewayEventBus } from "../events.js";
12
+ import type {
13
+ DispatchOutcome,
14
+ GatewayError,
15
+ Handoff,
16
+ HandoffAction,
17
+ } from "../types.js";
18
+
19
+ /** The actions this client knows how to surface. */
20
+ export const HANDOFF_ACTIONS: readonly HandoffAction[] = [
21
+ "trigger_key_wizard",
22
+ "upgrade_plan",
23
+ "retry_later",
24
+ ];
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null;
28
+ }
29
+
30
+ /** Type guard: does an arbitrary parsed body match the {@link Handoff} shape? */
31
+ export function isHandoffPayload(body: unknown): body is Handoff {
32
+ if (!isRecord(body)) return false;
33
+ return (
34
+ typeof body["error"] === "string" &&
35
+ typeof body["provider"] === "string" &&
36
+ typeof body["action"] === "string" &&
37
+ (HANDOFF_ACTIONS as readonly string[]).includes(body["action"])
38
+ );
39
+ }
40
+
41
+ /** Parse a JSON body without throwing; returns `undefined` on empty/invalid. */
42
+ async function readJson(response: Response): Promise<unknown> {
43
+ const text = await response.text();
44
+ if (!text) return undefined;
45
+ try {
46
+ return JSON.parse(text) as unknown;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function toGatewayError(body: unknown, status: number): GatewayError {
53
+ if (isRecord(body) && typeof body["error"] === "string") {
54
+ const detail = body["detail"];
55
+ return {
56
+ error: body["error"],
57
+ ...(typeof detail === "string" ? { detail } : {}),
58
+ };
59
+ }
60
+ return { error: "unexpected_response", detail: `HTTP ${status}` };
61
+ }
62
+
63
+ /**
64
+ * Decode a final response into a typed outcome.
65
+ *
66
+ * @param response - The response emerging from the interceptor chain.
67
+ * @param bus - Event bus a handoff is published on before returning.
68
+ */
69
+ export async function decodeOutcome<T>(
70
+ response: Response,
71
+ bus: GatewayEventBus,
72
+ ): Promise<DispatchOutcome<T>> {
73
+ const body = await readJson(response);
74
+
75
+ if (response.ok) {
76
+ return { kind: "ok", result: body as T };
77
+ }
78
+
79
+ if (isHandoffPayload(body)) {
80
+ const handoff: Handoff = {
81
+ error: body.error,
82
+ provider: body.provider,
83
+ action: body.action,
84
+ ...(body.detail !== undefined ? { detail: body.detail } : {}),
85
+ };
86
+ bus.emit("handoff", { status: response.status, handoff });
87
+ return { kind: "handoff", status: response.status, handoff };
88
+ }
89
+
90
+ return { kind: "error", status: response.status, error: toGatewayError(body, response.status) };
91
+ }
@@ -1,53 +1,53 @@
1
- /**
2
- * Interceptor pipeline contracts (Chain of Responsibility).
3
- *
4
- * Each {@link Interceptor} receives the outgoing request config and a `next`
5
- * continuation. It may mutate the config, short-circuit, wrap `next` with
6
- * cross-cutting behaviour (timeout, retry), and inspect the resulting response
7
- * before returning it. Interceptors are composed as an onion: the first in the
8
- * list is the outermost layer.
9
- */
10
-
11
- /** The in-flight request description handed down the interceptor chain. */
12
- export interface GatewayRequestConfig {
13
- /** Absolute or app-relative endpoint, e.g. `"/api/gateway"`. */
14
- url: string;
15
- /** HTTP method; the gateway endpoint is `POST`. */
16
- method: string;
17
- /** Mutable header bag; interceptors add to it (App Check, content-type). */
18
- headers: Record<string, string>;
19
- /** JSON-serialisable request body. */
20
- body: unknown;
21
- /** Per-attempt timeout in milliseconds. */
22
- timeoutMs: number;
23
- /** Optional caller-supplied cancellation signal, merged with the timeout. */
24
- signal?: AbortSignal;
25
- }
26
-
27
- /** Terminal continuation: performs exactly one transport attempt. */
28
- export type Send = (config: GatewayRequestConfig) => Promise<Response>;
29
-
30
- /** A single layer of the request/response pipeline. */
31
- export interface Interceptor {
32
- /**
33
- * Process the request and return a response.
34
- *
35
- * @param config - The (mutable) request configuration.
36
- * @param next - Continuation invoking the remaining inner layers.
37
- */
38
- intercept(config: GatewayRequestConfig, next: Send): Promise<Response>;
39
- }
40
-
41
- /**
42
- * Fold a list of interceptors around a terminal {@link Send} into one `Send`.
43
- *
44
- * The first interceptor becomes the outermost layer, so a list of
45
- * `[appcheck, retry, timeout]` yields `appcheck(retry(timeout(fetch)))` — retry
46
- * re-drives the timeout+transport on each attempt.
47
- */
48
- export function compose(interceptors: readonly Interceptor[], terminal: Send): Send {
49
- return interceptors.reduceRight<Send>(
50
- (next, interceptor) => (config) => interceptor.intercept(config, next),
51
- terminal,
52
- );
53
- }
1
+ /**
2
+ * Interceptor pipeline contracts (Chain of Responsibility).
3
+ *
4
+ * Each {@link Interceptor} receives the outgoing request config and a `next`
5
+ * continuation. It may mutate the config, short-circuit, wrap `next` with
6
+ * cross-cutting behaviour (timeout, retry), and inspect the resulting response
7
+ * before returning it. Interceptors are composed as an onion: the first in the
8
+ * list is the outermost layer.
9
+ */
10
+
11
+ /** The in-flight request description handed down the interceptor chain. */
12
+ export interface GatewayRequestConfig {
13
+ /** Absolute or app-relative endpoint, e.g. `"/api/gateway"`. */
14
+ url: string;
15
+ /** HTTP method; the gateway endpoint is `POST`. */
16
+ method: string;
17
+ /** Mutable header bag; interceptors add to it (App Check, content-type). */
18
+ headers: Record<string, string>;
19
+ /** JSON-serialisable request body. */
20
+ body: unknown;
21
+ /** Per-attempt timeout in milliseconds. */
22
+ timeoutMs: number;
23
+ /** Optional caller-supplied cancellation signal, merged with the timeout. */
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ /** Terminal continuation: performs exactly one transport attempt. */
28
+ export type Send = (config: GatewayRequestConfig) => Promise<Response>;
29
+
30
+ /** A single layer of the request/response pipeline. */
31
+ export interface Interceptor {
32
+ /**
33
+ * Process the request and return a response.
34
+ *
35
+ * @param config - The (mutable) request configuration.
36
+ * @param next - Continuation invoking the remaining inner layers.
37
+ */
38
+ intercept(config: GatewayRequestConfig, next: Send): Promise<Response>;
39
+ }
40
+
41
+ /**
42
+ * Fold a list of interceptors around a terminal {@link Send} into one `Send`.
43
+ *
44
+ * The first interceptor becomes the outermost layer, so a list of
45
+ * `[appcheck, retry, timeout]` yields `appcheck(retry(timeout(fetch)))` — retry
46
+ * re-drives the timeout+transport on each attempt.
47
+ */
48
+ export function compose(interceptors: readonly Interceptor[], terminal: Send): Send {
49
+ return interceptors.reduceRight<Send>(
50
+ (next, interceptor) => (config) => interceptor.intercept(config, next),
51
+ terminal,
52
+ );
53
+ }