@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.
- package/README.md +8 -1
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/dist/App.js +2 -2
- package/dist/agent/agent.js +41 -5
- package/dist/agent/backends/anthropic.js +5 -10
- package/dist/agent/backends/gemini.js +2 -0
- package/dist/agent/backends/index.d.ts +1 -0
- package/dist/agent/backends/index.js +10 -1
- package/dist/agent/backends/openai.d.ts +0 -6
- package/dist/agent/backends/openai.js +5 -2
- package/dist/agent/backends/openai_compatible.d.ts +2 -0
- package/dist/agent/backends/openai_compatible.js +18 -8
- package/dist/agent/backends/provider_ids.d.ts +1 -0
- package/dist/agent/backends/provider_ids.js +4 -0
- package/dist/agent/backends/types.d.ts +1 -0
- package/dist/agent/backends/zrouter_stream.d.ts +5 -1
- package/dist/agent/backends/zrouter_stream.js +141 -37
- package/dist/agent/commands/compact.js +6 -5
- package/dist/agent/commands/config.js +4 -1
- package/dist/agent/commands/context.js +6 -1
- package/dist/agent/commands/effort.js +14 -6
- package/dist/agent/commands/model.js +12 -2
- package/dist/agent/commands/profile.js +21 -2
- package/dist/agent/commands/provider_selection.js +14 -1
- package/dist/agent/commands/settings.js +45 -7
- package/dist/agent/commands/types.d.ts +19 -0
- package/dist/agent/factory.js +14 -68
- package/dist/agent/subagent.d.ts +0 -10
- package/dist/agent/subagent.js +22 -0
- package/dist/agent/tool_response_store.d.ts +1 -1
- package/dist/agent/tool_response_store.js +4 -2
- package/dist/agent/tools/pane.d.ts +0 -7
- package/dist/agent/tools/pane.js +5 -4
- package/dist/app/command_context.js +33 -10
- package/dist/app/model_catalog_cache.d.ts +5 -0
- package/dist/app/model_catalog_cache.js +138 -0
- package/dist/app/restart.d.ts +1 -1
- package/dist/app/restart.js +16 -5
- package/dist/app/selection_completion.d.ts +17 -2
- package/dist/app/selection_completion.js +74 -6
- package/dist/app/session_manager.js +13 -8
- package/dist/cli_args.js +2 -16
- package/dist/cli_main.js +25 -6
- package/dist/cli_ratatui.js +70 -33
- package/dist/config/defaults.d.ts +2 -1
- package/dist/config/defaults.js +9 -2
- package/dist/config/resolved.d.ts +2 -2
- package/dist/config.d.ts +35 -3
- package/dist/config.js +227 -29
- package/dist/headless.js +2 -0
- package/dist/onboarding/first_run.js +5 -1
- package/dist/panes/model_catalog.d.ts +3 -3
- package/dist/panes/model_catalog.js +28 -4
- package/dist/panes/pane_registry.js +6 -2
- package/dist/panes/types.d.ts +2 -0
- package/dist/panes/ztc_launch.js +12 -0
- package/dist/session.d.ts +6 -2
- package/dist/session.js +65 -10
- package/dist/ui/views/completion_menu.js +10 -5
- package/dist/ui/views/status_bar.js +9 -5
- package/dist/ui/widgets/resume_panel.d.ts +2 -0
- package/dist/ui/widgets/resume_panel.js +17 -15
- package/dist/utils/compaction.d.ts +1 -1
- package/dist/utils/compaction.js +2 -2
- package/dist/utils/context.d.ts +4 -4
- package/dist/utils/context.js +14 -12
- package/dist/utils/deepseek.d.ts +2 -2
- package/dist/utils/deepseek.js +13 -5
- package/dist/utils/model_cache.d.ts +15 -0
- package/dist/utils/model_cache.js +143 -0
- package/dist/utils/models.d.ts +4 -2
- package/dist/utils/models.js +12 -16
- package/dist/utils/reasoning.d.ts +8 -1
- package/dist/utils/reasoning.js +31 -11
- package/dist/utils/router_catalog.d.ts +47 -0
- package/dist/utils/router_catalog.js +222 -0
- package/dist/zlm-embed.mjs +1180 -514
- package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
- package/node_modules/@zerg/model-contracts/README.md +78 -0
- package/node_modules/@zerg/model-contracts/index.d.ts +18 -0
- package/node_modules/@zerg/model-contracts/index.js +220 -0
- package/node_modules/@zerg/model-contracts/package.json +8 -0
- package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +9 -6
- package/packages/model-contracts/LICENSE.md +375 -0
- package/packages/model-contracts/README.md +78 -0
- package/packages/model-contracts/index.d.ts +18 -0
- package/packages/model-contracts/index.js +220 -0
- package/packages/model-contracts/package.json +8 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent model-catalog cache.
|
|
3
|
+
*
|
|
4
|
+
* Discovery needs the network, but the picker should not go empty when the
|
|
5
|
+
* network is down. This stores the last successful listing per provider so a
|
|
6
|
+
* restart — or an offline session — can still show what the provider offered
|
|
7
|
+
* last time.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately boring: a single JSON file in the profile config dir, written
|
|
10
|
+
* atomically, capped at a fixed entry count. It is a cache, so every read path
|
|
11
|
+
* tolerates a missing, truncated, or corrupt file by returning nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { profileConfigDir } from '../config/profiles.js';
|
|
17
|
+
const CACHE_VERSION = 1;
|
|
18
|
+
/** Bounded so a wide catalog (Modal, self-hosted) cannot grow the file without limit. */
|
|
19
|
+
const MAX_MODELS_PER_PROVIDER = 512;
|
|
20
|
+
const MAX_PROVIDERS = 64;
|
|
21
|
+
function cacheFilePath() {
|
|
22
|
+
return join(profileConfigDir(), 'model-catalog-cache.json');
|
|
23
|
+
}
|
|
24
|
+
function sanitizeModels(models) {
|
|
25
|
+
const cleaned = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const value of models) {
|
|
28
|
+
if (typeof value !== 'string')
|
|
29
|
+
continue;
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
// Model ids never legitimately carry control characters; drop anything that
|
|
32
|
+
// would corrupt a rendered row if a remote body were ever echoed here.
|
|
33
|
+
if (!trimmed || trimmed.length > 256 || /[\r\n\0]/.test(trimmed))
|
|
34
|
+
continue;
|
|
35
|
+
if (seen.has(trimmed))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(trimmed);
|
|
38
|
+
cleaned.push(trimmed);
|
|
39
|
+
if (cleaned.length >= MAX_MODELS_PER_PROVIDER)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return cleaned;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The cached model list for a provider, or an empty array when absent.
|
|
46
|
+
* Empty means "nothing usable cached", never an error to surface.
|
|
47
|
+
*/
|
|
48
|
+
export async function readCachedModels(provider) {
|
|
49
|
+
const cache = await readModelCatalogCache();
|
|
50
|
+
return cache.providers[provider]?.models ?? [];
|
|
51
|
+
}
|
|
52
|
+
/** Epoch ms of the cached listing, or undefined when there is none. */
|
|
53
|
+
export async function readCachedModelsFetchedAt(provider) {
|
|
54
|
+
const cache = await readModelCatalogCache();
|
|
55
|
+
return cache.providers[provider]?.fetchedAt;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Record a successful listing for a provider. A no-op for an empty list: a
|
|
59
|
+
* provider that legitimately returns zero models should not erase a good
|
|
60
|
+
* earlier cache, which is the case an offline restart needs most.
|
|
61
|
+
*/
|
|
62
|
+
export async function writeCachedModels(provider, models) {
|
|
63
|
+
const sanitized = sanitizeModels(models);
|
|
64
|
+
if (sanitized.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const file = cacheFilePath();
|
|
67
|
+
await updateCache(file, current => {
|
|
68
|
+
const providers = { ...current.providers, [provider]: { models: sanitized, fetchedAt: Date.now() } };
|
|
69
|
+
const names = Object.keys(providers);
|
|
70
|
+
for (const name of names.sort((a, b) => providers[a].fetchedAt - providers[b].fetchedAt)
|
|
71
|
+
.slice(0, Math.max(0, names.length - MAX_PROVIDERS)))
|
|
72
|
+
delete providers[name];
|
|
73
|
+
return { version: CACHE_VERSION, providers };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Drop one provider's cache without discarding concurrent catalog updates. */
|
|
77
|
+
export async function clearCachedModels(provider) {
|
|
78
|
+
await updateCache(cacheFilePath(), current => {
|
|
79
|
+
if (!current.providers[provider])
|
|
80
|
+
return undefined;
|
|
81
|
+
delete current.providers[provider];
|
|
82
|
+
return current;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const pendingWrites = new Map();
|
|
86
|
+
/** One in-process writer per profile owns read/modify/rename and its cleanup. */
|
|
87
|
+
async function updateCache(file, update) {
|
|
88
|
+
const work = (pendingWrites.get(file) ?? Promise.resolve()).then(async () => {
|
|
89
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
90
|
+
try {
|
|
91
|
+
const payload = update(await readModelCatalogCache(file));
|
|
92
|
+
if (payload === undefined)
|
|
93
|
+
return;
|
|
94
|
+
await mkdir(dirname(file), { recursive: true });
|
|
95
|
+
await writeFile(temp, JSON.stringify(payload, null, 2), { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
96
|
+
await rename(temp, file);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Catalog persistence is optional; discovery must survive unwritable profiles.
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await unlink(temp).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
pendingWrites.set(file, work);
|
|
106
|
+
try {
|
|
107
|
+
await work;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (pendingWrites.get(file) === work)
|
|
111
|
+
pendingWrites.delete(file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function readModelCatalogCache(file = cacheFilePath()) {
|
|
115
|
+
const empty = { version: CACHE_VERSION, providers: {} };
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = await readFile(file, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return empty;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(raw);
|
|
125
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.providers !== 'object' || parsed.providers === null) {
|
|
126
|
+
return empty;
|
|
127
|
+
}
|
|
128
|
+
const providers = Object.create(null);
|
|
129
|
+
for (const [name, entry] of Object.entries(parsed.providers)) {
|
|
130
|
+
if (!entry || typeof entry !== 'object')
|
|
131
|
+
continue;
|
|
132
|
+
const models = sanitizeModels(Array.isArray(entry.models) ? entry.models : []);
|
|
133
|
+
if (models.length === 0)
|
|
134
|
+
continue;
|
|
135
|
+
providers[name] = { models, fetchedAt: Number.isFinite(entry.fetchedAt) ? entry.fetchedAt : 0 };
|
|
136
|
+
}
|
|
137
|
+
return { version: CACHE_VERSION, providers };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Corrupt or truncated file: treat as absent rather than throwing.
|
|
141
|
+
return empty;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/utils/models.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReasoningWireSurface } from '@zerg/model-contracts';
|
|
1
2
|
import type { CodexReasoningEffort } from '../config/types.js';
|
|
2
3
|
export declare const OFFICIAL_CODEX_MODEL_IDS: readonly ["gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"];
|
|
3
4
|
export interface OfficialCodexModelInfo {
|
|
@@ -66,9 +67,9 @@ export declare const CLAUDE_REASONING_EFFORTS: readonly CodexReasoningEffort[];
|
|
|
66
67
|
* on a Haiku pane must be rejected, not persisted and then 400'd every turn.
|
|
67
68
|
*/
|
|
68
69
|
export declare function getSupportedClaudeReasoningEfforts(model: string | undefined): readonly CodexReasoningEffort[] | undefined;
|
|
69
|
-
export declare function getSupportedCodexReasoningEfforts(model: string | undefined, provider?: string): readonly CodexReasoningEffort[] | undefined;
|
|
70
|
+
export declare function getSupportedCodexReasoningEfforts(model: string | undefined, provider?: string, surface?: ReasoningWireSurface): readonly CodexReasoningEffort[] | undefined;
|
|
70
71
|
/** Unknown/dynamic models stay permissive; known models must honor their advertised contract. */
|
|
71
|
-
export declare function isCodexReasoningEffortSupported(provider: string | undefined, model: string | undefined, effort: CodexReasoningEffort): boolean;
|
|
72
|
+
export declare function isCodexReasoningEffortSupported(provider: string | undefined, model: string | undefined, effort: CodexReasoningEffort, surface?: ReasoningWireSurface): boolean;
|
|
72
73
|
/**
|
|
73
74
|
* Message shown when a model rejects a chosen reasoning effort. Handles the
|
|
74
75
|
* empty-supported-list case (e.g. Kimi K2.6, whose depth is a `thinking` object
|
|
@@ -79,6 +80,7 @@ export declare function unsupportedEffortMessage(model: string | undefined, leve
|
|
|
79
80
|
export declare const CODEX_MODELS_CLIENT_VERSION = "0.144.0";
|
|
80
81
|
export declare function getDefaultModel(provider: string, tier?: 'full' | 'cheap'): string;
|
|
81
82
|
interface ListModelsOptions {
|
|
83
|
+
workspaceId?: string;
|
|
82
84
|
signal?: AbortSignal;
|
|
83
85
|
provider: string;
|
|
84
86
|
apiKey?: string;
|
package/dist/utils/models.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CONTRACT_PROVIDERS, modelContract, normalizeContractProvider } from '@zerg/model-contracts';
|
|
2
|
+
import { fetchRouterCatalog, routerModelIds } from './router_catalog.js';
|
|
1
3
|
/**
|
|
2
4
|
* Get the default model for a provider by tier.
|
|
3
5
|
* Single source of truth — replaces duplicated switch statements in sim/llm.ts,
|
|
@@ -202,7 +204,10 @@ export function getSupportedClaudeReasoningEfforts(model) {
|
|
|
202
204
|
// Unrecognized Claude id (e.g. a future family): stay permissive.
|
|
203
205
|
return undefined;
|
|
204
206
|
}
|
|
205
|
-
export function getSupportedCodexReasoningEfforts(model, provider) {
|
|
207
|
+
export function getSupportedCodexReasoningEfforts(model, provider, surface = provider === 'openai_compatible' ? 'chat_tools' : 'native_api') {
|
|
208
|
+
if (provider && model && CONTRACT_PROVIDERS.includes(normalizeContractProvider(provider))) {
|
|
209
|
+
return modelContract(provider, model, surface)?.reasoningEfforts ?? [];
|
|
210
|
+
}
|
|
206
211
|
if (isModalDeepSeekFlashModel(model))
|
|
207
212
|
return MODAL_DEEPSEEK_FLASH_EFFORTS;
|
|
208
213
|
if (isDeepSeekFlashModel(model))
|
|
@@ -235,8 +240,8 @@ export function getSupportedCodexReasoningEfforts(model, provider) {
|
|
|
235
240
|
return appServerEfforts;
|
|
236
241
|
}
|
|
237
242
|
/** Unknown/dynamic models stay permissive; known models must honor their advertised contract. */
|
|
238
|
-
export function isCodexReasoningEffortSupported(provider, model, effort) {
|
|
239
|
-
const supported = getSupportedCodexReasoningEfforts(model, provider);
|
|
243
|
+
export function isCodexReasoningEffortSupported(provider, model, effort, surface) {
|
|
244
|
+
const supported = getSupportedCodexReasoningEfforts(model, provider, surface);
|
|
240
245
|
return supported === undefined || supported.includes(effort);
|
|
241
246
|
}
|
|
242
247
|
/**
|
|
@@ -423,17 +428,7 @@ export async function listModels(options) {
|
|
|
423
428
|
}
|
|
424
429
|
// Token-authenticated zrouter model listing (container/default cloud path).
|
|
425
430
|
if (proxyUrl && authToken) {
|
|
426
|
-
|
|
427
|
-
const res = await fetch(url, {
|
|
428
|
-
signal,
|
|
429
|
-
headers: {
|
|
430
|
-
Authorization: `Bearer ${authToken}`,
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
if (!res.ok) {
|
|
434
|
-
throw new Error(`ZRouter error (${res.status})`);
|
|
435
|
-
}
|
|
436
|
-
return parseList(res, provider === 'moonshot' ? 'kimi' : provider);
|
|
431
|
+
return routerModelIds(await fetchRouterCatalog({ origin: proxyUrl, token: authToken, workspaceId: options.workspaceId }, signal, provider === 'zergrouter' ? undefined : provider === 'moonshot' ? 'kimi' : provider), provider === 'moonshot' ? 'kimi' : provider);
|
|
437
432
|
}
|
|
438
433
|
if (provider === 'claude_code' || provider === 'claude-code') {
|
|
439
434
|
const { ClaudeCodeAuthManager } = await import('../agent/backends/claude_code_auth.js');
|
|
@@ -470,8 +465,9 @@ export async function listModels(options) {
|
|
|
470
465
|
}
|
|
471
466
|
return parseList(res);
|
|
472
467
|
}
|
|
473
|
-
if (provider === 'openai' || provider === 'deepseek') {
|
|
474
|
-
const endpoint = provider === '
|
|
468
|
+
if (provider === 'openai' || provider === 'deepseek' || provider === 'fireworks') {
|
|
469
|
+
const endpoint = provider === 'fireworks' ? `${(options.baseUrl || 'https://api.fireworks.ai/inference/v1').replace(/\/$/, '')}/models`
|
|
470
|
+
: provider === 'deepseek' ? 'https://api.deepseek.com/v1/models' : 'https://api.openai.com/v1/models';
|
|
475
471
|
const res = await fetch(endpoint, {
|
|
476
472
|
signal,
|
|
477
473
|
headers: {
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import type { ReasoningWireSurface } from '@zerg/model-contracts';
|
|
1
2
|
import { type CodexReasoningEffort } from '../config/types.js';
|
|
3
|
+
export type ReasoningContract = ReasoningWireSurface | readonly string[] | {
|
|
4
|
+
choices: readonly string[];
|
|
5
|
+
default?: string;
|
|
6
|
+
};
|
|
7
|
+
/** Suggestions and command validation share the same model contract. */
|
|
8
|
+
export declare function reasoningCommandChoices(kind: 'effort' | 'thinking', provider: string, model: string, contract?: ReasoningContract): string[];
|
|
2
9
|
/** One policy for slash commands and startup flags. null clears the model override. */
|
|
3
10
|
export declare function parseReasoningSelection(provider: string, model: string, current: CodexReasoningEffort | undefined, selection: {
|
|
4
11
|
effort?: string;
|
|
5
12
|
thinking?: string;
|
|
6
|
-
}): CodexReasoningEffort | null;
|
|
13
|
+
}, contract?: ReasoningContract): CodexReasoningEffort | null;
|
|
7
14
|
/**
|
|
8
15
|
* How a resolved effort reads in command output.
|
|
9
16
|
*
|
package/dist/utils/reasoning.js
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
import { CODEX_REASONING_EFFORTS } from '../config/types.js';
|
|
2
2
|
import { getSupportedCodexReasoningEfforts, unsupportedEffortMessage } from './models.js';
|
|
3
|
+
function reasoningChoices(provider, model, contract) {
|
|
4
|
+
return contract === undefined || typeof contract === 'string'
|
|
5
|
+
? getSupportedCodexReasoningEfforts(model, provider, contract)
|
|
6
|
+
: 'choices' in contract ? contract.choices : contract;
|
|
7
|
+
}
|
|
8
|
+
/** Suggestions and command validation share the same model contract. */
|
|
9
|
+
export function reasoningCommandChoices(kind, provider, model, contract) {
|
|
10
|
+
const choices = reasoningChoices(provider, model, contract);
|
|
11
|
+
if (kind === 'effort')
|
|
12
|
+
return [...(choices ?? []), 'default'];
|
|
13
|
+
return [choices?.some(value => value.toLowerCase() !== 'none') ? 'on' : '', choices?.some(value => value.toLowerCase() === 'none') ? 'off' : ''].filter(Boolean);
|
|
14
|
+
}
|
|
3
15
|
/** One policy for slash commands and startup flags. null clears the model override. */
|
|
4
|
-
export function parseReasoningSelection(provider, model, current, selection) {
|
|
5
|
-
const supported =
|
|
6
|
-
const
|
|
16
|
+
export function parseReasoningSelection(provider, model, current, selection, contract) {
|
|
17
|
+
const supported = reasoningChoices(provider, model, contract);
|
|
18
|
+
const advertisedChoices = typeof contract === 'string' || contract === undefined ? undefined : supported;
|
|
19
|
+
const canonical = (value) => supported?.find(choice => choice.toLowerCase() === value.toLowerCase()) ?? value;
|
|
20
|
+
const effortInput = selection.effort?.toLowerCase();
|
|
21
|
+
const effort = effortInput === undefined ? undefined : canonical(effortInput);
|
|
7
22
|
const thinking = selection.thinking?.toLowerCase();
|
|
8
23
|
if (thinking !== undefined && thinking !== 'on' && thinking !== 'off') {
|
|
9
24
|
throw new Error('Invalid thinking setting. Choose on or off.');
|
|
10
25
|
}
|
|
11
|
-
if (
|
|
26
|
+
if (effortInput !== undefined && ['default', 'clear', 'reset', 'unset'].includes(effortInput)) {
|
|
12
27
|
if (thinking !== undefined)
|
|
13
28
|
throw new Error('Default effort conflicts with an explicit thinking setting.');
|
|
14
29
|
return null;
|
|
15
30
|
}
|
|
16
|
-
if (effort !== undefined && !CODEX_REASONING_EFFORTS.includes(effort)) {
|
|
31
|
+
if (effort !== undefined && !(advertisedChoices ?? CODEX_REASONING_EFFORTS).includes(effort)) {
|
|
17
32
|
throw new Error(`Invalid effort "${effort}". Choose: ${(supported ?? CODEX_REASONING_EFFORTS).join(', ')} (or default).`);
|
|
18
33
|
}
|
|
19
|
-
if (effort !== undefined && ((thinking === 'off' && effort !== 'none') || (thinking === 'on' && effort === 'none'))) {
|
|
34
|
+
if (effort !== undefined && ((thinking === 'off' && effort.toLowerCase() !== 'none') || (thinking === 'on' && effort.toLowerCase() === 'none'))) {
|
|
20
35
|
throw new Error(`Effort "${effort}" conflicts with thinking ${thinking}.`);
|
|
21
36
|
}
|
|
22
37
|
// A toggle requires a known model contract. Unknown endpoints can still
|
|
@@ -24,9 +39,14 @@ export function parseReasoningSelection(provider, model, current, selection) {
|
|
|
24
39
|
if (thinking !== undefined && supported === undefined) {
|
|
25
40
|
throw new Error(`Thinking controls are unknown for model "${model}". Use /effort only if your endpoint supports it.`);
|
|
26
41
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
const defaultLevel = contract && typeof contract !== 'string' && 'choices' in contract ? contract.default : undefined;
|
|
43
|
+
const enabled = supported?.filter(value => value.toLowerCase() !== 'none');
|
|
44
|
+
const currentLevel = current === undefined ? undefined : canonical(current);
|
|
45
|
+
const preferred = defaultLevel === undefined ? undefined : canonical(defaultLevel);
|
|
46
|
+
const level = effort ?? (thinking === 'off' ? canonical('none')
|
|
47
|
+
: currentLevel && currentLevel.toLowerCase() !== 'none' && (supported === undefined || supported.includes(currentLevel)) ? currentLevel
|
|
48
|
+
: preferred && enabled?.includes(preferred) ? preferred
|
|
49
|
+
: enabled?.find(value => value.toLowerCase() === 'high') ?? enabled?.[0] ?? 'high');
|
|
30
50
|
if (!level || (supported !== undefined && !supported.includes(level))) {
|
|
31
51
|
throw new Error(unsupportedEffortMessage(model, level ?? 'on', supported ?? []));
|
|
32
52
|
}
|
|
@@ -43,7 +63,7 @@ export function parseReasoningSelection(provider, model, current, selection) {
|
|
|
43
63
|
export function formatReasoningEffort(level) {
|
|
44
64
|
if (level === undefined)
|
|
45
65
|
return 'provider default';
|
|
46
|
-
return level === 'none' ? 'thinking off' : `${level} thinking`;
|
|
66
|
+
return level.toLowerCase() === 'none' ? 'thinking off' : `${level} thinking`;
|
|
47
67
|
}
|
|
48
68
|
/**
|
|
49
69
|
* The thinking switch on its own, for readouts that show it without the level.
|
|
@@ -52,5 +72,5 @@ export function formatReasoningEffort(level) {
|
|
|
52
72
|
* claim either state then.
|
|
53
73
|
*/
|
|
54
74
|
export function reasoningEnabled(level) {
|
|
55
|
-
return level === undefined ? undefined : level !== 'none';
|
|
75
|
+
return level === undefined ? undefined : level.toLowerCase() !== 'none';
|
|
56
76
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RouterModelCapabilities {
|
|
2
|
+
chat: boolean;
|
|
3
|
+
tools?: boolean;
|
|
4
|
+
images?: boolean;
|
|
5
|
+
reasoning?: {
|
|
6
|
+
choices: string[];
|
|
7
|
+
default?: string;
|
|
8
|
+
};
|
|
9
|
+
context_window?: number;
|
|
10
|
+
max_output_tokens?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RouterModelDescriptor {
|
|
13
|
+
id: string;
|
|
14
|
+
owned_by: string;
|
|
15
|
+
object?: 'model';
|
|
16
|
+
display_name?: string;
|
|
17
|
+
provider_display_name?: string;
|
|
18
|
+
aliases?: string[];
|
|
19
|
+
is_default?: boolean;
|
|
20
|
+
available?: boolean;
|
|
21
|
+
capabilities?: RouterModelCapabilities;
|
|
22
|
+
}
|
|
23
|
+
export interface RouterCatalog {
|
|
24
|
+
version: 0 | 1;
|
|
25
|
+
models: RouterModelDescriptor[];
|
|
26
|
+
cached: boolean;
|
|
27
|
+
fetchedAt: number;
|
|
28
|
+
authRequired?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface RouterCatalogConnection {
|
|
31
|
+
origin: string;
|
|
32
|
+
token: string;
|
|
33
|
+
workspaceId?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function resolveRouterModel(catalog: RouterCatalog, provider: string, model: string): RouterModelDescriptor | undefined;
|
|
36
|
+
/** Opaque identity shared by cache readers and account-scoped completion state. */
|
|
37
|
+
export declare function routerCatalogScope(connection: RouterCatalogConnection): string;
|
|
38
|
+
export declare function readRouterCatalog(connection: RouterCatalogConnection): RouterCatalog | undefined;
|
|
39
|
+
export declare class RouterCatalogAuthenticationError extends Error {
|
|
40
|
+
readonly status: number;
|
|
41
|
+
constructor(status: number);
|
|
42
|
+
}
|
|
43
|
+
/** Fresh HTTP discovery, shared by the catalog and provider-list cache owners. */
|
|
44
|
+
export declare function fetchRouterCatalog(connection: RouterCatalogConnection, signal?: AbortSignal, provider?: string): Promise<RouterCatalog>;
|
|
45
|
+
export declare function discoverRouterCatalog(connection: RouterCatalogConnection, signal?: AbortSignal): Promise<RouterCatalog>;
|
|
46
|
+
export declare function routerModelsForProvider(catalog: RouterCatalog, provider: string): RouterModelDescriptor[];
|
|
47
|
+
export declare function routerModelIds(catalog: RouterCatalog, provider: string): string[];
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { profileConfigDir } from '../config/profiles.js';
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function identifier(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
|
|
11
|
+
}
|
|
12
|
+
function identifiers(value, max) {
|
|
13
|
+
return Array.isArray(value) && value.length <= max && value.every(identifier);
|
|
14
|
+
}
|
|
15
|
+
function descriptor(value, legacy) {
|
|
16
|
+
if (!record(value))
|
|
17
|
+
return undefined;
|
|
18
|
+
const id = value.id ?? (legacy ? value.name : undefined);
|
|
19
|
+
if (!identifier(id) || !identifier(value.owned_by))
|
|
20
|
+
return undefined;
|
|
21
|
+
const result = { id, owned_by: value.owned_by };
|
|
22
|
+
if (value.object !== undefined && value.object !== 'model')
|
|
23
|
+
return undefined;
|
|
24
|
+
if (value.object === 'model')
|
|
25
|
+
result.object = 'model';
|
|
26
|
+
for (const key of ['display_name', 'provider_display_name']) {
|
|
27
|
+
if (value[key] !== undefined) {
|
|
28
|
+
if (!identifier(value[key]))
|
|
29
|
+
return undefined;
|
|
30
|
+
result[key] = value[key];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const key of ['is_default', 'available']) {
|
|
34
|
+
if (value[key] !== undefined) {
|
|
35
|
+
if (typeof value[key] !== 'boolean')
|
|
36
|
+
return undefined;
|
|
37
|
+
result[key] = value[key];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (value.aliases !== undefined) {
|
|
41
|
+
if (!identifiers(value.aliases, 32))
|
|
42
|
+
return undefined;
|
|
43
|
+
result.aliases = value.aliases;
|
|
44
|
+
}
|
|
45
|
+
if (value.capabilities !== undefined) {
|
|
46
|
+
const caps = value.capabilities;
|
|
47
|
+
if (!record(caps) || typeof caps.chat !== 'boolean')
|
|
48
|
+
return undefined;
|
|
49
|
+
const capabilities = { chat: caps.chat };
|
|
50
|
+
for (const key of ['tools', 'images']) {
|
|
51
|
+
if (caps[key] !== undefined) {
|
|
52
|
+
if (typeof caps[key] !== 'boolean')
|
|
53
|
+
return undefined;
|
|
54
|
+
capabilities[key] = caps[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const key of ['context_window', 'max_output_tokens']) {
|
|
58
|
+
if (caps[key] !== undefined) {
|
|
59
|
+
if (typeof caps[key] !== 'number' || !Number.isSafeInteger(caps[key]) || caps[key] <= 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
capabilities[key] = caps[key];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (caps.reasoning !== undefined) {
|
|
65
|
+
const reasoning = caps.reasoning;
|
|
66
|
+
if (!record(reasoning) || !identifiers(reasoning.choices, 32)
|
|
67
|
+
|| (reasoning.default !== undefined && !identifier(reasoning.default)))
|
|
68
|
+
return undefined;
|
|
69
|
+
capabilities.reasoning = { choices: reasoning.choices, ...(reasoning.default === undefined ? {} : { default: reasoning.default }) };
|
|
70
|
+
}
|
|
71
|
+
result.capabilities = capabilities;
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
// Resolve only exact IDs or advertised/legacy aliases within the selected provider.
|
|
76
|
+
// Opaque IDs win; duplicate aliases remain ambiguous instead of selecting another model.
|
|
77
|
+
export function resolveRouterModel(catalog, provider, model) {
|
|
78
|
+
const owner = provider === 'moonshot' ? 'kimi' : provider;
|
|
79
|
+
const rows = catalog.models.filter(row => row.owned_by === owner);
|
|
80
|
+
const exact = rows.find(row => row.id === model);
|
|
81
|
+
if (exact)
|
|
82
|
+
return exact;
|
|
83
|
+
const matches = rows.filter(row => row.id === `${owner}/${model}` || row.aliases?.includes(model));
|
|
84
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
85
|
+
}
|
|
86
|
+
// The token digest is a conservative account partition, including opaque tokens.
|
|
87
|
+
// Never persist a credential, or share a catalog between profiles or origins.
|
|
88
|
+
/** Opaque identity shared by cache readers and account-scoped completion state. */
|
|
89
|
+
export function routerCatalogScope(connection) {
|
|
90
|
+
const key = createHash('sha256').update(JSON.stringify([connection.origin.replace(/\/+$/, ''), connection.token, connection.workspaceId ?? ''])).digest('hex');
|
|
91
|
+
return join(profileConfigDir(), `router-models-${key}.json`);
|
|
92
|
+
}
|
|
93
|
+
function parseCatalog(value) {
|
|
94
|
+
if (!record(value))
|
|
95
|
+
throw new Error('Invalid Router catalog');
|
|
96
|
+
if (value.model_contract_version !== undefined && value.model_contract_version !== 1)
|
|
97
|
+
throw new Error('Unsupported Router model contract');
|
|
98
|
+
const data = value.data ?? value.models ?? value.items ?? [];
|
|
99
|
+
if (!Array.isArray(data) || data.length > 4096)
|
|
100
|
+
throw new Error('Invalid Router catalog');
|
|
101
|
+
const version = value.model_contract_version === 1 ? 1 : 0;
|
|
102
|
+
return { version, models: data.flatMap(value => { const row = descriptor(value, version === 0); return row ? [row] : []; }) };
|
|
103
|
+
}
|
|
104
|
+
const memo = new Map();
|
|
105
|
+
export function readRouterCatalog(connection) {
|
|
106
|
+
const file = routerCatalogScope(connection);
|
|
107
|
+
try {
|
|
108
|
+
const stat = statSync(file, { bigint: true });
|
|
109
|
+
const stamp = `${stat.mtimeNs}:${stat.size}:${stat.ino}`;
|
|
110
|
+
const previous = memo.get(file);
|
|
111
|
+
if (previous?.stamp === stamp)
|
|
112
|
+
return previous.catalog;
|
|
113
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
114
|
+
const parsed = parseCatalog(saved);
|
|
115
|
+
const catalog = { ...parsed, cached: true, fetchedAt: Number(saved.fetchedAt) || 0 };
|
|
116
|
+
if (memo.size >= 32)
|
|
117
|
+
memo.delete(memo.keys().next().value);
|
|
118
|
+
memo.set(file, { stamp, catalog });
|
|
119
|
+
return catalog;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
memo.delete(file);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export class RouterCatalogAuthenticationError extends Error {
|
|
127
|
+
status;
|
|
128
|
+
constructor(status) {
|
|
129
|
+
super(`ZRouter error (${status}): authentication rejected. Run ztc auth login again.`);
|
|
130
|
+
this.status = status;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Fresh HTTP discovery, shared by the catalog and provider-list cache owners. */
|
|
134
|
+
export async function fetchRouterCatalog(connection, signal, provider) {
|
|
135
|
+
const file = routerCatalogScope(connection);
|
|
136
|
+
const timeout = AbortSignal.timeout(8000);
|
|
137
|
+
const suffix = provider === undefined ? '' : `?provider=${encodeURIComponent(provider)}`;
|
|
138
|
+
const response = await fetch(`${connection.origin.replace(/\/+$/, '')}/v1/models${suffix}`, {
|
|
139
|
+
headers: { Authorization: `Bearer ${connection.token}`, ...(connection.workspaceId ? { 'X-Zerg-Workspace-ID': connection.workspaceId } : {}) },
|
|
140
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
141
|
+
});
|
|
142
|
+
if (response.status === 401 || response.status === 403) {
|
|
143
|
+
const cached = memo.get(file);
|
|
144
|
+
if (cached)
|
|
145
|
+
cached.catalog.authRequired = true;
|
|
146
|
+
throw new RouterCatalogAuthenticationError(response.status);
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok)
|
|
149
|
+
throw new Error(`ZRouter error (${response.status})`);
|
|
150
|
+
return { ...parseCatalog(await response.json()), cached: false, fetchedAt: Date.now() };
|
|
151
|
+
}
|
|
152
|
+
async function pruneCatalogFiles(file) {
|
|
153
|
+
const directory = dirname(file);
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const caches = [];
|
|
156
|
+
for (const name of await readdir(directory)) {
|
|
157
|
+
const cache = /^router-models-[a-f0-9]{64}\.json$/.test(name);
|
|
158
|
+
const temporary = /^router-models-[a-f0-9]{64}\.json\.[a-f0-9-]{36}\.tmp$/.test(name);
|
|
159
|
+
if (!cache && !temporary)
|
|
160
|
+
continue;
|
|
161
|
+
const path = join(directory, name);
|
|
162
|
+
try {
|
|
163
|
+
const info = await stat(path);
|
|
164
|
+
if (!info.isFile())
|
|
165
|
+
continue;
|
|
166
|
+
if (temporary && now - info.mtimeMs > 3_600_000)
|
|
167
|
+
await unlink(path);
|
|
168
|
+
if (cache)
|
|
169
|
+
caches.push({ file: path, modified: info.mtimeMs });
|
|
170
|
+
}
|
|
171
|
+
catch { /* Another process may have completed or pruned this entry. */ }
|
|
172
|
+
}
|
|
173
|
+
// Keep this publication even when another process writes during the scan.
|
|
174
|
+
caches.sort((a, b) => Number(b.file === file) - Number(a.file === file) || b.modified - a.modified || a.file.localeCompare(b.file));
|
|
175
|
+
for (const entry of caches.slice(32)) {
|
|
176
|
+
await unlink(entry.file).catch(() => { });
|
|
177
|
+
memo.delete(entry.file);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export async function discoverRouterCatalog(connection, signal) {
|
|
181
|
+
const file = routerCatalogScope(connection); // Capture the profile before awaiting HTTP.
|
|
182
|
+
const previous = readRouterCatalog(connection);
|
|
183
|
+
let result;
|
|
184
|
+
try {
|
|
185
|
+
result = await fetchRouterCatalog(connection, signal);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof RouterCatalogAuthenticationError) {
|
|
189
|
+
if (previous) {
|
|
190
|
+
previous.authRequired = true;
|
|
191
|
+
return previous;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!signal?.aborted && previous)
|
|
196
|
+
return previous;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
200
|
+
try {
|
|
201
|
+
await mkdir(dirname(file), { recursive: true });
|
|
202
|
+
await writeFile(temp, JSON.stringify({ model_contract_version: result.version || undefined, data: result.models, fetchedAt: result.fetchedAt }), { mode: 0o600, flag: 'wx' });
|
|
203
|
+
await rename(temp, file);
|
|
204
|
+
await pruneCatalogFiles(file);
|
|
205
|
+
}
|
|
206
|
+
catch { /* Discovery still works when optional persistence is unavailable. */ }
|
|
207
|
+
finally {
|
|
208
|
+
await unlink(temp).catch(() => { });
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
export function routerModelsForProvider(catalog, provider) {
|
|
213
|
+
return catalog.models.filter(model => model.owned_by === provider && (catalog.version === 0 || model.capabilities?.chat === true));
|
|
214
|
+
}
|
|
215
|
+
export function routerModelIds(catalog, provider) {
|
|
216
|
+
if (provider === 'zergrouter')
|
|
217
|
+
return [...new Set(catalog.models.filter(m => catalog.version === 0 || m.capabilities?.chat).map(m => m.owned_by))];
|
|
218
|
+
const ids = routerModelsForProvider(catalog, provider).map(model => model.id);
|
|
219
|
+
if (catalog.version === 1)
|
|
220
|
+
return ids;
|
|
221
|
+
return provider === 'modal' ? ids.map(id => id.replace(/^modal\//, '')).filter(id => !/\.modal\.(?:direct|run)(?:[/:?#]|$)/i.test(id)) : ids;
|
|
222
|
+
}
|