dsh-llm-fallbacks 0.1.0-alpha.1

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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `fallbacks/switch` session event vocabulary (spec §5 table; plan Task 3).
3
+ *
4
+ * The event is appended (never rewrites history — AC-7) at every switch,
5
+ * including the always-mode cap path: "无事件即无切换" (Global Constraint).
6
+ * The module is type-only — the augmentation is erased at runtime; the
7
+ * plugin's runtime behavior lives in `src/index.ts`.
8
+ *
9
+ * @module dsh-llm-fallbacks/events
10
+ */
11
+ /** Why a switch was decided (spec §5.1 `PendingSwitch.reason`). */
12
+ export type FallbackSwitchReason = 'trigger-code' | 'always-cap';
13
+ /** Durable payload of one provider/model switch (spec §5 table). */
14
+ export interface FallbacksSwitchEventData {
15
+ turn: number;
16
+ step: number;
17
+ /** The model the request was using when the switch was decided. */
18
+ from: {
19
+ provider: string;
20
+ model: string;
21
+ };
22
+ /** The chain candidate the switch moves to. */
23
+ to: {
24
+ provider: string;
25
+ model: string;
26
+ };
27
+ /** The fallback-chain role the decision resolved for the agent (ADR-3). */
28
+ role: string;
29
+ reason: FallbackSwitchReason;
30
+ }
31
+ declare module '@deepseek-ai/dsh-session/types' {
32
+ interface SessionEventMap {
33
+ /** Durable, append-only record of one provider/model switch decided by dsh-llm-fallbacks. */
34
+ 'fallbacks/switch': FallbacksSwitchEventData;
35
+ }
36
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * T1 (plan llm-fallbacks-settings-gateway) — host-side `fallbacks` config
3
+ * gateway: the `/api/fallbacks/get` + `/api/fallbacks/set` +
4
+ * `/api/fallbacks/reset` endpoints.
5
+ *
6
+ * Transport: the typertGateway `/api` interceptor is the single host-wide RPC
7
+ * slot (a plugin must NOT `connection.rpc.intercept('/api')` again — it would
8
+ * throw). The service declares a typertGateway binding (via the
9
+ * `TypertRemoteService` base — kept ONLY for its `typertRemote` binding, which
10
+ * dispatch's `validateBinding` requires on the live service) and the
11
+ * endpoints are registered EXPLICITLY through
12
+ * `ctx.typert.register(fallbacksTypertContribution())` (see `apply` in
13
+ * `src/index.ts`) — NOT via `@Remote` SRC markers: SRC discovery reads
14
+ * `remoteMethods()`, a module-private WeakMap in `@deepseek-ai/dsh-typert-protocol`,
15
+ * so a locally-linked plugin whose peers resolve outside the host
16
+ * installation never shares that table with the host typertGateway (zero
17
+ * claimed endpoints, `/api/fallbacks/*` 404). The explicit
18
+ * `TypertRegistry.register` path writes the invocation descriptors into
19
+ * `ctx.typert.local`, which `claimsEndpoint` checks FIRST, so claim +
20
+ * dispatch work regardless of module identity. The payload contract is
21
+ * exactly one plain-object `args` field whose keys are the method parameter
22
+ * names (`get()` → `{ args: {} }`; `set(patch)` → `{ args: { patch } }`;
23
+ * `reset()` → `{ args: {} }`).
24
+ *
25
+ * Data: `get` reads the `FallbacksSettingsBridge` source — the same live
26
+ * composed config the runtime reads (schema defaults → plugin-row base →
27
+ * settings user layer). There is NO hard-gate resolver (unlike advisor's
28
+ * `resolveAdvisorConfig`): the fallbacks decision path runs at
29
+ * `agent/request` time in `src/index.ts`, so the gateway returns the raw
30
+ * composed config — `enabled` is a plain config field, not a gate output.
31
+ * `set` validates the patch against the `Config` schema first (unknown-key
32
+ * rejection unchanged — the settings service itself is non-strict and would
33
+ * merge the unknown key through), then writes the USER layer in-process via
34
+ * `ctx.settings.update` (no exposed-namespace gate on the in-process write —
35
+ * the wire-level `exposedNamespaces()` check only guards the apiproxy path),
36
+ * and returns the new composed value. `reset` (fallbacks-specific third
37
+ * method — advisor has only get/set) clears the user layer via
38
+ * `ctx.settings.replace(ns, {})`: `set` is merge-only and cannot express
39
+ * "reset to composition defaults" (sending default VALUES as a patch would
40
+ * pin stale defaults into the user layer).
41
+ *
42
+ * The settings service is OPTIONAL (no settings service → the bridge source
43
+ * stays the entry, `get` still works; `set`/`reset` fail with a clear
44
+ * error — KD-G5 fallback). The gateway captures the service through a
45
+ * conditional `ctx.inject(['settings'], ...)` child (the same activation
46
+ * pattern as `installSettingsSection`), because `ctx.settings` is only
47
+ * resolvable from a fiber that declares it.
48
+ *
49
+ * The returned config is normalized to the typertGateway JSON wire boundary:
50
+ * only schema-declared keys cross the wire, and absent values are OMITTED,
51
+ * never present-as-undefined (the gateway's result validation rejects
52
+ * undefined values).
53
+ *
54
+ * @module dsh-llm-fallbacks/gateway
55
+ */
56
+ import type { Context } from '@deepseek-ai/cordis';
57
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
58
+ import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry';
59
+ import type { FallbacksConfig } from './config';
60
+ /** The `fallbacks` settings namespace (registered when a settings service exists). */
61
+ export declare const FALLBACKS_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
62
+ /**
63
+ * The live configuration source for the gateway (guide §7 — the same bridge
64
+ * shape the runtime reads through). `source()` returns the live composed
65
+ * config (schema defaults → plugin-row base → settings user layer). The
66
+ * gateway reads it LIVE on every call, so no change notification is needed
67
+ * (the bridge stays minimal: source + the settings write channel; the dead
68
+ * `onChange` fan-out was removed in the QC fix wave — nothing subscribed).
69
+ */
70
+ export interface FallbacksSettingsBridge {
71
+ source(): FallbacksConfig;
72
+ }
73
+ /** Patch shape accepted by `fallbacks.set` — any subset of the config keys. */
74
+ export type FallbacksConfigPatch = Partial<FallbacksConfig>;
75
+ /**
76
+ * The host-side `fallbacks` config gateway (`/api/fallbacks/get` +
77
+ * `/api/fallbacks/set` + `/api/fallbacks/reset`). Registered as the cordis
78
+ * service key `'fallbacks'` (namespace defaults to the service key). The
79
+ * `TypertRemoteService` base is kept ONLY for its `typertRemote` binding —
80
+ * the typertGateway's dispatch `validateBinding` requires the visible binding
81
+ * on the live service (a pure instance property, no module-private state).
82
+ * Endpoints are registered EXPLICITLY through
83
+ * `ctx.typert.register(fallbacksTypertContribution())` (see `apply` in
84
+ * `src/index.ts`) instead of `@Remote` SRC markers (see the module docblock
85
+ * for why).
86
+ */
87
+ export declare class FallbacksConfigGateway extends TypertRemoteService {
88
+ private readonly bridge;
89
+ /** The live settings service once the optional inject child activates. */
90
+ private settings;
91
+ /**
92
+ * @param ctx - owning context (the plugin fiber's ctx inside `apply`).
93
+ * @param bridge - the same `FallbacksSettingsBridge` the runtime reads, so
94
+ * get/set/reset always operate on the live composed config.
95
+ */
96
+ constructor(ctx: Context, bridge: FallbacksSettingsBridge);
97
+ /**
98
+ * Read the current composed config (schema defaults → entry base → settings
99
+ * user layer). No hard-gate resolver (ADR-2): the raw composed config is
100
+ * the wire value — `enabled` is a plain field, not a gate output.
101
+ * @returns the wire-normalized composed config plus `legacyKeys` — legacy
102
+ * two-block-era fields (`chains` / `roles.default` / undeclared rule
103
+ * role refs) detected on the composed source (schemastery retains them,
104
+ * plan Task 1 Step 1), so the client can show a migration banner (spec
105
+ * §9, incremental field — old clients ignore it).
106
+ */
107
+ get(): {
108
+ config: FallbacksConfig;
109
+ legacyKeys: string[];
110
+ };
111
+ /**
112
+ * Validate a config patch and write it to the settings USER layer (live —
113
+ * the runtime re-reads the same bridge source; no restart needed).
114
+ * @param patch - any subset of the config keys; unknown keys (top-level
115
+ * and nested under `roles`) are rejected before anything is written.
116
+ * @returns the NEW composed config plus `legacyKeys` detected on the
117
+ * POST-WRITE composed source (W-1/F-1): `set` is a settings MERGE, so a
118
+ * legacy user layer (`chains` / `roles.default`) survives a new-shape
119
+ * save — the response must keep reporting it, or the client banner
120
+ * would clear against server truth. Same shape as `get`.
121
+ * @throws when the patch fails `Config` validation, or when no settings
122
+ * service is composed (KD-G5: the write channel is unavailable).
123
+ */
124
+ set(patch: FallbacksConfigPatch): Promise<{
125
+ config: FallbacksConfig;
126
+ legacyKeys: string[];
127
+ }>;
128
+ /**
129
+ * Clear the fallbacks settings USER layer so the composition defaults
130
+ * reapply (`settings.replace(ns, {})` — the in-process removal path a
131
+ * merge-only `set` cannot express).
132
+ * @returns the new composed config plus `legacyKeys` on the post-write
133
+ * source — `replace` drops the user layer, but legacy keys carried by
134
+ * the entry base survive and are correctly re-reported (W-1/F-1).
135
+ * @throws when no settings service is composed (KD-G5: the write channel
136
+ * is unavailable).
137
+ */
138
+ reset(): Promise<{
139
+ config: FallbacksConfig;
140
+ legacyKeys: string[];
141
+ }>;
142
+ /**
143
+ * Read the live composed config and normalize it to the typertGateway JSON
144
+ * wire boundary. Containment (guide §10): a malformed stored user layer
145
+ * that the non-strict settings schema let through (e.g. an unknown key)
146
+ * must never fail the RPC — only schema-declared keys cross the wire, and
147
+ * absent values are omitted, never present-as-undefined (the result
148
+ * validator rejects undefined values). The `roles` object is additionally
149
+ * normalized to its declared `list`/`rules` fields: legacy nested keys
150
+ * (e.g. `roles.default`) that schemastery retains on the composed source
151
+ * never leak past the wire boundary (reviewer finding T1 Important #1 —
152
+ * a consumer like Task 2's parseFallbacksConfig would misread them),
153
+ * even though `legacyKeys` still reports them.
154
+ */
155
+ private readConfig;
156
+ /**
157
+ * The wire response of every read (get/set/reset — W-1/F-1): the
158
+ * normalized config plus `legacyKeys` detected on the live composed
159
+ * source. set/reset must report the POST-WRITE source: the settings
160
+ * merge retains legacy user-layer keys, so a save cannot clear them —
161
+ * the honest response keeps the migration banner until a get agrees.
162
+ */
163
+ private readResult;
164
+ }
165
+ /**
166
+ * The explicit typert contribution for the `fallbacks` gateway endpoints —
167
+ * registered via `ctx.typert.register(...)` (see `apply` in `src/index.ts`).
168
+ * The descriptors mirror exactly what the former SRC discovery derived from
169
+ * the `@Remote` markers (`src:<ns>#<endpoint>` identity shape, direct
170
+ * receiver, JSON wire params with `src-json` codec), so the host
171
+ * typertGateway claim + dispatch behavior is the same — the only difference
172
+ * is the registration does not depend on the module-private `remoteMethods`
173
+ * marker table, which a locally-linked plugin can never share with the host
174
+ * installation (see the module docblock).
175
+ */
176
+ export declare function fallbacksTypertContribution(): TypertContribution;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-llm-fallbacks host half (plan Task 3: settings + waterfall + state
3
+ * machine + events).
4
+ *
5
+ * Cordis function plugin mounted by the profile bundle patch row
6
+ * `llm-fallbacks` (see `bundle/cordis.patch.yml`), composed AFTER llm-retry.
7
+ *
8
+ * Wiring:
9
+ * - `fallbacks` settings namespace via {@link installSettingsSection}
10
+ * (composition entry as base; `scope.watch` → `onChange` re-reads the
11
+ * runtime and re-validates selectors — spec §4).
12
+ * - `agent/request-error` waterfall: `!enabled` / code ∉ `triggerCodes`
13
+ * (**always mode included**) → `next()`; otherwise resolve role + chain,
14
+ * and when a candidate survives the filter (current / cooldown /
15
+ * step-failed / `provider/*`-missing-id) write the pending switch +
16
+ * cooldown + failure bookkeeping + append `fallbacks/switch`, then return
17
+ * `{ kind: 'retry' }` (own recovery, no `next()`).
18
+ * - `agent/request` waterfall: apply a pending switch after `await next()`
19
+ * (provider/model override, inherited `reasoningEffort` dropped — the
20
+ * `installModelSelection` `withoutInheritedEffort` pattern), then the
21
+ * always-mode cap check (count `llm/retry` events for the current
22
+ * turn/step/provider; ≥ `alwaysModeRetryCap` → same decision path, reason
23
+ * `always-cap` — ADR-2).
24
+ * - Per-agent state (`FallbackStateStore`): `agent/disposed` removes it,
25
+ * `agent/status` idle prunes per-step state defensively, plugin dispose
26
+ * clears everything (spec §6 — no residual state).
27
+ *
28
+ * @module dsh-llm-fallbacks
29
+ */
30
+ import type { Context } from '@deepseek-ai/cordis';
31
+ import type { Session } from '@deepseek-ai/dsh-session';
32
+ import { Config, type FallbacksConfig } from './config.ts';
33
+ import { FallbackStateStore } from './state.ts';
34
+ /** The plugin row id mounted by the profile bundle patch. */
35
+ export declare const name = "llm-fallbacks";
36
+ export { Config };
37
+ /** The plugin's composition config — the `fallbacks` settings schema (spec §4). */
38
+ export type Config = FallbacksConfig;
39
+ export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts';
40
+ export type { AgentFallbackState, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
41
+ /**
42
+ * @internal Test seam (T3 review Minor 3): the per-agent fallback state store
43
+ * of the plugin applied to `ctx` — last apply wins. Not part of the plugin's
44
+ * public surface; lets tests assert the no-op purity invariant (a plain
45
+ * request must not grow the store) without reaching into the closure.
46
+ */
47
+ export declare function stateStore(ctx: Context): FallbackStateStore | undefined;
48
+ /**
49
+ * Count durable `llm/retry` events for the current (turn, step, provider) in
50
+ * **always mode** (ADR-2; spec §2 clause 5). Normal-mode retries belong to
51
+ * llm-retry's bounded budget and must not preempt the fallback, so only
52
+ * `mode: 'always'` events count toward `alwaysModeRetryCap` (T3 review
53
+ * Minor 2 — the real event carries the discriminator, llm-retry types.ts).
54
+ *
55
+ * Fast path (T3 review Minor 4): the session log is append-ordered, so the
56
+ * scan runs backwards and stops at the first event older than the target
57
+ * (turn, step) — a long session's earlier turns are never scanned.
58
+ *
59
+ * Exported for direct unit testing of the counting + fast path.
60
+ */
61
+ export declare function countRetryEvents(session: Session, turn: number, step: number, provider: string): number;
62
+ export declare function apply(ctx: Context, config?: FallbacksConfig): void;