@askalf/dario 5.5.90 → 6.0.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,210 @@
1
+ /**
2
+ * Shadow compare (v6.0.0) — run one prompt past a second model family and keep
3
+ * both answers, without the client ever knowing.
4
+ *
5
+ * WHY THIS SHAPE. Once dario can serve either wire shape from either
6
+ * subscription, the obvious question stops being "can I reach GPT" and becomes
7
+ * "which of these two is actually better at MY work". Answering that from
8
+ * benchmarks is close to worthless; answering it from your own real traffic is
9
+ * not. So a compare is triggered per-request by a header, on prompts you were
10
+ * sending anyway.
11
+ *
12
+ * THE CLIENT IS NEVER AFFECTED. The primary answer streams through untouched
13
+ * and the comparison runs beside it — the request is not held open for it, and
14
+ * a compare that fails, times out, or has nowhere to go is dropped silently
15
+ * with the record still written. A diagnostic that can degrade the thing it is
16
+ * measuring is worse than no diagnostic.
17
+ *
18
+ * BOTH SIDES ARE RECORDED IN THE CLIENT'S OWN WIRE SHAPE. The comparison is
19
+ * dispatched through the same forwardToCodex translation the real path uses, so
20
+ * an Anthropic-shape request yields two Anthropic-shape answers. Comparing a
21
+ * Messages response against a raw Responses payload would mean eyeballing
22
+ * across two formats and calling the difference a model difference.
23
+ *
24
+ * Raw payloads are stored rather than extracted text: extraction is exactly
25
+ * where a subtle bug would quietly make two answers look more alike than they
26
+ * are, and this release was written off the back of five failures that read as
27
+ * successes.
28
+ */
29
+ import { mkdirSync, writeFileSync } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import { join } from 'node:path';
32
+ import { forwardToCodex, getCodexModelSlugs, isCodexModel } from './codex-backend.js';
33
+ import { hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
34
+ /** Request header that arms a comparison: `x-dario-compare: <model>`. */
35
+ export const COMPARE_HEADER = 'x-dario-compare';
36
+ /**
37
+ * Response header naming the model a comparison was REQUESTED against.
38
+ *
39
+ * Deliberately not "compared-with". The header has to be set before the primary
40
+ * response starts streaming, which is strictly earlier than we can know whether
41
+ * the comparison ran — it may still be skipped for a missing account, an
42
+ * unlisted model, or an unparseable body. A header claiming a comparison
43
+ * happened would therefore sometimes be a lie, which is the exact failure this
44
+ * release exists to stamp out. The name now states only what is true at the
45
+ * moment it is written; whether it actually ran is in the record.
46
+ */
47
+ export const COMPARE_RESULT_HEADER = 'x-dario-compare-requested';
48
+ export const COMPARE_DIR = join(homedir(), '.dario', 'compare');
49
+ /**
50
+ * Read the compare target from request headers. Returns null when unarmed, and
51
+ * for the empty value, so `-H 'x-dario-compare:'` reliably means "off" rather
52
+ * than "compare against a model named empty string".
53
+ */
54
+ export function readCompareTarget(headers) {
55
+ const raw = headers[COMPARE_HEADER] ?? headers[COMPARE_HEADER.toUpperCase()];
56
+ const value = Array.isArray(raw) ? raw[0] : raw;
57
+ if (typeof value !== 'string')
58
+ return null;
59
+ return value.trim() || null;
60
+ }
61
+ /**
62
+ * Swap the model in a JSON request body. Returns null when the body is not
63
+ * JSON — the caller then skips the comparison rather than sending something it
64
+ * could not read.
65
+ */
66
+ export function withModel(body, model) {
67
+ try {
68
+ const parsed = JSON.parse(body.toString());
69
+ if (typeof parsed !== 'object' || parsed === null)
70
+ return null;
71
+ // A comparison is always non-streaming: nobody is watching it arrive, and a
72
+ // single JSON body is far easier to diff later than two SSE transcripts.
73
+ return Buffer.from(JSON.stringify({ ...parsed, model, stream: false }));
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Tee everything written to a real response into a buffer, leaving delivery
81
+ * completely unchanged. `captured()` is meaningful once the response finishes.
82
+ */
83
+ export function teeResponse(res) {
84
+ const started = Date.now();
85
+ const chunks = [];
86
+ const origWrite = res.write.bind(res);
87
+ const origEnd = res.end.bind(res);
88
+ res.write = ((chunk, ...rest) => {
89
+ if (typeof chunk === 'string')
90
+ chunks.push(chunk);
91
+ else if (Buffer.isBuffer(chunk))
92
+ chunks.push(chunk.toString('utf-8'));
93
+ return origWrite(chunk, ...rest);
94
+ });
95
+ res.end = ((chunk, ...rest) => {
96
+ if (typeof chunk === 'string')
97
+ chunks.push(chunk);
98
+ else if (Buffer.isBuffer(chunk))
99
+ chunks.push(chunk.toString('utf-8'));
100
+ return origEnd(chunk, ...rest);
101
+ });
102
+ return {
103
+ captured: () => ({
104
+ status: res.statusCode ?? null,
105
+ body: chunks.join(''),
106
+ ms: Date.now() - started,
107
+ }),
108
+ };
109
+ }
110
+ /**
111
+ * A ServerResponse stand-in that keeps what was written instead of sending it.
112
+ * The comparison has no socket of its own — it is answering nobody.
113
+ */
114
+ function captureSink() {
115
+ const started = Date.now();
116
+ const state = { status: null, chunks: [], headersSent: false };
117
+ const sink = {
118
+ statusCode: 0,
119
+ headersSent: false,
120
+ writeHead(code) { state.status = code; state.headersSent = true; this.headersSent = true; return this; },
121
+ setHeader() { return this; },
122
+ write(s) { if (s !== undefined && s !== null)
123
+ state.chunks.push(String(s)); return true; },
124
+ end(s) { if (s !== undefined && s !== null)
125
+ state.chunks.push(String(s)); return this; },
126
+ on() { return this; },
127
+ once() { return this; },
128
+ emit() { return false; },
129
+ };
130
+ return {
131
+ sink: sink,
132
+ side: () => ({
133
+ status: state.status,
134
+ body: state.chunks.join(''),
135
+ ms: Date.now() - started,
136
+ }),
137
+ };
138
+ }
139
+ /**
140
+ * Run the comparison against the ChatGPT subscription.
141
+ *
142
+ * Resolves to a reason-for-skipping string when it declines, never throwing:
143
+ * the caller is on the success path of a request it has already answered, and
144
+ * a diagnostic must not be able to take that down.
145
+ *
146
+ * v6.0.0 compares against a Codex account only. Comparing against the Claude
147
+ * pool would mean occupying a seat for a request nobody is waiting on, which is
148
+ * a trade worth making deliberately rather than by default.
149
+ */
150
+ export async function runCompare(opts) {
151
+ try {
152
+ if (!(await hasAnyCodexAccount()))
153
+ return { side: null, skipped: 'no Codex account configured' };
154
+ const stored = await selectCodexAccount();
155
+ if (!stored)
156
+ return { side: null, skipped: 'no Codex account configured' };
157
+ const creds = await getFreshCodexAccount(stored);
158
+ const slugs = await getCodexModelSlugs(creds).catch(() => []);
159
+ if (!isCodexModel(opts.targetModel, slugs)) {
160
+ return { side: null, skipped: `${opts.targetModel} is not served by Codex account ${creds.alias}` };
161
+ }
162
+ const body = withModel(opts.body, opts.targetModel);
163
+ if (!body)
164
+ return { side: null, skipped: 'request body is not JSON' };
165
+ const { sink, side } = captureSink();
166
+ await forwardToCodex({}, sink, body, creds, opts.corsOrigin, {}, opts.timeoutMs, opts.verbose, opts.shape);
167
+ return { side: side() };
168
+ }
169
+ catch (err) {
170
+ return { side: null, skipped: `compare failed: ${err.message}` };
171
+ }
172
+ }
173
+ /**
174
+ * Persist one record. Returns the path written, or null on failure — a compare
175
+ * log that cannot be written is not worth failing a served request over.
176
+ */
177
+ export function writeCompareRecord(record, dir = COMPARE_DIR) {
178
+ try {
179
+ mkdirSync(dir, { recursive: true });
180
+ const stamp = record.ts.replace(/[:.]/g, '-');
181
+ // Separators out, then dot-runs collapsed. Stripping separators alone
182
+ // already prevents traversal, but leaving `..` in a filename invites the
183
+ // next reader to assume it was never considered.
184
+ const safeModel = record.comparedModel.replace(/[^\w.-]/g, '_').replace(/\.{2,}/g, '.');
185
+ const base = join(dir, `${stamp}-${safeModel}`);
186
+ const payload = JSON.stringify(record, null, 2);
187
+ // `wx` fails when the file already exists, so two comparisons landing in the
188
+ // same millisecond get distinct files instead of one silently overwriting
189
+ // the other. Exclusive-create also holds ACROSS PROCESSES, which a
190
+ // read-then-write existence check would not: two dario instances sharing a
191
+ // compare directory is an ordinary setup, and losing half a calibration run
192
+ // to a race would be invisible — the log would simply have fewer records
193
+ // than requests, with nothing to indicate why.
194
+ for (let n = 0; n < 50; n++) {
195
+ const path = n === 0 ? `${base}.json` : `${base}-${n + 1}.json`;
196
+ try {
197
+ writeFileSync(path, payload, { encoding: 'utf-8', flag: 'wx' });
198
+ return path;
199
+ }
200
+ catch (err) {
201
+ if (err.code !== 'EEXIST')
202
+ throw err;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
@@ -92,11 +92,12 @@ export interface DarioConfig {
92
92
  effort?: string | null;
93
93
  maxTokens?: number | 'client' | null;
94
94
  /**
95
- * Pool-exhausted fallback. When `model` is a non-empty string and an
96
- * openai-compat backend is configured, OpenAI-shape requests that the
97
- * Claude pool can't serve are forwarded to that backend as `model`
98
- * (response marked `x-dario-pool-fallback`) instead of surfacing the
99
- * 429/503. Null/absent = off.
95
+ * Pool-exhausted fallback. When `model` is a non-empty string, a request
96
+ * the Claude pool can't serve is forwarded as `model` to whichever
97
+ * provider can serve it a stored Codex/ChatGPT subscription that lists
98
+ * it (either wire shape), otherwise a configured openai-compat backend
99
+ * (OpenAI shape only) — instead of surfacing the 429/503. The response
100
+ * is marked `x-dario-pool-fallback`. Null/absent = off.
100
101
  */
101
102
  poolFallback?: {
102
103
  model?: string | null;
package/dist/doctor.d.ts CHANGED
@@ -19,6 +19,31 @@ export interface Check {
19
19
  /** Right-column detail — human readable, may include versions, paths, counts. */
20
20
  detail: string;
21
21
  }
22
+ /**
23
+ * Decide what `doctor` should say about pool-exhaustion failover, from
24
+ * configuration alone. Pure and exported so every branch is testable — the
25
+ * live box can only ever exercise the one that matches its own credentials,
26
+ * which is how the inert case went unnoticed in the first place.
27
+ *
28
+ * This check exists because of a specific outage. The box ran with
29
+ * --pool-fallback armed, no Codex account and no api-key backend, so failover
30
+ * was INERT: correctly configured by every check that existed, and incapable of
31
+ * doing anything. On 2026-08-29 the Claude pool filled twice and the fleet went
32
+ * dark beside an idle ChatGPT subscription. Nothing reported it, because
33
+ * nothing asked "armed" and "has somewhere to go" as a single question.
34
+ *
35
+ * It reports configuration, never reachability. Claiming a route WORKS needs a
36
+ * live request, and this release was built on the lesson that a green config is
37
+ * not a working path.
38
+ */
39
+ export declare function failoverReadiness(input: {
40
+ chain: readonly string[];
41
+ codexAccounts: number;
42
+ backends: readonly string[];
43
+ }): {
44
+ status: CheckStatus;
45
+ detail: string;
46
+ };
22
47
  /**
23
48
  * Format a epoch timestamp reset time relative to the current time.
24
49
  * Returns a human-friendly string like "1h 9m", "45m", "2d 3h".
package/dist/doctor.js CHANGED
@@ -22,6 +22,57 @@ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
22
22
  import { runAuthorizeProbe } from './cc-authorize-probe.js';
23
23
  import { MIGRATED_LOGIN_ALIAS } from './accounts.js';
24
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ /**
26
+ * Decide what `doctor` should say about pool-exhaustion failover, from
27
+ * configuration alone. Pure and exported so every branch is testable — the
28
+ * live box can only ever exercise the one that matches its own credentials,
29
+ * which is how the inert case went unnoticed in the first place.
30
+ *
31
+ * This check exists because of a specific outage. The box ran with
32
+ * --pool-fallback armed, no Codex account and no api-key backend, so failover
33
+ * was INERT: correctly configured by every check that existed, and incapable of
34
+ * doing anything. On 2026-08-29 the Claude pool filled twice and the fleet went
35
+ * dark beside an idle ChatGPT subscription. Nothing reported it, because
36
+ * nothing asked "armed" and "has somewhere to go" as a single question.
37
+ *
38
+ * It reports configuration, never reachability. Claiming a route WORKS needs a
39
+ * live request, and this release was built on the lesson that a green config is
40
+ * not a working path.
41
+ */
42
+ export function failoverReadiness(input) {
43
+ const { chain, codexAccounts, backends } = input;
44
+ const hasCodex = codexAccounts > 0;
45
+ const hasBackend = backends.length > 0;
46
+ if (chain.length === 0) {
47
+ return { status: 'info', detail: 'off — a drained Claude pool returns 429/503 (--pool-fallback to arm)' };
48
+ }
49
+ if (!hasCodex && !hasBackend) {
50
+ return {
51
+ status: 'warn',
52
+ detail: `armed (${chain.join(' → ')}) but INERT — no Codex account and no backend to fall back to. `
53
+ + 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).',
54
+ };
55
+ }
56
+ if (hasCodex && chain.length > 1) {
57
+ return {
58
+ status: 'ok',
59
+ detail: `symmetric: ${chain.join(' → ')}, across ${codexAccounts} Codex account`
60
+ + `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}`,
61
+ };
62
+ }
63
+ if (hasCodex) {
64
+ return {
65
+ status: 'ok',
66
+ detail: `claude → codex as ${chain[0]} (both wire shapes). One-way — add a Claude model to `
67
+ + 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.',
68
+ };
69
+ }
70
+ return {
71
+ status: 'ok',
72
+ detail: `claude → ${backends[0]} as ${chain[0]} — OpenAI path only. A Codex account would `
73
+ + 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).',
74
+ };
75
+ }
25
76
  /**
26
77
  * Format a epoch timestamp reset time relative to the current time.
27
78
  * Returns a human-friendly string like "1h 9m", "45m", "2d 3h".
@@ -951,6 +1002,26 @@ export async function runChecks(opts = {}) {
951
1002
  catch (err) {
952
1003
  checks.push({ status: 'warn', label: 'Backends', detail: `check failed: ${err.message}` });
953
1004
  }
1005
+ // ---- Failover readiness (v6.0.0) — see failoverReadiness() for the why.
1006
+ try {
1007
+ const { loadConfig } = await import('./config-file.js');
1008
+ const { listCodexAccountAliases } = await import('./codex-accounts.js');
1009
+ const { listBackends } = await import('./openai-backend.js');
1010
+ const cfg = loadConfig().config;
1011
+ const raw = (process.env.DARIO_POOL_FALLBACK ?? cfg.poolFallback?.model ?? '').trim();
1012
+ const chain = raw.split(',').map((m) => m.trim()).filter(Boolean);
1013
+ const codexAliases = await listCodexAccountAliases().catch(() => []);
1014
+ const backends = await listBackends().catch(() => []);
1015
+ const verdict = failoverReadiness({
1016
+ chain,
1017
+ codexAccounts: codexAliases.length,
1018
+ backends: backends.map((b) => b.name),
1019
+ });
1020
+ checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
1021
+ }
1022
+ catch (err) {
1023
+ checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
1024
+ }
954
1025
  // ---- CC sub-agent (v3.26, direction #2)
955
1026
  try {
956
1027
  const { loadSubagentStatus } = await import('./subagent.js');
@@ -99,4 +99,35 @@ export declare const DEFAULT_ADAPTERS: readonly ProviderAdapter[];
99
99
  * because it's a cross-adapter relationship (a Claude-primary request that
100
100
  * spills to openai on pool exhaustion), not a primary claim by either side.
101
101
  */
102
+ /**
103
+ * Who serves a request the Claude pool could not, and on which wire shapes.
104
+ *
105
+ * This exists because v6.0.0 shipped the failover dispatcher correctly and the
106
+ * GATE in front of it wrongly. `selectPoolAccount()` in proxy.ts still required
107
+ * an api-key backend AND the OpenAI path before it would defer, so a box with a
108
+ * Codex account and no api-key backend — the deployment this release is FOR —
109
+ * got a 503 before the dispatcher ran, and Anthropic-shape requests never
110
+ * reached it at all. Every routing test passed, because not one of them went
111
+ * through that selector.
112
+ *
113
+ * So the matrix lives here, in one place, stated once:
114
+ *
115
+ * codex account lists the model → 'codex' (both wire shapes)
116
+ * else api-key backend, OpenAI path → 'openai' (no Messages translation)
117
+ * else → 'unavailable' (honest 503)
118
+ *
119
+ * A caveat worth keeping in view: this is a SPECIFICATION test surface, not a
120
+ * wiring one. It cannot prove proxy.ts asks it the right question at the right
121
+ * moment — that is precisely what broke — and only a live request through the
122
+ * selector can. Treat green here as necessary, never sufficient.
123
+ */
124
+ export type PoolFallbackOutcome = 'codex' | 'openai' | 'unavailable';
125
+ export declare function poolFallbackOutcome(input: {
126
+ fallbackModels: readonly string[];
127
+ poolSize: number;
128
+ /** A stored Codex account LISTS one of `fallbackModels`. */
129
+ codexServes: boolean;
130
+ hasOpenAIBackend: boolean;
131
+ isOpenAIPath: boolean;
132
+ }): PoolFallbackOutcome;
102
133
  export declare function route(ctx: RouteContext, adapters?: readonly ProviderAdapter[]): RouteDecision;
@@ -87,25 +87,47 @@ export const claudeAdapter = {
87
87
  },
88
88
  };
89
89
  export const DEFAULT_ADAPTERS = [codexAdapter, openaiAdapter, claudeAdapter];
90
- /**
91
- * Resolve the routing decision. Offers the request to adapters in priority
92
- * order and takes the first primary claim; the Claude adapter always claims, so
93
- * the result is total. The claude→openai pool fallback is layered on top
94
- * because it's a cross-adapter relationship (a Claude-primary request that
95
- * spills to openai on pool exhaustion), not a primary claim by either side.
96
- */
90
+ export function poolFallbackOutcome(input) {
91
+ const { fallbackModels, poolSize, codexServes, hasOpenAIBackend, isOpenAIPath } = input;
92
+ // Unarmed, or an empty pool, is not a failover situation at all: an empty
93
+ // pool is a setup error the operator must see, not traffic to re-bill.
94
+ if (fallbackModels.length === 0 || poolSize === 0)
95
+ return 'unavailable';
96
+ if (codexServes)
97
+ return 'codex';
98
+ if (hasOpenAIBackend && isOpenAIPath)
99
+ return 'openai';
100
+ return 'unavailable';
101
+ }
97
102
  export function route(ctx, adapters = DEFAULT_ADAPTERS) {
98
103
  const ordered = [...adapters].sort((a, b) => b.priority - a.priority);
99
104
  const primary = ordered.find((a) => a.claimsPrimary(ctx)) ?? claudeAdapter;
100
105
  let fallback = null;
101
106
  let reason = `${primary.id} primary`;
102
- if (primary.id === 'claude' &&
103
- ctx.poolFallbackModel !== null &&
104
- ctx.hasOpenAIBackend &&
105
- ctx.isOpenAIPath &&
106
- ctx.poolSize > 0) {
107
- fallback = 'openai';
108
- reason = 'claude primary, openai fallback on pool-exhaustion';
107
+ // NOTE: this reports the CLAUDE-PRIMARY direction. The reverse — a codex
108
+ // primary declining a 429/5xx so the request lands on the Claude pool — is
109
+ // dispatched in proxy.ts, because it is a mid-flight decision that depends
110
+ // on the upstream's answer rather than on anything knowable when routing.
111
+ //
112
+ // Pool-exhaustion failover (v6.0.0). The target is whichever provider can
113
+ // actually serve `poolFallbackModel`, which makes a SECOND SUBSCRIPTION a
114
+ // first-class failover target rather than requiring an API key.
115
+ //
116
+ // codex is preferred when the nominated model is one its account lists: it
117
+ // is a plan you already pay for, so failover costs nothing per token, and
118
+ // since v5.5.87 it serves BOTH wire shapes — an Anthropic-shape client
119
+ // (Claude Code, any Anthropic SDK, a fleet agent) can fail over too. The
120
+ // api-key backend keeps its OpenAI-path guard: there is still no Messages
121
+ // translation on that route.
122
+ if (primary.id === 'claude' && ctx.poolFallbackModel !== null && ctx.poolSize > 0) {
123
+ if (ctx.hasCodexAccount && isCodexModel(ctx.poolFallbackModel, ctx.codexModels)) {
124
+ fallback = 'codex';
125
+ reason = 'claude primary, codex (subscription) fallback on pool-exhaustion';
126
+ }
127
+ else if (ctx.hasOpenAIBackend && ctx.isOpenAIPath) {
128
+ fallback = 'openai';
129
+ reason = 'claude primary, openai fallback on pool-exhaustion';
130
+ }
109
131
  }
110
132
  return { provider: primary.id, fallback, reason };
111
133
  }
package/dist/proxy.d.ts CHANGED
@@ -376,15 +376,21 @@ interface ProxyOptions {
376
376
  */
377
377
  maxTokens?: number | 'client';
378
378
  /**
379
- * Pool-exhausted fallback model (strictly opt-in; off when unset/empty).
380
- * When the Claude pool can't serve selection finds every seat drained
381
- * or cooling, or a mid-flight 429 has no peer left — OpenAI-shape
382
- * requests (/v1/chat/completions) are forwarded to the configured
383
- * openai-compat backend with the model swapped to this value, instead
384
- * of surfacing the 429/503. Responses carry `x-dario-pool-fallback`.
385
- * Anthropic-shape requests keep the error: dario has no OpenAI→Anthropic
386
- * response translation. Inert without a configured backend. Sourced from
387
- * `--pool-fallback` / `DARIO_POOL_FALLBACK` / config `poolFallback.model`.
379
+ * Pool-exhausted fallback (strictly opt-in; off when unset/empty). May name
380
+ * a CHAIN `gpt-5.6-sol,claude-sonnet-5`read left to right, each
381
+ * provider taking the first entry it can actually serve.
382
+ *
383
+ * When a provider can't serve — the pool finds every seat drained or
384
+ * cooling, a mid-flight 429 has no peer left, or a subscription answers
385
+ * 429/5xx the request is served as the nominated model by whoever can,
386
+ * instead of surfacing the error. A Codex/ChatGPT subscription is preferred
387
+ * and works on BOTH wire shapes; an openai-compat backend is OpenAI-path
388
+ * only, having no Messages translation. A chain makes failover symmetric in
389
+ * both directions. Responses carry `x-dario-pool-fallback`.
390
+ *
391
+ * Inert with no Codex account and no backend — `dario doctor` says so.
392
+ * Sourced from `--pool-fallback` / `DARIO_POOL_FALLBACK` / config
393
+ * `poolFallback.model`.
388
394
  */
389
395
  poolFallbackModel?: string;
390
396
  /**