@stage-labs/metro 0.1.0-beta.314 → 0.1.0-beta.316
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/effort.ts +21 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +10 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +23 -10
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +11 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/effort.ts +21 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./trains/protocol": "./src/protocol.ts",
|
|
20
20
|
"./ids": "./src/ids.ts",
|
|
21
21
|
"./claude-token": "./src/claude-token.ts",
|
|
22
|
+
"./effort": "./src/effort.ts",
|
|
22
23
|
"./tickets": "./src/tickets.ts",
|
|
23
24
|
"./station-names": "./src/station-names.ts",
|
|
24
25
|
"./str": "./src/str.ts",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
|
|
4
|
+
|
|
5
|
+
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
|
6
|
+
|
|
7
|
+
export interface RouteEffort {
|
|
8
|
+
main?: EffortLevel;
|
|
9
|
+
helpers?: EffortLevel;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
|
|
13
|
+
|
|
14
|
+
export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
|
|
15
|
+
|
|
16
|
+
export function readEffort(raw: unknown): RouteEffort {
|
|
17
|
+
if (!isRecord(raw)) return {};
|
|
18
|
+
return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
|
|
1
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
|
+
import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
4
|
|
|
4
5
|
export interface DeliveredRoute {
|
|
5
6
|
primary: Fallback | null;
|
|
6
7
|
fallbacks: Fallback[];
|
|
8
|
+
helpers: Chain | null;
|
|
9
|
+
effort: RouteEffort;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export interface Delivered {
|
|
@@ -24,10 +27,20 @@ function refOf(raw: unknown): Fallback | null {
|
|
|
24
27
|
return ref.connection === '' ? null : ref;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function fallbacksOf(raw: Record<string, unknown>): Fallback[] {
|
|
31
|
+
const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
|
|
32
|
+
return fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function helpersOf(raw: unknown): Chain | null {
|
|
36
|
+
if (!isRecord(raw)) return null;
|
|
37
|
+
const primary = refOf(raw.primary);
|
|
38
|
+
return primary === null ? null : { primary, fallbacks: fallbacksOf(raw) };
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
function routeOf(raw: unknown): DeliveredRoute | null {
|
|
28
42
|
if (!isRecord(raw)) return null;
|
|
29
|
-
|
|
30
|
-
return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
|
|
43
|
+
return { primary: refOf(raw.primary), fallbacks: fallbacksOf(raw), helpers: helpersOf(raw.helpers), effort: readEffort(raw.effort) };
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
function accountOf(raw: unknown): Account {
|
|
@@ -6,7 +6,7 @@ import { deliveredSetup, type OrgModels } from './org-models.js';
|
|
|
6
6
|
export function setupOf(delivered: Delivered | null): ModelSetup {
|
|
7
7
|
const route = delivered?.route ?? null;
|
|
8
8
|
if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
|
|
9
|
-
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
|
|
9
|
+
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { EFFORT_DEFAULTS, type RouteEffort } from '@metro-labs/core/effort';
|
|
2
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
4
|
|
|
4
|
-
const MAIN_EFFORT = 'low';
|
|
5
|
-
const SUBAGENT_EFFORT = 'max';
|
|
6
5
|
export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
|
|
7
6
|
|
|
8
7
|
const SUBAGENT_HEADER = 'x-claude-code-agent-id';
|
|
@@ -42,12 +41,13 @@ const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? bo
|
|
|
42
41
|
|
|
43
42
|
const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
|
|
44
43
|
|
|
45
|
-
export function plannedEffort(req: IncomingMessage, body: Body): string | null {
|
|
44
|
+
export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
|
|
46
45
|
const asked = askedEfforts(body);
|
|
47
46
|
if (asked.length === 0) return null;
|
|
48
47
|
const kind = requestKind(req, body);
|
|
49
48
|
if (kind === 'other') return null;
|
|
50
|
-
const wanted = kind === 'subagent' ?
|
|
49
|
+
const wanted = kind === 'subagent' ? (setting.helpers ?? EFFORT_DEFAULTS.helpers) : (setting.main ?? EFFORT_DEFAULTS.main);
|
|
50
|
+
if (wanted === 'asked') return null;
|
|
51
51
|
return asked.every((effort) => effort === wanted) ? null : wanted;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -18,14 +18,16 @@ interface Cooling {
|
|
|
18
18
|
reason: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export type Lane = 'main' | 'helpers';
|
|
22
|
+
|
|
21
23
|
const cooling = new Map<string, Cooling>();
|
|
22
|
-
|
|
24
|
+
const lastChoice = new Map<Lane, string>();
|
|
23
25
|
|
|
24
26
|
const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
|
|
25
27
|
|
|
26
28
|
export function forgetFallbackState(): void {
|
|
27
29
|
cooling.clear();
|
|
28
|
-
lastChoice
|
|
30
|
+
lastChoice.clear();
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function scopeOf(label: string): string | null {
|
|
@@ -96,18 +98,18 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
|
|
|
96
98
|
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
|
|
101
|
+
export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
|
|
100
102
|
const key = keyOf(chosen);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
lastChoice
|
|
103
|
+
const last = lastChoice.get(lane);
|
|
104
|
+
if (key === last) return;
|
|
105
|
+
lastChoice.set(lane, key);
|
|
104
106
|
if (key === keyOf(primary)) {
|
|
105
|
-
if (
|
|
107
|
+
if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
const hold = holdOf(primary, now);
|
|
109
111
|
log.info(
|
|
110
|
-
{ from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
112
|
+
{ lane, from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
111
113
|
'gateway: the first model is over its limit, so requests go to a fallback model',
|
|
112
114
|
);
|
|
113
115
|
}
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type Watch,
|
|
27
27
|
type Refusal,
|
|
28
28
|
} from './forward.js';
|
|
29
|
-
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
29
|
+
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry, type Lane } from './fallback.js';
|
|
30
30
|
import {
|
|
31
31
|
BINDING_BETA,
|
|
32
32
|
cappedEffort,
|
|
@@ -34,12 +34,13 @@ import {
|
|
|
34
34
|
forgetLearnedThinking,
|
|
35
35
|
learnAlwaysThinks,
|
|
36
36
|
plannedEffort,
|
|
37
|
+
requestKind,
|
|
37
38
|
thinkingOff,
|
|
38
39
|
withBlockBinding,
|
|
39
40
|
withEffort,
|
|
40
41
|
withThinkingFor,
|
|
41
42
|
} from './effort.js';
|
|
42
|
-
import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
43
|
+
import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
43
44
|
import { codexMessages, type CodexDeps } from './codex.js';
|
|
44
45
|
import { countTokens } from './subscription.js';
|
|
45
46
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
@@ -143,8 +144,8 @@ async function asAnthropicWants(send: Send, base: Record<string, string>, payloa
|
|
|
143
144
|
const { metro, asSent, bound } = payloads;
|
|
144
145
|
const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
|
|
145
146
|
if (upstream.status !== 400 || asSent === null) return upstream;
|
|
146
|
-
await upstream.
|
|
147
|
-
log.warn({ connection, model }, 'gateway:
|
|
147
|
+
const reason = upstreamMessage(await upstream.text().catch(() => ''), '').slice(0, 300);
|
|
148
|
+
log.warn({ connection, model, reason }, 'gateway: the provider refused the request metro shaped, so it was sent again as Claude Code wrote it');
|
|
148
149
|
return send(asSent, base);
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -241,11 +242,23 @@ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
241
242
|
else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
function shapedFor(req: IncomingMessage, sent: Record<string, unknown
|
|
245
|
-
const effort = plannedEffort(req, sent);
|
|
245
|
+
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
|
|
246
|
+
const effort = plannedEffort(req, sent, setup.effort);
|
|
246
247
|
return effort === null ? sent : withEffort(sent, effort);
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
interface Chosen {
|
|
251
|
+
lane: Lane;
|
|
252
|
+
setup: ModelSetup;
|
|
253
|
+
primary: Route;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
|
|
257
|
+
const helpers = requestKind(req, sent) === 'subagent' ? helperSetup(setup) : null;
|
|
258
|
+
if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
|
|
259
|
+
return { lane: 'helpers', setup: helpers, primary: pinnedRoute(requested, helpers) ?? noRoute() };
|
|
260
|
+
}
|
|
261
|
+
|
|
249
262
|
const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
|
|
250
263
|
|
|
251
264
|
interface Request {
|
|
@@ -299,12 +312,12 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
299
312
|
const parsed = parseJson(raw);
|
|
300
313
|
const sent = fitToolSearch(req, parsed);
|
|
301
314
|
const requested = requestedModel(sent);
|
|
302
|
-
const primary =
|
|
303
|
-
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
|
|
304
|
-
const routes = routesToTry(chainOf(primary,
|
|
315
|
+
const { lane, setup: chain, primary } = chosenChain(req, sent, setup, requested);
|
|
316
|
+
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent, setup), setup };
|
|
317
|
+
const routes = routesToTry(chainOf(primary, chain, requested));
|
|
305
318
|
for (const [at, route] of routes.entries()) {
|
|
306
319
|
const next = routes[at + 1];
|
|
307
|
-
if (path === MESSAGES) noteChoice(primary, route);
|
|
320
|
+
if (path === MESSAGES) noteChoice(primary, route, lane);
|
|
308
321
|
if (next === undefined) {
|
|
309
322
|
await attempt(req, res, ask, route, deps);
|
|
310
323
|
return;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { EFFORT_DEFAULTS } from '@metro-labs/core/effort';
|
|
2
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
4
|
import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
|
|
4
5
|
import { effectiveSetup } from './effective.js';
|
|
5
6
|
import { chainStatus } from './fallback.js';
|
|
6
|
-
import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
|
|
7
|
+
import { helperSetup, notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
|
|
7
8
|
import { orgModels, type SyncStatus } from './org-models.js';
|
|
8
9
|
import { lastServed } from './served.js';
|
|
9
10
|
import { refreshUsage, type UsageDeps } from './usage-refresh.js';
|
|
@@ -41,6 +42,13 @@ function publicConnection(c: Connection): Record<string, unknown> {
|
|
|
41
42
|
|
|
42
43
|
const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
|
|
43
44
|
|
|
45
|
+
function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
|
|
46
|
+
const helpers = helperSetup(setup);
|
|
47
|
+
if (helpers === null) return null;
|
|
48
|
+
const primary = primaryRoute(helpers);
|
|
49
|
+
return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers) };
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
|
|
45
53
|
const setup = setupOf(deps);
|
|
46
54
|
const primary = primaryRoute(setup);
|
|
@@ -55,6 +63,8 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
|
|
|
55
63
|
connections: setup.connections.map(publicConnection),
|
|
56
64
|
fallbacks: setup.fallbacks,
|
|
57
65
|
chain: chainStatus(setup),
|
|
66
|
+
helpers: helpersBody(setup),
|
|
67
|
+
effort: { ...EFFORT_DEFAULTS, ...setup.effort },
|
|
58
68
|
lastServed: lastServed(),
|
|
59
69
|
usage: usageSeen(),
|
|
60
70
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { RouteEffort } from '@metro-labs/core/effort';
|
|
2
|
+
|
|
1
3
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
|
|
2
4
|
export type Provider = (typeof PROVIDERS)[number];
|
|
3
5
|
export type CodexMethod = 'chatgpt' | 'code';
|
|
@@ -36,11 +38,18 @@ export interface Fallback {
|
|
|
36
38
|
model: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
export interface Chain {
|
|
42
|
+
primary: Fallback;
|
|
43
|
+
fallbacks: Fallback[];
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
export interface ModelSetup {
|
|
40
47
|
primary: Fallback | null;
|
|
41
48
|
fallbacks: Fallback[];
|
|
42
49
|
connections: Connection[];
|
|
43
50
|
source: RouteSource | null;
|
|
51
|
+
helpers?: Chain | null;
|
|
52
|
+
effort?: RouteEffort;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
export interface Route {
|
|
@@ -119,6 +128,19 @@ export function resolveRoute(requested: string, setup: ModelSetup): Route | null
|
|
|
119
128
|
return { connection: primary.connection, model: DEFAULTS[primary.connection.provider](bareModel(requested), primary.model) };
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
export function pinnedRoute(requested: string, setup: ModelSetup): Route | null {
|
|
132
|
+
const primary = primaryRoute(setup);
|
|
133
|
+
if (primary === null) return null;
|
|
134
|
+
const bare = bareModel(requested);
|
|
135
|
+
const passes = primary.connection.provider === 'anthropic' && (primary.model === '' || isSmallModel(bare));
|
|
136
|
+
return passes ? { connection: primary.connection, model: bare } : primary;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function helperSetup(setup: ModelSetup): ModelSetup | null {
|
|
140
|
+
const helpers = setup.helpers ?? null;
|
|
141
|
+
return helpers === null ? null : { ...setup, primary: helpers.primary, fallbacks: helpers.fallbacks, helpers: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
export const routeLabel = (route: Route): string =>
|
|
123
145
|
route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
|
|
124
146
|
|
|
@@ -42,7 +42,7 @@ export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now
|
|
|
42
42
|
export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
|
|
43
43
|
const run = (): void => {
|
|
44
44
|
const current = setup();
|
|
45
|
-
if (current.fallbacks.length === 0) return;
|
|
45
|
+
if (current.fallbacks.length === 0 && (current.helpers ?? null) === null) return;
|
|
46
46
|
refreshUsage(current, deps).catch((err: unknown) => {
|
|
47
47
|
log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
|
|
48
48
|
});
|
package/runtime/runtime.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./trains/protocol": "./src/protocol.ts",
|
|
20
20
|
"./ids": "./src/ids.ts",
|
|
21
21
|
"./claude-token": "./src/claude-token.ts",
|
|
22
|
+
"./effort": "./src/effort.ts",
|
|
22
23
|
"./tickets": "./src/tickets.ts",
|
|
23
24
|
"./station-names": "./src/station-names.ts",
|
|
24
25
|
"./str": "./src/str.ts",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
|
|
4
|
+
|
|
5
|
+
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
|
6
|
+
|
|
7
|
+
export interface RouteEffort {
|
|
8
|
+
main?: EffortLevel;
|
|
9
|
+
helpers?: EffortLevel;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
|
|
13
|
+
|
|
14
|
+
export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
|
|
15
|
+
|
|
16
|
+
export function readEffort(raw: unknown): RouteEffort {
|
|
17
|
+
if (!isRecord(raw)) return {};
|
|
18
|
+
return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
|