auto-model-router 0.2.28 → 0.2.30

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/CLAUDE.md CHANGED
@@ -119,8 +119,9 @@ read/write client is `src/context/agentdox.ts` (assemble / createSession / appen
119
119
  Beyond consuming agentdox as an agent, `src/context/` is the **router↔agentdox bridge**: it
120
120
  injects shared project context into every routed turn and records turns back, attributed to
121
121
  the model that served them. See `docs/AGENTDOX-BRIDGE.md` for the current state, how to run
122
- it, and the open issue. Design rationale lives in
123
- `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
122
+ it, and the open issue. Design rationale is the **decision log in the agentdox project brief**
123
+ for scope `omp-router` (read it with `context_brief`) — the bridge decisions and the evidence
124
+ behind them are recorded there as they are made.
124
125
 
125
126
  Turning the bridge on for the router itself (distinct from the MCP wiring above):
126
127
 
package/README.md CHANGED
@@ -677,7 +677,7 @@ has none of the project knowledge the last one built up. Because every harness
677
677
  routes through this one provider, the router is the single place that can fix
678
678
  that for all of them at once.
679
679
 
680
- Point it at an [agentdox](https://github.com/…/agentdox) server and every turn —
680
+ Point it at an [agentdox](https://github.com/drewappling/agentdox) server and every turn —
681
681
  whatever model wins the routing decision — carries the same project memory, docs,
682
682
  and brief:
683
683
 
@@ -744,8 +744,9 @@ not a dependency. If it is unreachable the turn routes and dispatches normally,
744
744
  and a pinned block keeps being served.
745
745
 
746
746
  `GET /health` reports the bridge's URL, default scope, and `recordTurns` — never
747
- the token. Design notes: `docs/architecture/router-context-bridge.md` in the
748
- agentdox repo. Live check: `bun tools/agentdox-e2e.ts`.
747
+ the token. Design notes: [`docs/AGENTDOX-BRIDGE.md`](docs/AGENTDOX-BRIDGE.md).
748
+ Server side: the [agentdox repo](https://github.com/drewappling/agentdox).
749
+ Live check: `bun tools/agentdox-e2e.ts`.
749
750
 
750
751
  ---
751
752
 
@@ -1,10 +1,11 @@
1
1
  # agentdox bridge — handoff
2
2
 
3
- **Status:** implemented, typechecks clean, 439 tests pass, injection verified end-to-end
3
+ **Status:** implemented, typechecks clean, 502 tests pass, injection verified end-to-end
4
4
  through omp. The write-back faults in §5 and §6 are **fixed**; `context.recordTurns` is on.
5
5
 
6
- Design rationale (why it is built this way):
7
- `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
6
+ Design rationale (why it is built this way) is the decision log in the agentdox project
7
+ brief for scope `omp-router` — read it with `context_brief`. The agentdox server itself:
8
+ [github.com/drewappling/agentdox](https://github.com/drewappling/agentdox).
8
9
 
9
10
  ---
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -98,6 +98,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
98
98
  toolAxis: "coding",
99
99
  chatAxis: "intelligence",
100
100
  agenticLoopDepth: 3,
101
+ // Shipped values, unchanged. See ClassifierConfig.reasoningWeights: a
102
+ // harness that pins the level for a whole session turns these into a
103
+ // constant tier offset, in which case `medium` belongs near 0.
104
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
101
105
  },
102
106
  escalation: {
103
107
  enabled: true,
@@ -131,6 +135,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
131
135
  // OpenRouter sticky sessions expire in 5-10 minutes.
132
136
  cacheWarmTtlMs: 300_000,
133
137
  maxDowngradePerTurn: 1,
138
+ // Off: breaking a hold means a model switch, which costs a cache write.
139
+ // Enable where the held tier is expensive; see HysteresisConfig.
140
+ breakHoldOnMechanical: false,
134
141
  },
135
142
  exploration: {
136
143
  // Opt-in. Exploration knowingly routes some turns below the tier that
@@ -85,6 +85,14 @@ const classifier = z.strictObject({
85
85
  toolAxis: qualityAxis.optional(),
86
86
  chatAxis: qualityAxis.optional(),
87
87
  agenticLoopDepth: z.number().int().nonnegative().optional(),
88
+ reasoningWeights: z
89
+ .strictObject({
90
+ medium: z.number().nonnegative().optional(),
91
+ high: z.number().nonnegative().optional(),
92
+ xhigh: z.number().nonnegative().optional(),
93
+ max: z.number().nonnegative().optional(),
94
+ })
95
+ .optional(),
88
96
  });
89
97
 
90
98
  const escalation = z.strictObject({
@@ -103,6 +111,7 @@ const hysteresis = z.strictObject({
103
111
  switchMargin: z.number().positive().optional(),
104
112
  cacheWarmTtlMs: z.number().nonnegative().optional(),
105
113
  maxDowngradePerTurn: z.number().int().nonnegative().optional(),
114
+ breakHoldOnMechanical: z.boolean().optional(),
106
115
  });
107
116
 
108
117
  const exploration = z.strictObject({
@@ -229,6 +229,28 @@ export interface ClassifierConfig {
229
229
  chatAxis: QualityAxis;
230
230
  /** Tool-loop depth above which the agentic axis takes over. */
231
231
  agenticLoopDepth: number;
232
+ /**
233
+ * Score added when the CLIENT asks for a reasoning effort, per level. The
234
+ * premise is that asking for reasoning states expected difficulty directly.
235
+ *
236
+ * That premise fails when a harness sets the level once for a whole session:
237
+ * a constant cannot discriminate difficulty between turns, but it still
238
+ * shifts every turn's score. Measured on a live day: the requested level
239
+ * never changed within 111 of 115 conversations, `medium` (+0.14, over half
240
+ * of a 0.25-wide tier band) rode on 41.6% of dispatches, and 64 of 119
241
+ * `hard` dispatches reached that tier ONLY because of it — $6.66 billed
242
+ * against $0.16 for the same tokens on the moderate pick.
243
+ *
244
+ * Tune per deployment: a harness that raises the level deliberately for hard
245
+ * turns wants these weights, one that pins it session-wide wants `medium`
246
+ * near zero. Defaults preserve the shipped behaviour.
247
+ */
248
+ reasoningWeights: {
249
+ medium: number;
250
+ high: number;
251
+ xhigh: number;
252
+ max: number;
253
+ };
232
254
  }
233
255
 
234
256
  export interface EscalationConfig {
@@ -265,6 +287,25 @@ export interface HysteresisConfig {
265
287
  cacheWarmTtlMs: number;
266
288
  /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
267
289
  maxDowngradePerTurn: number;
290
+ /**
291
+ * Let a mechanical tool-result continuation escape a hold that sits above its
292
+ * own classification.
293
+ *
294
+ * A hold bets that the next turn resembles the one that armed it, and it is
295
+ * usually right — flapping cold-starts prompt caches. But a continuation the
296
+ * classifier has already docked for being a mechanical next step, and whose
297
+ * score lands below the held tier, is evidence against that bet. Measured on
298
+ * 24h of live traffic: 37 of 44 sticky `hard` dispatches were exactly that,
299
+ * one scoring 0.154 (trivial) yet served by claude-opus-5 — $2.66 billed
300
+ * against $0.05 for the identical tokens on the moderate pick.
301
+ *
302
+ * Off by default: breaking a hold means a model switch, and switching costs a
303
+ * cache write. Worth it when the held tier is expensive, not obviously worth
304
+ * it when the tiers are close, so it is opt-in per deployment.
305
+ * `maxDowngradePerTurn` still applies, so quality steps down rather than
306
+ * falling off a cliff.
307
+ */
308
+ breakHoldOnMechanical: boolean;
268
309
  }
269
310
 
270
311
  /**
@@ -76,17 +76,24 @@ const W_TOOLS_OFFERED = 0.03;
76
76
  /** Score bucket boundaries: [trivial, simple, moderate, hard]. */
77
77
  const BOUNDARIES: readonly [number, number, number] = [0.25, 0.5, 0.75];
78
78
 
79
- /** A client that asks for reasoning is stating expected difficulty directly. */
80
- function reasoningWeight(level: ReasoningLevel | undefined): number {
79
+ /**
80
+ * Score for a client-stated reasoning effort. The premise is that asking for
81
+ * reasoning states expected difficulty — true when a harness raises the level
82
+ * for a hard turn, false when it pins one level for the whole session, where the
83
+ * "signal" is a constant that lifts every turn's score. Weights are therefore
84
+ * configurable per deployment; see ClassifierConfig.reasoningWeights.
85
+ */
86
+ function reasoningWeight(level: ReasoningLevel | undefined, cfg: RouterConfig): number {
87
+ const w = cfg.classifier.reasoningWeights;
81
88
  switch (level) {
82
89
  case "medium":
83
- return 0.14;
90
+ return w.medium;
84
91
  case "high":
85
- return 0.24;
92
+ return w.high;
86
93
  case "xhigh":
87
- return 0.3;
94
+ return w.xhigh;
88
95
  case "max":
89
- return 0.34;
96
+ return w.max;
90
97
  default:
91
98
  // off/minimal/low/undefined: no stated difficulty above the baseline.
92
99
  return 0;
@@ -113,7 +120,7 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
113
120
  );
114
121
  if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
115
122
  if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
116
- const rw = reasoningWeight(f.requestedReasoning);
123
+ const rw = reasoningWeight(f.requestedReasoning, cfg);
117
124
  if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
118
125
  if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
119
126
  add(Math.min(f.codeBlocks * W_CODE_BLOCK, CAP_CODE), `${f.codeBlocks} code block(s) in new content`);
@@ -117,10 +117,23 @@ export function select(args: SelectArgs): Decision {
117
117
 
118
118
  // 2. Hysteresis: while the sticky window is open, never route below the
119
119
  // held tier — per-turn flapping would repeatedly cold-start prompt caches.
120
+ //
121
+ // Exception, when `breakHoldOnMechanical` is on: a hold is a bet that the
122
+ // NEXT turn resembles the one that armed it. A tool-result continuation
123
+ // whose own score lands below the held tier is direct evidence against
124
+ // that bet — the classifier already docks it for being a mechanical next
125
+ // step — so paying the held tier for it buys nothing. Measured on 24h of
126
+ // live traffic: 37 of 44 sticky `hard` dispatches were exactly this shape,
127
+ // one scoring 0.154 (trivial) yet served by claude-opus-5; $2.66 billed
128
+ // against $0.05 for the identical tokens on the moderate pick.
120
129
  let cls = classification;
130
+ const mechanicalOverride =
131
+ cfg.hysteresis.breakHoldOnMechanical && features.isToolResultContinuation && tierIdx(effective) < tierIdx(clampTier(state.currentTier ?? effective));
121
132
  if (state.stickyUntilTurn > state.turn && state.currentTier !== null && tierIdx(state.currentTier) >= tierIdx(effective)) {
122
133
  const held = clampTier(state.currentTier);
123
- if (held !== effective) {
134
+ if (mechanicalOverride) {
135
+ reasons.push(`hysteresis hold ${held} broken: mechanical tool-result continuation classified ${effective}`);
136
+ } else if (held !== effective) {
124
137
  reasons.push(`hysteresis: holding ${held} until turn ${state.stickyUntilTurn} (classified ${effective})`);
125
138
  cls = {
126
139
  ...classification,
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
3
4
  import { loadConfig } from "../src/config/load.ts";
4
5
  import type { RouterConfig } from "../src/config/types.ts";
5
6
  import { classify, classifyTask, pickQualityAxis, scoreHeuristic } from "../src/router/classify.ts";
@@ -178,6 +179,35 @@ describe("scoreHeuristic", () => {
178
179
  expect(thinking.score).toBeGreaterThan(plain.score);
179
180
  });
180
181
 
182
+ test("the reasoning weight is configurable, so a session-wide level can be discounted", () => {
183
+ // A harness that pins one reasoning level for a whole session turns this
184
+ // "signal" into a constant that lifts every turn's score. Measured live:
185
+ // the level never changed within 111 of 115 conversations, and 64 of 119
186
+ // hard dispatches reached that tier ONLY via the weight — $6.66 billed
187
+ // against $0.16 for the same tokens on the moderate pick.
188
+ //
189
+ // DEFAULT_CONFIG, not BASE: BASE is loadConfig({}), which reads this
190
+ // machine's real config.yml, and this assertion is about shipped values.
191
+ const features = extractFeatures(
192
+ parseChatRequest(
193
+ { model: "auto", tools: TOOLS, reasoning_effort: "medium", messages: [SYSTEM, { role: "user", content: "tidy this up" }] },
194
+ new Headers(),
195
+ ),
196
+ 5000,
197
+ );
198
+ const shipped = scoreHeuristic(features, DEFAULT_CONFIG);
199
+ const discounted = scoreHeuristic(features, {
200
+ ...DEFAULT_CONFIG,
201
+ classifier: { ...DEFAULT_CONFIG.classifier, reasoningWeights: { ...DEFAULT_CONFIG.classifier.reasoningWeights, medium: 0 } },
202
+ });
203
+ expect(shipped.score - discounted.score).toBeCloseTo(DEFAULT_CONFIG.classifier.reasoningWeights.medium, 5);
204
+ expect(discounted.reasons.some((r) => /requested reasoning/.test(r))).toBe(false);
205
+ });
206
+
207
+ test("ships with the historical weights, so enabling a discount is opt-in", () => {
208
+ expect(DEFAULT_CONFIG.classifier.reasoningWeights).toEqual({ medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 });
209
+ });
210
+
181
211
  test("always produces a bounded score, a real tier, and its reasoning", () => {
182
212
  const c = scoreHeuristic(featuresFor([SYSTEM, { role: "user", content: "hello" }]), BASE);
183
213
  expect(c.score).toBeGreaterThanOrEqual(0);
@@ -55,6 +55,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
55
55
  toolAxis: "coding",
56
56
  chatAxis: "intelligence",
57
57
  agenticLoopDepth: 3,
58
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
58
59
  },
59
60
  escalation: {
60
61
  enabled: true,
@@ -66,7 +67,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
66
67
  escalateOnLengthStop: false,
67
68
  ...escalation,
68
69
  },
69
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
70
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
73
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
@@ -743,3 +743,81 @@ describe("context compaction", () => {
743
743
  expect(tight.promptTokensSaved).toBeGreaterThan(loose.promptTokensSaved);
744
744
  });
745
745
  });
746
+
747
+ describe("hysteresis.breakHoldOnMechanical", () => {
748
+ // A hold bets the next turn resembles the one that armed it. A tool-result
749
+ // continuation the classifier has already docked, scoring below the held
750
+ // tier, is evidence against that bet. Measured on 24h of live traffic: 37 of
751
+ // 44 sticky `hard` dispatches were exactly that shape — one scoring 0.154
752
+ // (trivial) yet served by claude-opus-5 — $2.66 billed against $0.05 for the
753
+ // same tokens on the moderate pick.
754
+ function continuation(): NormRequest {
755
+ return parseChatRequest(
756
+ {
757
+ model: "auto",
758
+ tools: TOOLS,
759
+ messages: [
760
+ { role: "system", content: "You are a coding agent." },
761
+ { role: "user", content: "read the file" },
762
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"a.ts"}' } }] },
763
+ { role: "tool", tool_call_id: "c1", content: "export const x = 1;" },
764
+ ],
765
+ },
766
+ new Headers(),
767
+ );
768
+ }
769
+
770
+ const held = state({ currentTier: "hard", currentSlug: "x-ai/grok-4.6", stickyUntilTurn: 9, turn: 1 });
771
+
772
+ function decide(req: NormRequest, breakHold: boolean) {
773
+ const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: breakHold } };
774
+ const features = extractFeatures(req, 4_000);
775
+ return { d: select({ req, features, classification: scoreHeuristic(features, cfg), profile: PROFILE, state: held, snapshot: SNAPSHOT, ledger: null, cfg, nowMs: Date.now() }), features };
776
+ }
777
+
778
+ test("off by default, so a hold still pins the tier", () => {
779
+ expect(BASE.hysteresis.breakHoldOnMechanical).toBe(false);
780
+ const { d, features } = decide(continuation(), false);
781
+ expect(features.isToolResultContinuation).toBe(true);
782
+ expect(d.tier).toBe("hard");
783
+ expect(d.classification.source).toBe("sticky");
784
+ });
785
+
786
+ test("on, a mechanical continuation escapes the hold", () => {
787
+ const { d } = decide(continuation(), true);
788
+ expect(d.tier).not.toBe("hard");
789
+ expect(d.classification.source).not.toBe("sticky");
790
+ expect(d.reasons.some((r) => /hold hard broken/.test(r))).toBe(true);
791
+ });
792
+
793
+ test("a NON-mechanical turn still gets the hold, so flap protection survives", () => {
794
+ // This is the case hysteresis exists for: fresh user work mid-conversation
795
+ // must not bounce the model and cold-start its cache.
796
+ const { d, features } = decide(request("now refactor the retry helper"), true);
797
+ expect(features.isToolResultContinuation).toBe(false);
798
+ expect(d.tier).toBe("hard");
799
+ expect(d.classification.source).toBe("sticky");
800
+ });
801
+
802
+ test("the downgrade clamp still applies, so quality steps rather than falls", () => {
803
+ const cfg: RouterConfig = {
804
+ ...BASE,
805
+ hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: true, maxDowngradePerTurn: 1 },
806
+ };
807
+ const req = continuation();
808
+ const features = extractFeatures(req, 4_000);
809
+ // Force the fresh classification far below the hold to exercise the clamp.
810
+ const d = select({
811
+ req,
812
+ features,
813
+ classification: { ...scoreHeuristic(features, cfg), tier: "trivial" },
814
+ profile: PROFILE,
815
+ state: held,
816
+ snapshot: SNAPSHOT,
817
+ ledger: null,
818
+ cfg,
819
+ nowMs: Date.now(),
820
+ });
821
+ expect(d.tier).toBe("moderate");
822
+ });
823
+ });
package/test/turn.test.ts CHANGED
@@ -56,6 +56,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
56
56
  toolAxis: "coding",
57
57
  chatAxis: "intelligence",
58
58
  agenticLoopDepth: 3,
59
+ reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
59
60
  },
60
61
  escalation: {
61
62
  enabled: true,
@@ -67,7 +68,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
67
68
  escalateOnLengthStop: false,
68
69
  ...escalation,
69
70
  },
70
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
71
72
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
73
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
74
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
package/tools/replay.ts CHANGED
@@ -37,8 +37,10 @@
37
37
  * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
38
38
  * - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
39
39
  * window is absent. This is the main residual gap.
40
- * - `requestedReasoning` is the one `Features` field the ledger omits; it
41
- * replays as undefined.
40
+ * - `requestedReasoning` IS recorded and is now used. It was previously forced
41
+ * to undefined here on the belief the ledger omitted it, which under-scored
42
+ * ~42% of dispatches and reproduced 27 hard decisions against 120 served.
43
+ * Treat replay numbers produced before that fix as biased toward cheap tiers.
42
44
  * - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
43
45
  * be A/B'd via `--set` — only `RouterConfig` paths can.
44
46
  *
@@ -140,10 +142,20 @@ interface Row {
140
142
  created_at_ms: number;
141
143
  }
142
144
 
143
- /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
145
+ /**
146
+ * Rebuilds the classifier input from the recorded blob.
147
+ *
148
+ * `requestedReasoning` IS recorded (JSON.stringify only drops it when the client
149
+ * sent no level), and it must be used: it is worth up to +0.34 of score, rides
150
+ * on ~42% of dispatches, and forcing it to undefined — as this did, on the
151
+ * assumption the ledger omitted it — under-scored every one of those rows.
152
+ * Measured effect of the bug: replay reproduced 27 hard-tier decisions against
153
+ * 120 actually served, i.e. it silently biased every comparison toward cheaper
154
+ * tiers and made reasoning-weight changes look like no-ops.
155
+ */
144
156
  function featuresOf(row: Row, promptTokens: number): Features {
145
157
  const f = JSON.parse(row.features) as Partial<Features>;
146
- return { ...(f as Features), promptTokens, requestedReasoning: undefined };
158
+ return { ...(f as Features), promptTokens };
147
159
  }
148
160
 
149
161
  /**