@zergai/ztc 0.4.0-beta.1 → 0.4.1-dev.36919234045

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.
Files changed (92) hide show
  1. package/README.md +8 -1
  2. package/THIRD_PARTY_NOTICES.md +4 -0
  3. package/dist/App.js +2 -2
  4. package/dist/agent/agent.js +41 -5
  5. package/dist/agent/backends/anthropic.js +5 -10
  6. package/dist/agent/backends/gemini.js +2 -0
  7. package/dist/agent/backends/index.d.ts +1 -0
  8. package/dist/agent/backends/index.js +10 -1
  9. package/dist/agent/backends/openai.d.ts +0 -6
  10. package/dist/agent/backends/openai.js +5 -2
  11. package/dist/agent/backends/openai_compatible.d.ts +2 -0
  12. package/dist/agent/backends/openai_compatible.js +18 -8
  13. package/dist/agent/backends/provider_ids.d.ts +1 -0
  14. package/dist/agent/backends/provider_ids.js +4 -0
  15. package/dist/agent/backends/types.d.ts +1 -0
  16. package/dist/agent/backends/zrouter_stream.d.ts +5 -1
  17. package/dist/agent/backends/zrouter_stream.js +141 -37
  18. package/dist/agent/commands/compact.js +6 -5
  19. package/dist/agent/commands/config.js +4 -1
  20. package/dist/agent/commands/context.js +6 -1
  21. package/dist/agent/commands/effort.js +14 -6
  22. package/dist/agent/commands/model.js +12 -2
  23. package/dist/agent/commands/profile.js +21 -2
  24. package/dist/agent/commands/provider_selection.js +14 -1
  25. package/dist/agent/commands/settings.js +45 -7
  26. package/dist/agent/commands/types.d.ts +19 -0
  27. package/dist/agent/factory.js +14 -68
  28. package/dist/agent/subagent.d.ts +0 -10
  29. package/dist/agent/subagent.js +22 -0
  30. package/dist/agent/tool_response_store.d.ts +1 -1
  31. package/dist/agent/tool_response_store.js +4 -2
  32. package/dist/agent/tools/pane.d.ts +0 -7
  33. package/dist/agent/tools/pane.js +5 -4
  34. package/dist/app/command_context.js +33 -10
  35. package/dist/app/model_catalog_cache.d.ts +5 -0
  36. package/dist/app/model_catalog_cache.js +138 -0
  37. package/dist/app/restart.d.ts +1 -1
  38. package/dist/app/restart.js +16 -5
  39. package/dist/app/selection_completion.d.ts +17 -2
  40. package/dist/app/selection_completion.js +74 -6
  41. package/dist/app/session_manager.js +13 -8
  42. package/dist/cli_args.js +2 -16
  43. package/dist/cli_main.js +25 -6
  44. package/dist/cli_ratatui.js +70 -33
  45. package/dist/config/defaults.d.ts +2 -1
  46. package/dist/config/defaults.js +9 -2
  47. package/dist/config/resolved.d.ts +2 -2
  48. package/dist/config.d.ts +35 -3
  49. package/dist/config.js +227 -29
  50. package/dist/headless.js +2 -0
  51. package/dist/onboarding/first_run.js +5 -1
  52. package/dist/panes/model_catalog.d.ts +3 -3
  53. package/dist/panes/model_catalog.js +28 -4
  54. package/dist/panes/pane_registry.js +6 -2
  55. package/dist/panes/types.d.ts +2 -0
  56. package/dist/panes/ztc_launch.js +12 -0
  57. package/dist/session.d.ts +6 -2
  58. package/dist/session.js +65 -10
  59. package/dist/ui/views/completion_menu.js +10 -5
  60. package/dist/ui/views/status_bar.js +9 -5
  61. package/dist/ui/widgets/resume_panel.d.ts +2 -0
  62. package/dist/ui/widgets/resume_panel.js +17 -15
  63. package/dist/utils/compaction.d.ts +1 -1
  64. package/dist/utils/compaction.js +2 -2
  65. package/dist/utils/context.d.ts +4 -4
  66. package/dist/utils/context.js +14 -12
  67. package/dist/utils/deepseek.d.ts +2 -2
  68. package/dist/utils/deepseek.js +13 -5
  69. package/dist/utils/model_cache.d.ts +15 -0
  70. package/dist/utils/model_cache.js +143 -0
  71. package/dist/utils/models.d.ts +4 -2
  72. package/dist/utils/models.js +12 -16
  73. package/dist/utils/reasoning.d.ts +8 -1
  74. package/dist/utils/reasoning.js +31 -11
  75. package/dist/utils/router_catalog.d.ts +47 -0
  76. package/dist/utils/router_catalog.js +222 -0
  77. package/dist/zlm-embed.mjs +1180 -514
  78. package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
  79. package/node_modules/@zerg/model-contracts/README.md +78 -0
  80. package/node_modules/@zerg/model-contracts/index.d.ts +18 -0
  81. package/node_modules/@zerg/model-contracts/index.js +220 -0
  82. package/node_modules/@zerg/model-contracts/package.json +8 -0
  83. package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
  84. package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
  85. package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
  86. package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
  87. package/package.json +9 -6
  88. package/packages/model-contracts/LICENSE.md +375 -0
  89. package/packages/model-contracts/README.md +78 -0
  90. package/packages/model-contracts/index.d.ts +18 -0
  91. package/packages/model-contracts/index.js +220 -0
  92. package/packages/model-contracts/package.json +8 -0
@@ -1,12 +1,14 @@
1
+ import { modelContract, normalizeContractModel } from '@zerg/model-contracts';
2
+ import { resolveRouterModel } from '../../utils/router_catalog.js';
1
3
  import { randomUUID } from 'node:crypto';
2
4
  import { measureResponse, measureStream } from './request_metrics.js';
3
5
  import { parseSSEEvents } from './sse.js';
