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.
- package/dist/client.d.ts +57 -0
- package/dist/client.js +90 -0
- package/dist/client.js.map +1 -0
- package/dist/events.d.ts +44 -0
- package/dist/events.js +48 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors/appcheck.d.ts +20 -0
- package/dist/interceptors/appcheck.js +29 -0
- package/dist/interceptors/appcheck.js.map +1 -0
- package/dist/interceptors/auth.d.ts +21 -0
- package/dist/interceptors/auth.js +30 -0
- package/dist/interceptors/auth.js.map +1 -0
- package/dist/interceptors/handoff.d.ts +22 -0
- package/dist/interceptors/handoff.js +73 -0
- package/dist/interceptors/handoff.js.map +1 -0
- package/dist/interceptors/interceptor.d.ts +44 -0
- package/dist/interceptors/interceptor.js +20 -0
- package/dist/interceptors/interceptor.js.map +1 -0
- package/dist/interceptors/retry.d.ts +46 -0
- package/dist/interceptors/retry.js +56 -0
- package/dist/interceptors/retry.js.map +1 -0
- package/dist/interceptors/timeout.d.ts +16 -0
- package/dist/interceptors/timeout.js +48 -0
- package/dist/interceptors/timeout.js.map +1 -0
- package/dist/react/useGateway.d.ts +34 -0
- package/dist/react/useGateway.js +36 -0
- package/dist/react/useGateway.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/client.ts +148 -0
- package/src/events.ts +77 -0
- package/src/index.ts +56 -0
- package/src/interceptors/appcheck.ts +33 -0
- package/src/interceptors/auth.ts +34 -0
- package/src/interceptors/handoff.ts +91 -0
- package/src/interceptors/interceptor.ts +53 -0
- package/src/interceptors/retry.ts +92 -0
- package/src/interceptors/timeout.ts +48 -0
- package/src/react/useGateway.ts +67 -0
- package/src/types.ts +112 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
|
|
13
|
+
import type { Scheduler } from "../types.js";
|
|
14
|
+
import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
|
|
15
|
+
|
|
16
|
+
/** Tunable backoff parameters. */
|
|
17
|
+
export interface RetryPolicy {
|
|
18
|
+
/** Maximum *additional* attempts after the first (e.g. `3`). */
|
|
19
|
+
readonly maxRetries: number;
|
|
20
|
+
/** Base delay in milliseconds (e.g. `250`). */
|
|
21
|
+
readonly baseDelayMs: number;
|
|
22
|
+
/** Upper bound on any single backoff sleep. */
|
|
23
|
+
readonly maxDelayMs: number;
|
|
24
|
+
/** Apply full jitter (`random(0, delay)`) when `true`. */
|
|
25
|
+
readonly jitter: boolean;
|
|
26
|
+
/** Statuses worth retrying. Never include `402`/`429`. */
|
|
27
|
+
readonly retryableStatuses: readonly number[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sensible defaults for a Vercel ↔ serverless-Python round trip. */
|
|
31
|
+
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
|
32
|
+
maxRetries: 3,
|
|
33
|
+
baseDelayMs: 250,
|
|
34
|
+
maxDelayMs: 4_000,
|
|
35
|
+
jitter: true,
|
|
36
|
+
retryableStatuses: [500, 502, 503, 504],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Real-time scheduler backing the default client. */
|
|
40
|
+
export const REAL_SCHEDULER: Scheduler = {
|
|
41
|
+
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
42
|
+
now: () => Date.now(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute the backoff for a given zero-based attempt index.
|
|
47
|
+
*
|
|
48
|
+
* @param policy - The active {@link RetryPolicy}.
|
|
49
|
+
* @param attempt - Number of retries already performed (0 for the first retry).
|
|
50
|
+
* @param random - Source of `[0, 1)` randomness (injected for deterministic tests).
|
|
51
|
+
*/
|
|
52
|
+
export function backoffDelayMs(
|
|
53
|
+
policy: RetryPolicy,
|
|
54
|
+
attempt: number,
|
|
55
|
+
random: () => number = Math.random,
|
|
56
|
+
): number {
|
|
57
|
+
const uncapped = policy.baseDelayMs * 2 ** attempt;
|
|
58
|
+
const capped = Math.min(policy.maxDelayMs, uncapped);
|
|
59
|
+
return policy.jitter ? Math.floor(random() * capped) : capped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Options for {@link retryInterceptor}. */
|
|
63
|
+
export interface RetryOptions {
|
|
64
|
+
readonly policy?: RetryPolicy;
|
|
65
|
+
readonly scheduler?: Scheduler;
|
|
66
|
+
readonly random?: () => number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build the backoff/retry {@link Interceptor}. */
|
|
70
|
+
export function retryInterceptor(options: RetryOptions = {}): Interceptor {
|
|
71
|
+
const policy = options.policy ?? DEFAULT_RETRY_POLICY;
|
|
72
|
+
const scheduler = options.scheduler ?? REAL_SCHEDULER;
|
|
73
|
+
const random = options.random ?? Math.random;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
|
|
77
|
+
let response = await next(config);
|
|
78
|
+
let attempt = 0;
|
|
79
|
+
|
|
80
|
+
while (
|
|
81
|
+
policy.retryableStatuses.includes(response.status) &&
|
|
82
|
+
attempt < policy.maxRetries
|
|
83
|
+
) {
|
|
84
|
+
await scheduler.sleep(backoffDelayMs(policy, attempt, random));
|
|
85
|
+
attempt += 1;
|
|
86
|
+
response = await next(config);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return response;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -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
|
+
|
|
11
|
+
import type { GatewayRequestConfig, Interceptor, Send } from "./interceptor.js";
|
|
12
|
+
|
|
13
|
+
/** Thrown when a single transport attempt exceeds its deadline. */
|
|
14
|
+
export class TimeoutError extends Error {
|
|
15
|
+
constructor(timeoutMs: number) {
|
|
16
|
+
super(`request exceeded ${timeoutMs}ms timeout`);
|
|
17
|
+
this.name = "TimeoutError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build the {@link Interceptor} that enforces a per-attempt deadline. */
|
|
22
|
+
export function timeoutInterceptor(): Interceptor {
|
|
23
|
+
return {
|
|
24
|
+
async intercept(config: GatewayRequestConfig, next: Send): Promise<Response> {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
controller.abort();
|
|
30
|
+
}, config.timeoutMs);
|
|
31
|
+
|
|
32
|
+
const external = config.signal;
|
|
33
|
+
if (external) {
|
|
34
|
+
if (external.aborted) controller.abort();
|
|
35
|
+
else external.addEventListener("abort", () => controller.abort(), { once: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await next({ ...config, signal: controller.signal });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (timedOut) throw new TimeoutError(config.timeoutMs);
|
|
42
|
+
throw error;
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
|
|
11
|
+
import { useCallback, useEffect, useState } from "react";
|
|
12
|
+
|
|
13
|
+
import type { GatewayClient, DispatchOptions } from "../client.js";
|
|
14
|
+
import type { DispatchOutcome, GatewayResult, HandoffAction } from "../types.js";
|
|
15
|
+
|
|
16
|
+
/** UI-facing snapshot of an active key-wizard handoff. */
|
|
17
|
+
export interface WizardState {
|
|
18
|
+
readonly provider: string;
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly action: HandoffAction;
|
|
21
|
+
readonly detail?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Return value of {@link useGateway}. */
|
|
25
|
+
export interface UseGatewayResult<T> {
|
|
26
|
+
/** Dispatch a request; resolves to a typed outcome (never rejects). */
|
|
27
|
+
dispatch: (body: unknown, options?: DispatchOptions) => Promise<DispatchOutcome<T>>;
|
|
28
|
+
/** Non-null when the gateway asked the UI to open the key wizard. */
|
|
29
|
+
wizard: WizardState | null;
|
|
30
|
+
/** Dismiss the wizard (e.g. after the user saves a key). */
|
|
31
|
+
dismissWizard: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bind a {@link GatewayClient} to React state.
|
|
36
|
+
*
|
|
37
|
+
* @param client - A stable client instance (create it once, e.g. in a module or
|
|
38
|
+
* a context provider, not inside render).
|
|
39
|
+
*/
|
|
40
|
+
export function useGateway<T = GatewayResult>(
|
|
41
|
+
client: GatewayClient,
|
|
42
|
+
): UseGatewayResult<T> {
|
|
43
|
+
const [wizard, setWizard] = useState<WizardState | null>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const unsubscribe = client.bus.on("handoff", ({ status, handoff }) => {
|
|
47
|
+
if (handoff.action === "trigger_key_wizard") {
|
|
48
|
+
setWizard({
|
|
49
|
+
provider: handoff.provider,
|
|
50
|
+
status,
|
|
51
|
+
action: handoff.action,
|
|
52
|
+
...(handoff.detail !== undefined ? { detail: handoff.detail } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return unsubscribe;
|
|
57
|
+
}, [client]);
|
|
58
|
+
|
|
59
|
+
const dispatch = useCallback(
|
|
60
|
+
(body: unknown, options?: DispatchOptions) => client.dispatch<T>(body, options),
|
|
61
|
+
[client],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const dismissWizard = useCallback(() => setWizard(null), []);
|
|
65
|
+
|
|
66
|
+
return { dispatch, wizard, dismissWizard };
|
|
67
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
|
|
12
|
+
/** A handoff action the gateway can instruct the UI to take. */
|
|
13
|
+
export type HandoffAction = "trigger_key_wizard" | "upgrade_plan" | "retry_later";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The structured Stage-2 onboarding instruction returned by the gateway.
|
|
17
|
+
*
|
|
18
|
+
* Serialised form (from the Python `HandoffResponse.to_payload`):
|
|
19
|
+
* `{"error": "quota_exceeded", "provider": "openai", "action": "trigger_key_wizard"}`.
|
|
20
|
+
*/
|
|
21
|
+
export interface Handoff {
|
|
22
|
+
/** Machine-readable error code, e.g. `"quota_exceeded"`. */
|
|
23
|
+
readonly error: string;
|
|
24
|
+
/** Provider whose limit was hit, so the wizard can pre-select it. */
|
|
25
|
+
readonly provider: string;
|
|
26
|
+
/** What the client UI should do next. */
|
|
27
|
+
readonly action: HandoffAction;
|
|
28
|
+
/** Optional human-readable explanation for display/logging. */
|
|
29
|
+
readonly detail?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A non-handoff error body mapped from a `ModelDispatcherError`. */
|
|
33
|
+
export interface GatewayError {
|
|
34
|
+
/** Stable machine-readable slug, e.g. `"perimeter_violation"`. */
|
|
35
|
+
readonly error: string;
|
|
36
|
+
/** Human-readable description, when the backend supplies one. */
|
|
37
|
+
readonly detail?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Kinds of transport failure that never reached a well-formed HTTP response. */
|
|
41
|
+
export type NetworkErrorKind = "timeout" | "offline" | "retries_exhausted" | "unknown";
|
|
42
|
+
|
|
43
|
+
/** A failure to obtain any HTTP response (before status interpretation). */
|
|
44
|
+
export interface NetworkError {
|
|
45
|
+
readonly kind: NetworkErrorKind;
|
|
46
|
+
/** Human-readable summary for logging/telemetry. */
|
|
47
|
+
readonly message: string;
|
|
48
|
+
/** The underlying thrown value, when one is available. */
|
|
49
|
+
readonly cause?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The result of a dispatch, as a discriminated union.
|
|
54
|
+
*
|
|
55
|
+
* `T` is the shape of a successful gateway run (default {@link GatewayResult}).
|
|
56
|
+
* Callers narrow on `kind`; there is no other legal way to read the payload.
|
|
57
|
+
*/
|
|
58
|
+
export type DispatchOutcome<T = GatewayResult> =
|
|
59
|
+
| { readonly kind: "ok"; readonly result: T }
|
|
60
|
+
| { readonly kind: "handoff"; readonly status: number; readonly handoff: Handoff }
|
|
61
|
+
| { readonly kind: "error"; readonly status: number; readonly error: GatewayError }
|
|
62
|
+
| { readonly kind: "network"; readonly error: NetworkError };
|
|
63
|
+
|
|
64
|
+
/** Token usage reported by a completed run. */
|
|
65
|
+
export interface Usage {
|
|
66
|
+
readonly prompt: number;
|
|
67
|
+
readonly completion: number;
|
|
68
|
+
readonly total: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One provider attempt within a run step (mirrors the demo trace shape). */
|
|
72
|
+
export interface Attempt {
|
|
73
|
+
readonly provider: string;
|
|
74
|
+
readonly error: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One turn of the agent loop. */
|
|
78
|
+
export interface Step {
|
|
79
|
+
readonly message: string | null;
|
|
80
|
+
readonly usage: number;
|
|
81
|
+
readonly attempts: readonly Attempt[];
|
|
82
|
+
}
|
|
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
|
+
|
|
93
|
+
/** Zero-argument async supplier of a Firebase App Check token. */
|
|
94
|
+
export type AppCheckTokenProvider = () => Promise<string | null>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Zero-argument async supplier of a Firebase Auth ID token.
|
|
98
|
+
*
|
|
99
|
+
* Distinct from {@link AppCheckTokenProvider}: App Check attests the *app*,
|
|
100
|
+
* this attests the *end user* — the wrapper derives the authoritative tenant
|
|
101
|
+
* id (and thus quota isolation) from this token's verified `uid`, not from
|
|
102
|
+
* anything in the request body. See `_lib/auth.py`.
|
|
103
|
+
*/
|
|
104
|
+
export type AuthTokenProvider = () => Promise<string | null>;
|
|
105
|
+
|
|
106
|
+
/** Injectable clock/timer seam so retry backoff is deterministic under test. */
|
|
107
|
+
export interface Scheduler {
|
|
108
|
+
/** Resolve after `ms` milliseconds. */
|
|
109
|
+
sleep(ms: number): Promise<void>;
|
|
110
|
+
/** Current epoch milliseconds. */
|
|
111
|
+
now(): number;
|
|
112
|
+
}
|