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.
@@ -1,92 +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
- }
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
+ }
@@ -1,48 +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
- }
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
+ }
@@ -1,67 +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
- }
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 CHANGED
@@ -1,112 +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
- }
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
+ }