@juno-ai/bind 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juno AI Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # bind
2
+
3
+ **`@juno-ai/bind` — an agent harness.**
4
+
5
+ The agent loop is a bind chain: each turn sequences a model completion into
6
+ tool effects into the next turn's context. `bind` is the harness that runs
7
+ that chain — the runtime-agnostic core of a production agent loop, extracted
8
+ from [Monad](https://onmonad.ai).
9
+
10
+ ## Status
11
+
12
+ Early. The package currently ships the **deterministic LLM provider-routing
13
+ core**; the turn kernel, tool/skill contracts, and the `runAgent` /
14
+ `advanceTurn` drivers are being extracted next. Expect breaking changes in
15
+ any release.
16
+
17
+ **Versioning is not semver.** Each published release increments the major and
18
+ resets the rest — `1.0.0`, `2.0.0`, `3.0.0` — so the major is a release
19
+ counter, not a compatibility signal, and a bump does not by itself mean the
20
+ surface changed. Pin an exact version and read the changes between releases
21
+ until this stabilizes.
22
+
23
+ ## What's here today: provider routing
24
+
25
+ Expand one canonical model selection (OpenRouter-style `vendor/model-slug`
26
+ ids) into an ordered, immutable, secret-free **route plan** across providers,
27
+ then drive attempts over it with a normative failure policy:
28
+
29
+ - **`ProviderPolicy`** — ordered provider preference per model, or a hard
30
+ `only` fence that pins a model to a single provider (useful for evals and
31
+ compliance).
32
+ - **`buildRoutePlan`** — deterministic planner: policy order is the only
33
+ ordering input; capability mismatches and configuration gaps become
34
+ recorded skips, never network attempts.
35
+ - **`executeRoutePlan`** — the attempt loop: model stage → provider candidate
36
+ → bounded same-endpoint retries, with an exhaustive failure-disposition
37
+ matrix (`failureDisposition`) deciding retry / next-provider /
38
+ fallback-model / propagate.
39
+ - **Circuit breaker** — per `(provider, invocation model, credential source)`
40
+ endpoint, with half-open probes, bounded Retry-After cooldowns, and an
41
+ injectable clock.
42
+
43
+ Transports (the actual HTTP clients), credentials, pricing policy, and
44
+ persistence stay with the host application — the harness never reads the
45
+ environment, never touches a filesystem, and holds no secrets, which is what
46
+ keeps it portable across Bun, Node, and edge runtimes such as Cloudflare
47
+ workerd.
48
+
49
+ ```ts
50
+ import {
51
+ buildRoutePlan,
52
+ executeRoutePlan,
53
+ createCircuitBreaker,
54
+ canonicalModelIdSchema,
55
+ } from "@juno-ai/bind/routing";
56
+
57
+ const { plan, skips } = buildRoutePlan({
58
+ primaryModel: canonicalModelIdSchema.parse("openai/gpt-example"),
59
+ fallbackModel: null,
60
+ requirements: { capabilities: new Set(["chat_completions", "streaming"]), requestedMaxCompletionTokens: null },
61
+ policyFor: (model) => resolveMyPolicy(model),
62
+ transports: myTransportMap, // availability + endpoint resolution per provider
63
+ });
64
+
65
+ const result = await executeRoutePlan({
66
+ plan,
67
+ breaker: createCircuitBreaker(),
68
+ attempt: async (candidate, cursor) => myTransportAttempt(candidate, cursor),
69
+ });
70
+ ```
71
+
72
+ ## Design rules
73
+
74
+ - **Pure by construction.** No `process`, no Node builtins, no framework
75
+ imports, no I/O except through injected functions. Enforced by lint in the
76
+ source-of-truth repository.
77
+ - **Deterministic.** Identical inputs produce identical plans; nothing about
78
+ registration order, map iteration, or wall-clock time reorders candidates.
79
+ - **Explicit failure policy.** Every classified failure maps through one
80
+ exhaustive disposition table; transports classify facts, they never decide
81
+ route order.
82
+ - **zod is a peer dependency** — bring your own instance (v4+).
83
+
84
+ ## Development
85
+
86
+ This repository is a read-only **archive mirror** of the `packages/bind`
87
+ workspace in Monad's canonical repository, exported via Copybara. npm
88
+ releases of `@juno-ai/bind` are published from the canonical repository, not
89
+ from here — the manifest here stays `"private": true` so it cannot be
90
+ published by accident, though the version it carries is the one the matching
91
+ npm release has. Issues are welcome here; code changes land in the canonical
92
+ repo and flow out with the next export.
93
+
94
+ Run the tests with [Bun](https://bun.sh):
95
+
96
+ ```sh
97
+ bun test
98
+ ```
99
+
100
+ ## License
101
+
102
+ [MIT](./LICENSE)
@@ -0,0 +1 @@
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn";
@@ -0,0 +1 @@
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, } from "./turn";
@@ -0,0 +1,79 @@
1
+ import type OpenAI from "openai";
2
+ /**
3
+ * Turn vocabulary — the shared language between the turn kernel (arriving in
4
+ * a later extraction phase), LLM transports, and hosts.
5
+ *
6
+ * The declared wire format is the OpenAI chat-completions message shape,
7
+ * consumed as **types only** (`openai` is a peer used purely for its type
8
+ * declarations here; no runtime import). Hosts on other client stacks (e.g.
9
+ * the Vercel AI SDK) adapt at the turn-function boundary.
10
+ *
11
+ * NOTE: `ToolPlugin` / tool-context contracts deliberately do NOT live here
12
+ * yet — they are being reshaped by the single-agent consolidation work in
13
+ * the host repo and move here once that lands.
14
+ */
15
+ export type TranscriptMessage = OpenAI.ChatCompletionMessageParam;
16
+ export type AssistantTurnMessage = OpenAI.ChatCompletionMessage;
17
+ export type WireToolDefinition = OpenAI.ChatCompletionTool;
18
+ export type WireToolCall = OpenAI.ChatCompletionMessageToolCall;
19
+ /**
20
+ * Per-turn latency/throughput measurements. Field shapes deliberately match
21
+ * the metrics the AA/StirrupJS harness reports (`speedStats`) so numbers are
22
+ * directly comparable with published benchmark methodology:
23
+ * time-to-first-token, generation wall time, and output tokens/second —
24
+ * plus the model-time vs tool-time split that per-task wall-clock hides.
25
+ */
26
+ export interface TurnTimings {
27
+ /** ms from request start to the first streamed token, if streaming. */
28
+ readonly ttftMs: number | null;
29
+ /** ms from request start to the completed response. */
30
+ readonly generationMs: number;
31
+ }
32
+ export interface TurnUsage {
33
+ readonly inputTokens: number;
34
+ readonly outputTokens: number;
35
+ /** Provider-reported cached input tokens, when available. */
36
+ readonly cachedInputTokens: number | null;
37
+ /** Billing-basis cost in USD cents, when the transport can report it. */
38
+ readonly costCents: number | null;
39
+ }
40
+ /** One model completion's message + usage + timings. */
41
+ export interface ModelTurnResult {
42
+ readonly message: AssistantTurnMessage;
43
+ readonly usage: TurnUsage;
44
+ readonly timings: TurnTimings;
45
+ }
46
+ /**
47
+ * The turn function — the seam between the loop and any LLM client. The
48
+ * kernel never imports an LLM client; it calls this. Implementations own
49
+ * retries, provider routing, and streaming internally and return one
50
+ * completed assistant turn.
51
+ */
52
+ export type TurnFn = (messages: readonly TranscriptMessage[], tools: readonly WireToolDefinition[] | undefined, signal: AbortSignal | undefined) => Promise<ModelTurnResult>;
53
+ /** Why a run stopped. `suspended` = a tool intentionally paused the run. */
54
+ export type StopReason = "done" | "suspended" | "iteration_limit" | "deadline" | "aborted";
55
+ /**
56
+ * Cumulative run accounting: model-time and tool-time reported separately —
57
+ * task wall-clock conflates provider inference speed with tool execution,
58
+ * and consumers comparing models need the model's contribution isolated.
59
+ */
60
+ export interface RunStats {
61
+ readonly turns: number;
62
+ readonly toolCalls: number;
63
+ readonly inputTokens: number;
64
+ readonly outputTokens: number;
65
+ readonly costCents: number;
66
+ /** Sum of model `generationMs` across turns. */
67
+ readonly modelTimeMs: number;
68
+ /** Sum of tool execution durations across dispatched calls. */
69
+ readonly toolTimeMs: number;
70
+ /** Output tokens per second of model time, null before any output. */
71
+ readonly outputTokensPerSecond: number | null;
72
+ /** Per-tool total execution ms, keyed by tool name. */
73
+ readonly toolTimeBreakdownMs: Readonly<Record<string, number>>;
74
+ }
75
+ export declare function emptyRunStats(): RunStats;
76
+ /** Fold one completed model turn into cumulative run stats. */
77
+ export declare function accumulateTurn(stats: RunStats, turn: ModelTurnResult): RunStats;
78
+ /** Fold one dispatched tool call's duration into cumulative run stats. */
79
+ export declare function accumulateToolCall(stats: RunStats, toolName: string, durationMs: number): RunStats;
@@ -0,0 +1,39 @@
1
+ export function emptyRunStats() {
2
+ return {
3
+ turns: 0,
4
+ toolCalls: 0,
5
+ inputTokens: 0,
6
+ outputTokens: 0,
7
+ costCents: 0,
8
+ modelTimeMs: 0,
9
+ toolTimeMs: 0,
10
+ outputTokensPerSecond: null,
11
+ toolTimeBreakdownMs: {},
12
+ };
13
+ }
14
+ /** Fold one completed model turn into cumulative run stats. */
15
+ export function accumulateTurn(stats, turn) {
16
+ const outputTokens = stats.outputTokens + turn.usage.outputTokens;
17
+ const modelTimeMs = stats.modelTimeMs + turn.timings.generationMs;
18
+ return {
19
+ ...stats,
20
+ turns: stats.turns + 1,
21
+ inputTokens: stats.inputTokens + turn.usage.inputTokens,
22
+ outputTokens,
23
+ costCents: stats.costCents + (turn.usage.costCents ?? 0),
24
+ modelTimeMs,
25
+ outputTokensPerSecond: modelTimeMs > 0 ? (outputTokens / modelTimeMs) * 1000 : null,
26
+ };
27
+ }
28
+ /** Fold one dispatched tool call's duration into cumulative run stats. */
29
+ export function accumulateToolCall(stats, toolName, durationMs) {
30
+ return {
31
+ ...stats,
32
+ toolCalls: stats.toolCalls + 1,
33
+ toolTimeMs: stats.toolTimeMs + durationMs,
34
+ toolTimeBreakdownMs: {
35
+ ...stats.toolTimeBreakdownMs,
36
+ [toolName]: (stats.toolTimeBreakdownMs[toolName] ?? 0) + durationMs,
37
+ },
38
+ };
39
+ }
package/index.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @juno-ai/bind — the agent harness.
3
+ *
4
+ * Why "bind": the agent loop is a bind chain — each turn sequences a model
5
+ * completion into tool effects into the next turn's context. This package is
6
+ * the harness that runs that chain.
7
+ *
8
+ * Current surface: the deterministic LLM provider-routing core (see
9
+ * `docs/bind.md` and the LLM Provider Routing PRD). The turn kernel and tool
10
+ * contracts move here in later extraction phases.
11
+ */
12
+ export * from "./routing/index";
13
+ export * from "./contracts/index";
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @juno-ai/bind — the agent harness.
3
+ *
4
+ * Why "bind": the agent loop is a bind chain — each turn sequences a model
5
+ * completion into tool effects into the next turn's context. This package is
6
+ * the harness that runs that chain.
7
+ *
8
+ * Current surface: the deterministic LLM provider-routing core (see
9
+ * `docs/bind.md` and the LLM Provider Routing PRD). The turn kernel and tool
10
+ * contracts move here in later extraction phases.
11
+ */
12
+ export * from "./routing/index";
13
+ export * from "./contracts/index";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@juno-ai/bind",
3
+ "version": "1.0.0",
4
+ "description": "Agent harness: deterministic LLM provider routing core and turn contracts (turn kernel arrives in later phases). MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "import": "./index.js"
13
+ },
14
+ "./routing": {
15
+ "types": "./routing/index.d.ts",
16
+ "import": "./routing/index.js"
17
+ },
18
+ "./contracts": {
19
+ "types": "./contracts/index.d.ts",
20
+ "import": "./contracts/index.js"
21
+ }
22
+ },
23
+ "peerDependencies": {
24
+ "zod": "^4.0.0",
25
+ "openai": "^6.0.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "openai": {
29
+ "optional": true
30
+ }
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/juno-ai-labs/agent-harness.git"
35
+ },
36
+ "keywords": [
37
+ "agent",
38
+ "harness",
39
+ "agent-loop",
40
+ "llm",
41
+ "routing"
42
+ ]
43
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Canonical model identity is the OpenRouter-style `vendor/model-slug` id
4
+ * (LLM Provider Routing PRD §4.1). Provider invocation ids (e.g. an Azure
5
+ * deployment name) are a separate concept and must never be parsed with this
6
+ * schema — the regex rejects them because they carry no `/` segment.
7
+ */
8
+ export declare const canonicalModelIdSchema: z.core.$ZodBranded<z.ZodString, "CanonicalModelId", "out">;
9
+ export type CanonicalModelId = z.infer<typeof canonicalModelIdSchema>;
10
+ /**
11
+ * Provider ids are lower-case slugs (`openrouter`, `azure`, …). The harness
12
+ * is generic over the set of providers; the host narrows it by which
13
+ * transports it registers. Policy validation rejects ids that name no
14
+ * registered transport at plan time, not here.
15
+ */
16
+ export declare const providerIdSchema: z.core.$ZodBranded<z.ZodString, "ProviderId", "out">;
17
+ export type ProviderId = z.infer<typeof providerIdSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Canonical model identity is the OpenRouter-style `vendor/model-slug` id
4
+ * (LLM Provider Routing PRD §4.1). Provider invocation ids (e.g. an Azure
5
+ * deployment name) are a separate concept and must never be parsed with this
6
+ * schema — the regex rejects them because they carry no `/` segment.
7
+ */
8
+ export const canonicalModelIdSchema = z
9
+ .string()
10
+ .trim()
11
+ .regex(/^[^/\s]+\/[^/\s]+$/, "expected an OpenRouter-style vendor/model id")
12
+ .brand("CanonicalModelId");
13
+ /**
14
+ * Provider ids are lower-case slugs (`openrouter`, `azure`, …). The harness
15
+ * is generic over the set of providers; the host narrows it by which
16
+ * transports it registers. Policy validation rejects ids that name no
17
+ * registered transport at plan time, not here.
18
+ */
19
+ export const providerIdSchema = z
20
+ .string()
21
+ .regex(/^[a-z][a-z0-9_-]*$/, "provider ids are lower-case slugs")
22
+ .brand("ProviderId");
@@ -0,0 +1,67 @@
1
+ import type { ProviderId } from "./canonical-model";
2
+ import type { CredentialSource } from "./plan";
3
+ /**
4
+ * Circuit-breaker state is keyed by (provider, invocation model, credential
5
+ * source) — LLM Provider Routing PRD §7.2. State is process-local in v1;
6
+ * deterministic routing and provider telemetry make replica differences
7
+ * observable.
8
+ */
9
+ export interface BreakerKey {
10
+ readonly providerId: ProviderId;
11
+ readonly invocationModel: string;
12
+ readonly credentialSource: CredentialSource;
13
+ /**
14
+ * Optional non-secret credential identity (e.g. an opaque per-tenant tag)
15
+ * that further scopes the endpoint state. Without it, one tenant's revoked
16
+ * BYOK key would open the circuit for every tenant whose calls share
17
+ * `credentialSource: "tenant"` on this process. Never put key material here
18
+ * — the value lands in breaker state keys.
19
+ */
20
+ readonly credentialScope?: string;
21
+ }
22
+ export interface CircuitBreakerOptions {
23
+ /** Consecutive fallbackable failures before the circuit opens. Default 3. */
24
+ readonly failureThreshold?: number;
25
+ /** Default cooldown once open, in ms. Default 60_000. */
26
+ readonly cooldownMs?: number;
27
+ /** Upper bound on any cooldown, including Retry-After extensions. Default 60_000. */
28
+ readonly maxCooldownMs?: number;
29
+ /** Injectable clock for tests. Defaults to Date.now. */
30
+ readonly now?: () => number;
31
+ }
32
+ export type EndpointAdmission = Readonly<{
33
+ admitted: true;
34
+ halfOpenProbe: boolean;
35
+ }> | Readonly<{
36
+ admitted: false;
37
+ retryAtMs: number;
38
+ }>;
39
+ export interface RecordFailureOptions {
40
+ /**
41
+ * Open immediately regardless of the consecutive count — used for
42
+ * credential/credit failures, which repeated requests cannot repair.
43
+ */
44
+ readonly openImmediately?: boolean;
45
+ /** Bounded Retry-After hint; extends the cooldown up to `maxCooldownMs`. */
46
+ readonly retryAfterMs?: number | null;
47
+ }
48
+ export interface RouteCircuitBreaker {
49
+ /**
50
+ * Ask to attempt an endpoint. Closed circuits admit; open circuits refuse
51
+ * until cooldown elapses; after cooldown exactly one caller is admitted as
52
+ * a half-open probe while others keep being refused until the probe
53
+ * resolves via `recordSuccess`/`recordFailure`.
54
+ */
55
+ admit(key: BreakerKey): EndpointAdmission;
56
+ recordSuccess(key: BreakerKey): void;
57
+ recordFailure(key: BreakerKey, options?: RecordFailureOptions): void;
58
+ /**
59
+ * Resolve a half-open probe that ended without a success or a
60
+ * breaker-recordable failure (an abort, a propagated client error). The
61
+ * probe slot is freed and the cooldown re-armed, so the next admit after
62
+ * cooldown runs a fresh probe instead of refusing forever. No-op when no
63
+ * probe is in flight.
64
+ */
65
+ releaseProbe(key: BreakerKey): void;
66
+ }
67
+ export declare function createCircuitBreaker(options?: CircuitBreakerOptions): RouteCircuitBreaker;
@@ -0,0 +1,75 @@
1
+ function keyString(key) {
2
+ return `${key.providerId} ${key.invocationModel} ${key.credentialSource} ${key.credentialScope ?? ""}`;
3
+ }
4
+ export function createCircuitBreaker(options = {}) {
5
+ const failureThreshold = options.failureThreshold ?? 3;
6
+ const cooldownMs = options.cooldownMs ?? 60_000;
7
+ const maxCooldownMs = options.maxCooldownMs ?? 60_000;
8
+ const now = options.now ?? (() => Date.now());
9
+ const states = new Map();
10
+ function stateFor(key) {
11
+ const k = keyString(key);
12
+ const existing = states.get(k);
13
+ if (existing !== undefined)
14
+ return existing;
15
+ const created = {
16
+ consecutiveFailures: 0,
17
+ openUntilMs: 0,
18
+ probeInFlight: false,
19
+ };
20
+ states.set(k, created);
21
+ return created;
22
+ }
23
+ function open(state, retryAfterMs) {
24
+ const extension = Math.max(cooldownMs, retryAfterMs ?? 0);
25
+ state.openUntilMs = now() + Math.min(extension, maxCooldownMs);
26
+ state.probeInFlight = false;
27
+ }
28
+ return {
29
+ admit(key) {
30
+ // No stored state = healthy endpoint. Don't allocate on the happy
31
+ // path: keys include an unbounded per-tenant scope, and inserting on
32
+ // every admit would grow the map monotonically for the process
33
+ // lifetime (three reviewers independently flagged the leak).
34
+ const existing = states.get(keyString(key));
35
+ if (existing === undefined)
36
+ return { admitted: true, halfOpenProbe: false };
37
+ const state = existing;
38
+ if (state.openUntilMs === 0)
39
+ return { admitted: true, halfOpenProbe: false };
40
+ const at = now();
41
+ if (at < state.openUntilMs)
42
+ return { admitted: false, retryAtMs: state.openUntilMs };
43
+ // Probe in flight past cooldown: the stored deadline is already in the
44
+ // past, so report "try again now-ish" rather than a stale timestamp.
45
+ if (state.probeInFlight) {
46
+ return { admitted: false, retryAtMs: Math.max(state.openUntilMs, at) };
47
+ }
48
+ state.probeInFlight = true;
49
+ return { admitted: true, halfOpenProbe: true };
50
+ },
51
+ recordSuccess(key) {
52
+ // A clean endpoint needs no state — drop the entry so the map only
53
+ // ever holds currently-failing/cooling endpoints.
54
+ states.delete(keyString(key));
55
+ },
56
+ recordFailure(key, failureOptions = {}) {
57
+ const state = stateFor(key);
58
+ state.consecutiveFailures += 1;
59
+ const shouldOpen = failureOptions.openImmediately === true ||
60
+ state.consecutiveFailures >= failureThreshold ||
61
+ // A failed half-open probe reopens regardless of the counter.
62
+ state.probeInFlight;
63
+ if (shouldOpen)
64
+ open(state, failureOptions.retryAfterMs);
65
+ },
66
+ releaseProbe(key) {
67
+ const state = stateFor(key);
68
+ if (!state.probeInFlight)
69
+ return;
70
+ // Re-arm the cooldown rather than closing: the probe told us nothing,
71
+ // and immediately admitting another caller would thrash the endpoint.
72
+ open(state, null);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,95 @@
1
+ import type { CanonicalModelId, ProviderId } from "./canonical-model";
2
+ /**
3
+ * The normative attempt-order cursor (LLM Provider Routing PRD §5.2):
4
+ * structured-output attempt → model stage → provider candidate → same-endpoint
5
+ * retry. No other ordering input exists.
6
+ */
7
+ export interface RouteAttemptCursor {
8
+ /** 0 for the first result; 1..3 for structured-output parse retries. */
9
+ readonly structuredOutputAttempt: number;
10
+ /** Primary stage first, then the optional fallback-model stage. */
11
+ readonly stageIndex: number;
12
+ /** Provider order inside `RouteStage.candidates`. */
13
+ readonly candidateIndex: number;
14
+ /** 0 for the first request; 1..N for same-endpoint completion-defect retries. */
15
+ readonly endpointAttempt: number;
16
+ }
17
+ export interface AttemptTarget {
18
+ readonly cursor: RouteAttemptCursor;
19
+ readonly providerId: ProviderId;
20
+ readonly canonicalModelId: CanonicalModelId;
21
+ readonly providerInvocationModel: string;
22
+ readonly durationMs: number;
23
+ }
24
+ export type HttpFailureCategory = "timeout" | "rate_limit" | "server_error" | "credential" | "credits" | "provider_bad_request" | "client_error";
25
+ /**
26
+ * Classified attempt failures (PRD §7.1). Transports classify facts; they do
27
+ * not decide route order — `failureDisposition` is the single ordering
28
+ * authority. `cause` stays in memory for logging/chaining and is never
29
+ * serialized into persistence.
30
+ */
31
+ export type InferenceAttemptError = Readonly<{
32
+ kind: "aborted";
33
+ target: AttemptTarget;
34
+ cause: Error;
35
+ }> | Readonly<{
36
+ kind: "completion_defect";
37
+ defect: "empty_completion" | "truncated_tool_call";
38
+ target: AttemptTarget;
39
+ cause: Error;
40
+ }> | Readonly<{
41
+ kind: "network";
42
+ target: AttemptTarget;
43
+ cause: Error;
44
+ }> | Readonly<{
45
+ kind: "http";
46
+ category: HttpFailureCategory;
47
+ statusCode: number;
48
+ retryAfterMs: number | null;
49
+ target: AttemptTarget;
50
+ cause: Error;
51
+ }>;
52
+ export type BreakerEffect = "none" | "record_failure" | "open_immediately";
53
+ /**
54
+ * What the executor may do after a classified failure. All fields are
55
+ * required — this is a flags record, not an options bag — so the disposition
56
+ * matrix in the PRD (§7.1) is total and testable.
57
+ */
58
+ export interface FailureDisposition {
59
+ /** Eligible for the same-endpoint completion-defect retry budget. */
60
+ readonly sameEndpointRetry: boolean;
61
+ /** May traverse to the next provider candidate for the same model. */
62
+ readonly nextProvider: boolean;
63
+ /** May traverse into the fallback-model stage. */
64
+ readonly fallbackModel: boolean;
65
+ readonly breaker: BreakerEffect;
66
+ /** Return to the caller immediately; no further traversal. */
67
+ readonly propagate: boolean;
68
+ }
69
+ /**
70
+ * The exhaustive failure → routing-behavior matrix (PRD §7.1). Pure; the
71
+ * executor applies it, the circuit breaker consumes its `breaker` effect.
72
+ */
73
+ export declare function failureDisposition(error: InferenceAttemptError): FailureDisposition;
74
+ /**
75
+ * Default HTTP status → category mapping. A transport may override per
76
+ * provider (e.g. OpenRouter reports credit exhaustion as 402; another
77
+ * provider may use 403 with a body marker) — this is only the neutral
78
+ * baseline.
79
+ */
80
+ export declare function categorizeHttpStatus(statusCode: number): HttpFailureCategory;
81
+ /**
82
+ * Whether a FRESH ATTEMPT LATER could plausibly succeed — the caller-facing
83
+ * "retriable" used to pick the most useful error when a whole plan fails
84
+ * (PRD §5.2 step 6: prefer a retriable primary-path error over a
85
+ * non-retriable stale fallback binding, so the host's queue-level retry
86
+ * still fires).
87
+ *
88
+ * Deliberately NOT the same notion as route-traversal eligibility: a
89
+ * provider-specific 400 traverses to the next provider (a different provider
90
+ * may accept the request shape — `failureDisposition`), but retrying the
91
+ * same exhausted plan later won't fix it, so it is not caller-retriable.
92
+ * Credential/credit failures likewise traverse but need operator action, not
93
+ * time.
94
+ */
95
+ export declare function isRetriableAttemptError(error: InferenceAttemptError): boolean;