4
6
  import { buildOpenAIMessages, buildOpenAIToolDefs } from './openai_format.js';
5
- import { buildKimiRequestParams, isKimiModel } from './kimi_params.js';
7
+ import { isKimiModel } from './kimi_params.js';
6
8
  import { runtimeDiag } from '../../utils/debug_log.js';
7
9
  import { createZrouterEventMapper } from './zrouter_events.js';
8
10
  import { parseTokenUsage } from './token_usage.js';
9
- import { deepseekRequestParams, DEEPSEEK_FLASH_MAX_OUTPUT, isModalDeepSeekFlashModel, MODAL_DEEPSEEK_FLASH_MAX_OUTPUT, isDeepSeekFlashModel } from '../../utils/deepseek.js';
11
+ import { DEEPSEEK_FLASH_MAX_OUTPUT, isModalDeepSeekFlashModel, MODAL_DEEPSEEK_FLASH_MAX_OUTPUT, isDeepSeekFlashModel } from '../../utils/deepseek.js';
10
12
  import { getVersion } from '../../utils/version.js';
11
13
  import { isTransportError } from './transport_errors.js';
12
14
  const ZROUTER_HEADERS_TIMEOUT_MS = 90_000;
@@ -23,19 +25,13 @@ const PINNABLE_DECORATION = /^[A-Za-z0-9._:-]{1,64}$/;
23
25
  function isPinnableDecoration(value) {
24
26
  return value !== undefined && PINNABLE_DECORATION.test(value);
25
27
  }
26
- // Transport reconnects must outlive a Router deploy or a Redis blip, not just a
27
- // lost packet: a Fly machine recycle RSTs every client's stream at once, so the
28
- // whole fleet sees "fetch failed" at the same moment. Three sub-second retries
29
- // used to hand a live turn back to the agent as a dead "Network error". Six
30
- // attempts with capped exponential backoff buy roughly 8-15s inside this
31
- // backend; the agent's own retry loop layers a second budget on top, and the
32
- // Router keeps buffering the producer under the same turn id the whole time, so
33
- // the reconnect resumes instead of restarting. Every knob is env-overridable
34
- // because a self-hosted Router may restart more slowly.
35
- const DEFAULT_RECONNECT_ATTEMPTS = 6;
36
- const DEFAULT_RECONNECT_BASE_MS = 500;
37
- const DEFAULT_RECONNECT_CAP_MS = 10_000;
38
- const MAX_RECONNECT_ATTEMPTS = 20;
28
+ // Allow Router restarts to recover through bounded, cancellable retries. Each
29
+ // reconnect reports progress and resumes the same turn/cursor. Hosts can tune
30
+ // the attempt count and backoff through the environment.
31
+ const DEFAULT_RECONNECT_ATTEMPTS = 120;
32
+ const DEFAULT_RECONNECT_BASE_MS = 1_000;
33
+ const DEFAULT_RECONNECT_CAP_MS = 30_000;
34
+ const MAX_RECONNECT_ATTEMPTS = 120;
39
35
  const MAX_RECONNECT_MS = 300_000;
40
36
  function envBoundedInt(name, fallback, min, max) {
41
37
  const parsed = Number(process.env[name]);
@@ -59,6 +55,7 @@ function resolveReconnectPolicy() {
59
55
  * uses the unified chat endpoint so routing can fall back across providers.
60
56
  */
61
57
  export class ZRouterStreamBackend {
58
+ catalog;
62
59
  apiKey;
63
60
  proxyUrl;
64
61
  provider;
@@ -67,6 +64,7 @@ export class ZRouterStreamBackend {
67
64
  conversationId;
68
65
  clientName;
69
66
  constructor(config) {
67
+ this.catalog = config.catalog ? structuredClone(config.catalog) : undefined;
70
68
  this.apiKey = config.apiKey;
71
69
  this.proxyUrl = config.proxyUrl.replace(/\/+$/, '');
72
70
  this.provider = config.provider;
@@ -87,12 +85,45 @@ export class ZRouterStreamBackend {
87
85
  handlesTransportRetries() {
88
86
  return true;
89
87
  }
88
+ getModelCapabilities(model) {
89
+ return this.catalog?.version === 1
90
+ ? resolveRouterModel(this.catalog, this.provider, model)?.capabilities : undefined;
91
+ }
90
92
  buildOpenAIBody(request) {
91
- // ZTC stores the model separately from its provider. Router's global model
93
+ if (this.catalog?.version === 1) {
94
+ const descriptor = resolveRouterModel(this.catalog, this.provider, request.model);
95
+ if (!descriptor?.capabilities?.chat)
96
+ throw new Error('This model is no longer in the Router chat catalog. Refresh /model.');
97
+ const caps = descriptor.capabilities;
98
+ if (request.codexEffort && caps.reasoning && !caps.reasoning.choices.includes(request.codexEffort)) {
99
+ throw new Error(`Unsupported reasoning effort: ${request.codexEffort}`);
100
+ }
101
+ if (request.maxTokens !== undefined && caps.max_output_tokens !== undefined && request.maxTokens > caps.max_output_tokens) {
102
+ throw new Error(`Max output tokens exceeds the advertised limit (${caps.max_output_tokens}).`);
103
+ }
104
+ if (caps.images === false && request.messages.some(message => Array.isArray(message.content)
105
+ && message.content.some(block => block.type === 'image' || block.type === 'video')))
106
+ throw new Error('This model does not support images.');
107
+ const prepared = caps.tools === false ? chatOnlyRequest(request) : request;
108
+ return { provider: descriptor.owned_by, model: descriptor.id,
109
+ ...(request.routePlan ? { route: request.routePlan } : {}),
110
+ ...(request.maxTokens === undefined ? {} : { max_output_tokens: request.maxTokens }),
111
+ ...(caps.reasoning && request.codexEffort ? { reasoning_effort: request.codexEffort } : {}),
112
+ messages: buildOpenAIMessages(prepared),
113
+ ...(prepared.tools.length ? { tools: buildOpenAIToolDefs(prepared.tools) } : {}),
114
+ };
115
+ }
116
+ // Legacy contract for older Routers only. ZTC stores the model separately from its provider. Router's global model
92
117
  // namespace is a wire concern; keep existing namespaced IDs compatible.
93
118
  if (this.provider === 'modal' && !/^modal\//i.test(request.model)) {
94
119
  request = { ...request, model: `modal/${request.model}` };
95
120
  }
121
+ // Router resolves the Fireworks upstream id from the NAMESPACED form:
122
+ // fireworksUpstreamModelId returns null without the prefix and provider-client
123
+ // then answers 400 before dispatch. ZTC stores the bare slug, so add it here.
124
+ if (this.provider === 'fireworks') {
125
+ request = { ...request, model: `fireworks/${normalizeContractModel('fireworks', request.model)}` };
126
+ }
96
127
  // Kimi deprecates max_tokens in favour of max_completion_tokens, same as the
97
128
  // o-series and GPT-5+; keep this in step with the direct backend so the token
98
129
  // field does not depend on how the user authenticated.
@@ -115,20 +146,42 @@ export class ZRouterStreamBackend {
115
146
  model: request.model,
116
147
  ...(request.routePlan ? { route: request.routePlan } : {}),
117
148
  ...tokenParam,
118
- // Kimi honors a reasoning_effort the picker exposes; without this the same
119
- // selection applied on a direct key and vanished through the proxy.
120
- // No-op for every non-Kimi model.
121
- // Modal uses Kimi's effort ladder but keeps its own max_tokens contract.
122
- ...buildKimiRequestParams(this.provider === 'modal'
123
- ? { ...request, model: request.model.slice('modal/'.length) }
124
- : request),
125
- ...deepseekRequestParams(request),
149
+ ...(request.codexEffort && modelContract(this.provider, request.model, 'chat_tools')?.reasoningEfforts.includes(request.codexEffort)
150
+ ? { reasoning_effort: request.codexEffort } : {}),
126
151
  messages: buildOpenAIMessages(request),
127
152
  ...(request.tools.length > 0 ? { tools: buildOpenAIToolDefs(request.tools) } : {})
128
153
  };
129
154
  }
130
- buildBody(request) {
131
- return this.buildOpenAIBody(request);
155
+ serializeBody(request, stream = false) {
156
+ const body = {
157
+ ...this.buildOpenAIBody(request),
158
+ ...(stream ? { stream: true } : {}),
159
+ };
160
+ const serialized = JSON.stringify(body);
161
+ // Funded DeepSeek admission scans at most 10,000 JSON values, independently
162
+ // of the model's token window. A longer text history is otherwise rejected
163
+ // as non-text trial input, and resumable streaming masks that as a 5xx.
164
+ // Signal context overflow locally so hosts compact and retry, including
165
+ // when an agent's tool loop crosses the limit before returning to idle.
166
+ if (body.model === 'modal/deepseek-v4.1-flash'
167
+ || body.model === 'fireworks/deepseek-v4p1-flash') {
168
+ // Count the serialized wire shape: undefined properties disappear, and
169
+ // tool calls/results expand into separate messages during encoding.
170
+ const pending = [JSON.parse(serialized)];
171
+ let values = 1;
172
+ while (pending.length > 0) {
173
+ const value = pending.pop();
174
+ if (value === null || typeof value !== 'object')
175
+ continue;
176
+ for (const child of Object.values(value)) {
177
+ if (++values > 10_000) {
178
+ throw new Error('Prompt is too long for Router\'s context structure limit (10,000 JSON values). Compact the conversation before retrying.');
179
+ }
180
+ pending.push(child);
181
+ }
182
+ }
183
+ }
184
+ return serialized;
132
185
  }
133
186
  async buildHeaders(request) {
134
187
  const token = this.authTokenProvider
@@ -141,6 +194,7 @@ export class ZRouterStreamBackend {
141
194
  const conversationPin = this.conversationPin(request);
142
195
  return {
143
196
  'Content-Type': 'application/json',
197
+ ...(this.catalog?.version === 1 ? { 'X-Zerg-Model-Contract': '1' } : {}),
144
198
  'Authorization': `Bearer ${normalizedToken}`,
145
199
  'X-Zerg-Client': this.clientName,
146
200
  'X-Zerg-Client-Version': getVersion(),
@@ -183,7 +237,7 @@ export class ZRouterStreamBackend {
183
237
  return measureResponse(routedRequest, fetcher => this.generateResponse(routedRequest, fetcher));
184
238
  }
185
239
  async generateResponse(request, fetcher) {
186
- const body = this.buildBody(request);
240
+ const body = this.serializeBody(request);
187
241
  const headers = await this.buildHeaders(request);
188
242
  // Proof-chain log: the exact router endpoint + model this turn dispatches to.
189
243
  runtimeDiag('info', `[ztc] zrouter dispatch ${JSON.stringify({
@@ -195,15 +249,19 @@ export class ZRouterStreamBackend {
195
249
  const response = await fetcher(this.endpointFor(), {
196
250
  method: 'POST',
197
251
  headers,
198
- body: JSON.stringify(body),
252
+ body,
199
253
  signal: request.signal,
200
254
  });
201
255
  if (!response.ok) {
202
256
  const errorText = await response.text();
203
- throw new Error(`ZRouter API error (${response.status}): ${errorText}`);
257
+ throw new Error(`ZRouter API error (${response.status}): ${errorText}${failureHeaderSuffix(response)}`);
204
258
  }
205
259
  const data = await response.json();
206
- return this.parseOpenAIResponse(data);
260
+ const parsed = this.parseOpenAIResponse(data);
261
+ if (this.getModelCapabilities(request.model)?.tools === false && parsed.content.some(block => block.type === 'tool_use')) {
262
+ throw new Error('Chat-only model returned tools; execution is disabled.');
263
+ }
264
+ return parsed;
207
265
  }
208
266
  parseOpenAIResponse(data) {
209
267
  // ZRouter wraps OpenAI responses in OpenAI-compatible format
@@ -248,12 +306,15 @@ export class ZRouterStreamBackend {
248
306
  return measureStream(routedRequest, fetcher => this.streamResponse(routedRequest, fetcher));
249
307
  }
250
308
  async *streamResponse(request, fetcher) {
251
- const body = { ...this.buildBody(request), stream: true };
309
+ const body = this.serializeBody(request, true);
252
310
  const mapEvent = createZrouterEventMapper();
253
311
  let resumeAfter;
254
312
  let emittedMappedEvent = false;
255
313
  let resumeSupported = false;
256
314
  const policy = resolveReconnectPolicy();
315
+ let reconnectStarted;
316
+ const budget = () => resumeAfter ? 300_000 : 30_000;
317
+ const remaining = () => budget() - (reconnectStarted === undefined ? 0 : Date.now() - reconnectStarted);
257
318
  for (let attempt = 1; attempt <= policy.attempts; attempt++) {
258
319
  try {
259
320
  const headers = await this.buildHeaders(request);
@@ -262,12 +323,12 @@ export class ZRouterStreamBackend {
262
323
  const response = await fetchWithHeadersTimeout(fetcher, this.endpointFor(), {
263
324
  method: 'POST',
264
325
  headers,
265
- body: JSON.stringify(body),
326
+ body,
266
327
  signal: request.signal,
267
- }, ZROUTER_HEADERS_TIMEOUT_MS);
328
+ }, Math.min(ZROUTER_HEADERS_TIMEOUT_MS, Math.max(1, remaining())));
268
329
  if (!response.ok) {
269
330
  const errorText = await response.text();
270
- throw new Error(`ZRouter API error (${response.status}): ${errorText}`);
331
+ throw new Error(`ZRouter API error (${response.status}): ${errorText}${failureHeaderSuffix(response)}`);
271
332
  }
272
333
  if (!response.body) {
273
334
  throw new Error('ZRouter API error: no response body for streaming request');
@@ -298,6 +359,9 @@ export class ZRouterStreamBackend {
298
359
  throw new Error(`ZRouter API error (${status}): ${JSON.stringify(ev.body ?? {})}`);
299
360
  }
300
361
  const mapped = mapEvent(ev);
362
+ if (this.getModelCapabilities(request.model)?.tools === false && mapped.some(event => event.type.startsWith('tool_use'))) {
363
+ throw new Error('Chat-only model returned tools; execution is disabled.');
364
+ }
301
365
  if (mapped.length > 0) {
302
366
  // Heartbeats are liveness, not model output. A drop while only
303
367
  // heartbeats have arrived cannot duplicate visible text, so it
@@ -328,15 +392,22 @@ export class ZRouterStreamBackend {
328
392
  // second copy of the turn, so fail the attempt instead.
329
393
  throw error;
330
394
  }
331
- if (attempt === policy.attempts)
395
+ reconnectStarted ??= Date.now();
396
+ const attempts = resumeAfter ? policy.attempts : Math.min(6, policy.attempts);
397
+ if (attempt >= attempts || remaining() <= 0)
332
398
  throw error;
333
399
  // The Router keeps producing under this turn id, so waiting costs the
334
400
  // turn nothing: the next attempt resumes from the cursor, not from zero.
335
- const delayMs = reconnectDelay(policy, attempt);
401
+ const delayMs = Math.min(reconnectDelay(policy, attempt), remaining());
336
402
  const reason = error instanceof Error ? error.message : String(error);
337
403
  runtimeDiag('warn', `[ztc] zrouter stream dropped (${reason}); reconnecting in ${delayMs}ms (attempt ${attempt + 1}/${policy.attempts})`);
338
- request.onTransportRetry?.({ attempt, maxAttempts: policy.attempts, delayMs, reason });
404
+ const publicReason = isRetryableResumeStatus(error)
405
+ ? `Router temporarily unavailable ${reason.match(/\(\d{3}\)/)?.[0] ?? ''}. Resuming the response.`
406
+ : reason;
407
+ request.onTransportRetry?.({ attempt, maxAttempts: attempts, delayMs, reason: publicReason });
339
408
  await waitForReconnect(delayMs, request.signal);
409
+ if (remaining() <= 0)
410
+ throw error;
340
411
  }
341
412
  }
342
413
  }
@@ -387,6 +458,27 @@ async function waitForReconnect(delayMs, signal) {
387
458
  signal?.addEventListener('abort', abort, { once: true });
388
459
  });
389
460
  }
461
+ /**
462
+ * Diagnostics that survive a body-less failure.
463
+ *
464
+ * A gateway that throws before it can classify anything often answers with a
465
+ * bare `Server Error` and puts the identifying information in the headers
466
+ * instead. Fly (Router's host) stamps a request id on every response; carrying
467
+ * it into the error is the only way to correlate a client-visible 500 with the
468
+ * server-side exception, since the body has nothing to correlate on.
469
+ *
470
+ * Bounded and header-only: never reads the body (already consumed), and caps
471
+ * each value so a hostile/proxied header cannot bloat an error message.
472
+ */
473
+ function failureHeaderSuffix(response) {
474
+ const parts = [];
475
+ for (const name of ['fly-request-id', 'x-request-id', 'cf-ray', 'x-zerg-error-code']) {
476
+ const value = response.headers.get(name)?.trim();
477
+ if (value && value.length <= 128 && !/[\r\n\0]/.test(value))
478
+ parts.push(`${name}=${value}`);
479
+ }
480
+ return parts.length > 0 ? ` [${parts.join(' ')}]` : '';
481
+ }
390
482
  /**
391
483
  * True for a `ZRouter API error (NNN)` a resume is worth retrying: a gateway or
392
484
  * rate-limit failure that is expected to clear. Kept separate from
@@ -415,3 +507,15 @@ function normalizeHeaderValue(value, label, maxLength) {
415
507
  }
416
508
  return normalized;
417
509
  }
510
+ /** A wire-only projection: saved tool history remains byte-for-byte intact. */
511
+ function chatOnlyRequest(request) {
512
+ return { ...request, tools: [], messages: request.messages.map(message => ({ ...message,
513
+ content: typeof message.content === 'string' ? message.content : message.content.map(block => {
514
+ if (block.type === 'tool_use')
515
+ return { type: 'text', text: `Previous tool call ${block.name} (${block.id}): ${JSON.stringify(block.input)}` };
516
+ if (block.type === 'tool_result')
517
+ return { type: 'text', text: `Previous tool result (${block.tool_use_id}): ${typeof block.content === 'string' ? block.content : block.content.map(part => part.type === 'text' ? part.text : '[image]').join('\n')}` };
518
+ return block;
519
+ }),
520
+ })) };
521
+ }
@@ -1,5 +1,5 @@
1
1
  import { compactConversation, describeCompaction, smartSummarizer, localSummarizer } from '../../utils/compaction.js';
2
- import { getContextStatus, contextStatusFromUsed, formatTokenCount } from '../../utils/context.js';
2
+ import { estimateConversationTokens, getContextStatus, contextStatusFromUsed, formatTokenCount } from '../../utils/context.js';
3
3
  import { MAX_COMPACTION_TARGET_PERCENT, MIN_COMPACTION_TARGET_PERCENT, resolveCompactionSettings, resolveCompactionTargetPercent, resolveContextTokenBudget, tokenTriggerActive, } from '../../config/compaction.js';
4
4
  export const compactCommand = {
5
5
  name: 'compact',
@@ -18,9 +18,10 @@ export const compactCommand = {
18
18
  // The "before" percentage should match the context bar (the real provider
19
19
  // token count); fall back to the char/4 estimate only before the first call.
20
20
  const realUsed = ctx.getContextTokens?.() ?? 0;
21
+ const catalogWindow = ctx.config.getModelContextWindow?.(model);
21
22
  const status = realUsed > 0
22
- ? contextStatusFromUsed(realUsed, model, ctx.getContextWindow?.())
23
- : getContextStatus(messages, model);
23
+ ? contextStatusFromUsed(realUsed, model, catalogWindow !== undefined ? catalogWindow : ctx.getContextWindow?.())
24
+ : getContextStatus(messages, model, catalogWindow);
24
25
  // An explicit argument wins; otherwise the configured target applies, so
25
26
  // /compact and auto-compaction aim at the same place.
26
27
  const targetPercent = args[0] ? parseInt(args[0], 10) : resolveCompactionTargetPercent(ctx.config.get());
@@ -55,7 +56,7 @@ export const compactCommand = {
55
56
  ? resolveContextTokenBudget(ctx.config.get())
56
57
  : undefined;
57
58
  const targetTokens = budget === undefined
58
- ? undefined
59
+ ? (catalogWindow === undefined ? undefined : Math.max(1, Math.floor((catalogWindow ?? estimateConversationTokens(messages)) * targetPercent / 100)))
59
60
  : Math.floor(budget * (targetPercent / 100));
60
61
  const result = await compactConversation(messages, model, {
61
62
  method: compaction.method,
@@ -74,7 +75,7 @@ export const compactCommand = {
74
75
  ...(msg.metadata ? { metadata: msg.metadata } : {}),
75
76
  });
76
77
  }
77
- const newStatus = getContextStatus(result.messages, model);
78
+ const newStatus = getContextStatus(result.messages, model, catalogWindow);
78
79
  // Reactively shrink the context bar to the compacted size — the backend
79
80
  // won't report this until the next turn, and the bar would otherwise stay
80
81
  // stuck at the pre-compaction (larger) value.
@@ -894,7 +894,10 @@ Usage: /config context-progress-bar <enabled|accent|warning|critical|track|reset
894
894
  return;
895
895
  }
896
896
  case 'effort': {
897
- const EFFORT_LEVELS = CODEX_REASONING_EFFORTS;
897
+ const catalog = ctx.config.getRoutedModelCatalog?.();
898
+ const EFFORT_LEVELS = catalog?.version === 1
899
+ ? catalog.models.find(row => row.owned_by === ctx.config.getProvider() && row.id === getEffectiveModel(ctx))?.capabilities?.reasoning?.choices ?? []
900
+ : CODEX_REASONING_EFFORTS;
898
901
  const CLEAR_WORDS = ['clear', 'default', 'unset', 'reset'];
899
902
  const config = ctx.config.get();
900
903
  const activeEffort = () => `Active (${config.provider} · ${config.model}): ${formatReasoningEffort(resolveCodexEffort(ctx.config.get(), config.provider, config.model))}`;
@@ -55,7 +55,12 @@ export const contextCommand = {
55
55
  const estimated = realTokens <= 0;
56
56
  const usedTokens = estimated ? estimateConversationTokens(messages) : realTokens;
57
57
  // Use the provider-reported window the bar used (Codex) so /context agrees.
58
- const status = contextStatusFromUsed(usedTokens, model, estimated ? undefined : ctx.getContextWindow?.());
58
+ const catalogWindow = ctx.config.getModelContextWindow?.(model);
59
+ const status = contextStatusFromUsed(usedTokens, model, catalogWindow !== undefined ? catalogWindow : estimated ? undefined : ctx.getContextWindow?.());
60
+ if (status.total === 0) {
61
+ ctx.addMessage({ role: 'system', content: `Context: ${formatTokens(usedTokens)} tokens${estimated ? ' (estimated)' : ''}. Context limit is unknown for this Router model.` });
62
+ return;
63
+ }
59
64
  const total = status.total;
60
65
  const usedPct = status.percentage;
61
66
  const freeTokens = Math.max(0, total - usedTokens);
@@ -1,15 +1,23 @@
1
1
  import { CODEX_REASONING_EFFORTS } from '../../config/types.js';
2
2
  import { resolveCodexEffort } from '../../config/defaults.js';
3
3
  import { getSupportedCodexReasoningEfforts } from '../../utils/models.js';
4
- import { formatReasoningEffort, parseReasoningSelection } from '../../utils/reasoning.js';
4
+ import { formatReasoningEffort, parseReasoningSelection, reasoningCommandChoices } from '../../utils/reasoning.js';
5
+ import { resolveRouterModel } from '../../utils/router_catalog.js';
5
6
  async function configureReasoning(kind, args, ctx) {
6
7
  const config = ctx.config.get();
7
8
  const provider = ctx.config.getProvider?.() || config.provider || 'anthropic';
8
9
  const model = ctx.config.getModel?.() || config.model;
9
- const current = resolveCodexEffort(config, provider, model);
10
- const supported = getSupportedCodexReasoningEfforts(model, provider);
10
+ const surface = ctx.config.getReasoningWireSurface?.(provider, model)
11
+ ?? (config.authMode === 'zrouter' || provider === 'openai_compatible' ? 'chat_tools' : 'native_api');
12
+ const catalog = ctx.config.getRoutedModelCatalog?.();
13
+ const capabilities = catalog?.version === 1 ? resolveRouterModel(catalog, provider, model)?.capabilities : undefined;
14
+ const current = resolveCodexEffort(config, provider, model, capabilities ?? surface);
15
+ const supported = catalog?.version === 1 ? capabilities?.reasoning?.choices ?? [] : getSupportedCodexReasoningEfforts(model, provider, surface);
16
+ const contract = ctx.config.getReasoningContract?.(provider, model)
17
+ ?? (catalog?.version === 1 ? capabilities?.reasoning ?? [] : surface);
18
+ const thinkingChoices = reasoningCommandChoices('thinking', provider, model, contract);
11
19
  const status = () => {
12
- const effective = resolveCodexEffort(ctx.config.get(), provider, model);
20
+ const effective = resolveCodexEffort(ctx.config.get(), provider, model, capabilities ?? surface);
13
21
  // A startup flag is this process's value, never the saved default, so say
14
22
  // where an effort that came from one is coming from.
15
23
  const processEffort = ctx.config.getCodexEffortOverride?.();
@@ -20,7 +28,7 @@ async function configureReasoning(kind, args, ctx) {
20
28
  ctx.addMessage({ role: 'system', content: status() +
21
29
  (supported?.length === 0 ? '\nThis model does not support a reasoning-effort setting.' :
22
30
  `\nSupported: ${(supported ?? CODEX_REASONING_EFFORTS).join(', ')}. /effort default inherits provider/global defaults.`) +
23
- (supported?.includes('none') ? '\n/thinking off disables reasoning; /thinking on enables it.' : '') });
31
+ (thinkingChoices.length ? `\nThinking settings: ${thinkingChoices.join(', ')}.` : '') });
24
32
  return;
25
33
  }
26
34
  if (args.length !== 1) {
@@ -29,7 +37,7 @@ async function configureReasoning(kind, args, ctx) {
29
37
  }
30
38
  let level;
31
39
  try {
32
- level = parseReasoningSelection(provider, model, current, { [kind]: args[0] });
40
+ level = parseReasoningSelection(provider, model, current, { [kind]: args[0] }, contract);
33
41
  }
34
42
  catch (error) {
35
43
  ctx.addMessage({ role: 'system', content: error.message });
@@ -6,10 +6,20 @@ const MODEL_SUBCOMMANDS = ['list', 'latest', 'set'];
6
6
  export const modelCommand = {
7
7
  name: 'model',
8
8
  description: 'Select provider and model, or set a model on the current provider',
9
- usage: '<provider> [model] | zergrouter <provider> [model] | <model-id> | list [provider] | latest [provider] | set <model-id>',
9
+ usage: '<provider> [model] | zergrouter <provider> [model] | <model-id> | list [provider] | latest [provider] | set <model-id> | refresh',
10
10
  agentRunnable: true,
11
11
  category: 'config',
12
- handler: (args, ctx) => {
12
+ handler: async (args, ctx) => {
13
+ if (args[0] === 'refresh' && ctx.models.catalog) {
14
+ try {
15
+ const catalog = await ctx.models.catalog(undefined, { refresh: true });
16
+ ctx.addMessage({ role: 'system', content: `${catalog.authRequired ? 'Router authentication rejected. Run ztc auth login again. Showing cached catalog' : catalog.cached ? 'Router unavailable. Showing cached catalog' : 'Router catalog refreshed'}: ${catalog.models.length} models. Active responses continue with their current selection.` });
17
+ }
18
+ catch (error) {
19
+ ctx.addMessage({ role: 'system', content: `Could not refresh Router models: ${error.message}` });
20
+ }
21
+ return;
22
+ }
13
23
  if (args.length > 0 && MODEL_SUBCOMMANDS.includes(args[0].toLowerCase())) {
14
24
  return modelsCommand.handler(args, ctx);
15
25
  }
@@ -6,12 +6,13 @@ const USAGE = [
6
6
  ' /profile <name> switch this session to an existing profile',
7
7
  ' /profile use <name> same as above',
8
8
  ' /profile create <name> create and switch to a custom profile',
9
+ ' /profile duplicate [name] copy the active profile and switch to the copy',
9
10
  ].join('\n');
10
11
  const OLD_BENCHMARK_COMMANDS = new Set(['show', 'gates', 'command', 'run', 'optimize', 'sweep']);
11
12
  export const profileCommand = {
12
13
  name: 'profile',
13
- description: 'List and switch configuration profiles',
14
- usage: '[list|<name>|use <name>|create <name>]',
14
+ description: 'List, switch, create, and duplicate configuration profiles',
15
+ usage: '[list|<name>|use <name>|create <name>|duplicate [name]]',
15
16
  handler: async (args, ctx) => {
16
17
  const [action, ...rest] = args;
17
18
  if (!action || action === 'list' || action === 'configs' || action === 'help') {
@@ -31,6 +32,24 @@ export const profileCommand = {
31
32
  ctx.addMessage({ role: 'system', content: `Config profiles (* active):\n${lines.join('\n')}${!action ? `\n\n${USAGE}` : ''}` });
32
33
  return;
33
34
  }
35
+ if (action === 'duplicate' || action === 'copy' || action === 'clone') {
36
+ if (rest.length > 1) {
37
+ ctx.addMessage({ role: 'system', content: USAGE });
38
+ return;
39
+ }
40
+ const source = activeProfile();
41
+ const target = rest[0] ?? `${source}-copy`;
42
+ try {
43
+ if (!ctx.duplicateProfile)
44
+ throw new Error('This renderer cannot switch config profiles in place.');
45
+ const name = await ctx.duplicateProfile(target, source);
46
+ ctx.addMessage({ role: 'system', content: `Duplicated config profile ${source} to ${name} and switched to it.` });
47
+ }
48
+ catch (error) {
49
+ ctx.addMessage({ role: 'system', content: `Profile duplicate failed: ${error.message}` });
50
+ }
51
+ return;
52
+ }
34
53
  if (OLD_BENCHMARK_COMMANDS.has(action)) {
35
54
  ctx.addMessage({ role: 'system', content: 'Use /emulation to inspect prompt modes. Profile benchmark gates remain available through npm run profiles:baseline.' });
36
55
  return;
@@ -4,6 +4,7 @@ import { applyRuntimeModelSelection, getEffectiveModel } from './runtime_model.j
4
4
  import { canRouteThroughRouter, canonicalProvider } from './provider_choices.js';
5
5
  import { resolveModelAndProvider } from '../../panes/model_catalog.js';
6
6
  import { getDefaultModel } from '../../utils/models.js';
7
+ import { resolveRouterModel } from '../../utils/router_catalog.js';
7
8
  /** Apply a provider and optional model in one validated config transition. */
8
9
  export async function selectProviderModel(args, ctx) {
9
10
  const values = args.filter(Boolean);
@@ -27,11 +28,23 @@ export async function selectProviderModel(args, ctx) {
27
28
  const authMode = routed ? 'zrouter' : 'direct';
28
29
  const defaultModel = provider === 'inception' ? getDefaultModel(provider) : getDefaultModelForProvider(provider);
29
30
  const explicitModel = values[choice === 'zergrouter' ? 2 : 1];
30
- const model = explicitModel || (provider === current ? getEffectiveModel(ctx)
31
+ let model = explicitModel || (provider === current ? getEffectiveModel(ctx)
31
32
  : provider === 'openai_compatible'
32
33
  ? (ctx.config.getOpenAICompatibleSettings?.().defaultModel || getDefaultModelForProvider(provider))
33
34
  : resolveModelAndProvider(defaultModel, provider).model);
34
35
  try {
36
+ if (routed && ctx.models.catalog) {
37
+ const catalog = await ctx.models.catalog().catch(() => undefined);
38
+ if (catalog?.version === 1) {
39
+ const models = catalog.models.filter(row => row.owned_by === provider && row.capabilities?.chat);
40
+ if (!explicitModel)
41
+ model = models.find(row => row.is_default)?.id ?? models[0]?.id ?? '';
42
+ const selected = resolveRouterModel(catalog, provider, model);
43
+ if (!selected?.capabilities?.chat)
44
+ throw new Error('Model is not in the Router catalog. Refresh /model.');
45
+ model = selected.id;
46
+ }
47
+ }
35
48
  const llm = ctx.config.resolveLLMBackendConfig({ provider, model,
36
49
  config: { ...ctx.config.get(), provider, model, authMode } });
37
50
  if (llm.authMode !== authMode) {
@@ -1,3 +1,6 @@
1
+ import { parseReasoningSelection } from '../../utils/reasoning.js';
2
+ import { resolveRouterModel } from '../../utils/router_catalog.js';
3
+ import { getSupportedCodexReasoningEfforts } from '../../utils/models.js';
1
4
  import { resolveExecutionSettings } from '../../config/execution.js';
2
5
  import { accountsCommand } from './accounts.js';
3
6
  import { authCommand } from './auth.js';
@@ -132,6 +135,8 @@ export async function buildSettingsCategories(ctx) {
132
135
  const routerSignedIn = routerCredentials !== null && Date.parse(routerCredentials.expiresAt) > Date.now();
133
136
  const provider = ctx.config.getProvider();
134
137
  const model = ctx.config.getModel?.() || config.model;
138
+ const reasoningEfforts = getSupportedCodexReasoningEfforts(model, provider, ctx.config.getReasoningWireSurface?.(provider, model)
139
+ ?? (config.authMode === 'zrouter' || provider === 'openai_compatible' ? 'chat_tools' : 'native_api'));
135
140
  const claudeCliInstalled = isClaudeCliAvailable();
136
141
  const claudeCodeTokenPresent = Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim());
137
142
  const claudeCodeReady = hasUsableClaudeCodeCliAuth();
@@ -168,6 +173,15 @@ export async function buildSettingsCategories(ctx) {
168
173
  modelOptions = models.slice(0, 20).map(m => ({ value: m, label: m }));
169
174
  }
170
175
  catch { /* fallback: no pre-fetched options, user can type manually */ }
176
+ const routerCatalog = ctx.config.getRoutedModelCatalog?.();
177
+ const routerDescriptor = routerCatalog?.version === 1
178
+ ? resolveRouterModel(routerCatalog, provider, model) : undefined;
179
+ if (routerCatalog?.version === 1)
180
+ modelOptions = routerCatalog.models.filter(row => row.owned_by === provider && row.capabilities?.chat)
181
+ .map(row => ({ value: row.id, label: `${row.display_name || row.id}${row.capabilities?.tools === false ? ' (chat only)' : ''}${row.available === false ? ' (access unavailable)' : ''}` }));
182
+ const reasoningChoices = routerCatalog?.version === 1 ? routerDescriptor?.capabilities?.reasoning?.choices ?? []
183
+ : reasoningEfforts ?? CODEX_REASONING_EFFORTS;
184
+ const effortSupported = config.codexEffort === undefined || reasoningChoices.includes(config.codexEffort);
171
185
  const sections = [
172
186
  {
173
187
  name: 'Config Scope',
@@ -242,7 +256,8 @@ export async function buildSettingsCategories(ctx) {
242
256
  // option matches a canonical PROVIDERS value instead of nothing.
243
257
  value: normalizeProviderId(provider),
244
258
  type: 'select',
245
- options: PROVIDERS,
259
+ options: routerCatalog?.version === 1 ? [...new Map(routerCatalog.models.filter(row => row.capabilities?.chat)
260
+ .map(row => [row.owned_by, { value: row.owned_by, label: row.provider_display_name || row.owned_by }])).values()] : PROVIDERS,
246
261
  },
247
262
  {
248
263
  name: 'model',
@@ -258,13 +273,15 @@ export async function buildSettingsCategories(ctx) {
258
273
  value: config.maxTokens === undefined ? 'provider default' : String(config.maxTokens),
259
274
  type: 'text',
260
275
  placeholder: 'provider default',
261
- description: 'Maximum output tokens, including reasoning tokens. Enter a positive whole number, or "provider default" to clear the limit.',
276
+ description: `Maximum output tokens, including reasoning tokens. ${routerDescriptor?.capabilities?.max_output_tokens ? `Router limit: ${routerDescriptor.capabilities.max_output_tokens.toLocaleString()}. ` : ''}Enter a positive whole number, or "provider default" to clear the limit.`,
262
277
  },
263
278
  {
264
279
  name: 'codexEffort', label: 'Default reasoning effort',
265
- value: config.codexEffort ?? 'provider default', type: 'select',
266
- options: [{ value: 'provider default', label: 'provider default' }, ...CODEX_REASONING_EFFORTS.map(level => ({ value: level, label: level }))],
267
- description: 'Global default. Model and provider overrides take precedence.',
280
+ value: effortSupported ? config.codexEffort ?? 'provider default' : 'provider default', type: 'select',
281
+ options: [{ value: 'provider default', label: 'provider default' }, ...reasoningChoices.map(level => ({ value: level, label: level }))],
282
+ description: reasoningChoices.length === 0
283
+ ? `This model takes no reasoning-effort setting (${provider} · ${model}).`
284
+ : `Levels supported by ${provider} · ${model}. Model and provider overrides take precedence.`,
268
285
  },
269
286
  {
270
287
  name: 'codexFastMode', label: 'Fast mode', value: String(config.codexFastMode === true), type: 'select',
@@ -1058,6 +1075,18 @@ function applySettingChange(ctx, setting, value) {
1058
1075
  }
1059
1076
  switch (setting) {
1060
1077
  case 'provider':
1078
+ {
1079
+ const catalog = ctx.config.getRoutedModelCatalog?.();
1080
+ if (catalog?.version === 1) {
1081
+ const rows = catalog.models.filter(row => row.owned_by === value && row.capabilities?.chat);
1082
+ const selected = rows.find(row => row.is_default) ?? rows[0];
1083
+ if (!selected)
1084
+ throw new Error('Provider is not in the Router chat catalog.');
1085
+ ctx.config.setProvider(value);
1086
+ ctx.config.set('model', selected.id);
1087
+ break;
1088
+ }
1089
+ }
1061
1090
  ctx.config.setProvider(value);
1062
1091
  {
1063
1092
  const resetModel = resolveModelForProviderChange(value, getEffectiveModel(ctx));
@@ -1082,10 +1111,19 @@ function applySettingChange(ctx, setting, value) {
1082
1111
  break;
1083
1112
  }
1084
1113
  case 'codexEffort':
1085
- if (value !== 'provider default' && !CODEX_REASONING_EFFORTS.includes(value)) {
1114
+ const catalog = ctx.config.getRoutedModelCatalog?.();
1115
+ const choices = catalog?.version === 1 ? resolveRouterModel(catalog, ctx.config.getProvider(), getEffectiveModel(ctx))?.capabilities?.reasoning?.choices ?? [] : CODEX_REASONING_EFFORTS;
1116
+ if (value !== 'provider default' && !choices.includes(value)) {
1086
1117
  throw new Error(`Unknown reasoning effort: ${value}`);
1087
1118
  }
1088
- ctx.config.set('codexEffort', value === 'provider default' ? undefined : value);
1119
+ const activeConfig = ctx.config.get();
1120
+ const activeProvider = ctx.config.getProvider();
1121
+ const activeModel = ctx.config.getModel?.() || activeConfig.model;
1122
+ const selectedEffort = parseReasoningSelection(activeProvider, activeModel, undefined, { effort: value === 'provider default' ? 'default' : value }, catalog?.version === 1
1123
+ ? resolveRouterModel(catalog, activeProvider, activeModel)?.capabilities?.reasoning?.choices ?? []
1124
+ : ctx.config.getReasoningWireSurface?.(activeProvider, activeModel)
1125
+ ?? (activeConfig.authMode === 'zrouter' || activeProvider === 'openai_compatible' ? 'chat_tools' : 'native_api'));
1126
+ ctx.config.set('codexEffort', selectedEffort ?? undefined);
1089
1127
  break;
1090
1128
  case 'codexFastMode':
1091
1129
  ctx.config.set('codexFastMode', value === 'true');