@provos/ironcurtain 0.12.0 → 0.13.0
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 +14 -12
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.d.ts +33 -1
- package/dist/config/config-command.js +608 -11
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/openrouter-catalog.d.ts +46 -0
- package/dist/config/openrouter-catalog.js +111 -0
- package/dist/config/openrouter-catalog.js.map +1 -0
- package/dist/config/openrouter-models-fallback.d.ts +15 -0
- package/dist/config/openrouter-models-fallback.js +49 -0
- package/dist/config/openrouter-models-fallback.js.map +1 -0
- package/dist/config/types.d.ts +8 -0
- package/dist/config/user-config.d.ts +133 -7
- package/dist/config/user-config.js +234 -5
- package/dist/config/user-config.js.map +1 -1
- package/dist/daemon/daemon-command.js.map +1 -1
- package/dist/daemon/ironcurtain-daemon.js +6 -0
- package/dist/daemon/ironcurtain-daemon.js.map +1 -1
- package/dist/docker/adapters/claude-code.js +109 -1
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/adapters/codex.d.ts +10 -1
- package/dist/docker/adapters/codex.js +99 -20
- package/dist/docker/adapters/codex.js.map +1 -1
- package/dist/docker/adapters/goose.js +38 -2
- package/dist/docker/adapters/goose.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +29 -5
- package/dist/docker/agent-registry.js +1 -1
- package/dist/docker/agent-registry.js.map +1 -1
- package/dist/docker/apple-container-manager.d.ts +4 -4
- package/dist/docker/apple-container-manager.js +44 -22
- package/dist/docker/apple-container-manager.js.map +1 -1
- package/dist/docker/ca.js +9 -2
- package/dist/docker/ca.js.map +1 -1
- package/dist/docker/container-lifecycle.js +9 -0
- package/dist/docker/container-lifecycle.js.map +1 -1
- package/dist/docker/docker-agent-session.d.ts +31 -0
- package/dist/docker/docker-agent-session.js +67 -6
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +55 -15
- package/dist/docker/docker-infrastructure.js +261 -61
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/docker-manager.js +127 -0
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/docker-resource-lifecycle.d.ts +71 -0
- package/dist/docker/docker-resource-lifecycle.js +454 -0
- package/dist/docker/docker-resource-lifecycle.js.map +1 -0
- package/dist/docker/gc-command.d.ts +2 -0
- package/dist/docker/gc-command.js +37 -0
- package/dist/docker/gc-command.js.map +1 -0
- package/dist/docker/mitm-proxy.d.ts +6 -4
- package/dist/docker/mitm-proxy.js +105 -17
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/network-topology.d.ts +16 -9
- package/dist/docker/network-topology.js +17 -10
- package/dist/docker/network-topology.js.map +1 -1
- package/dist/docker/openrouter.d.ts +107 -0
- package/dist/docker/openrouter.js +236 -0
- package/dist/docker/openrouter.js.map +1 -0
- package/dist/docker/orientation.js +1 -1
- package/dist/docker/orientation.js.map +1 -1
- package/dist/docker/platform.d.ts +3 -1
- package/dist/docker/platform.js +3 -1
- package/dist/docker/platform.js.map +1 -1
- package/dist/docker/provider-config.d.ts +6 -0
- package/dist/docker/provider-config.js.map +1 -1
- package/dist/docker/pty-session.d.ts +7 -0
- package/dist/docker/pty-session.js +111 -29
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/docker/pty-types.d.ts +15 -0
- package/dist/docker/pty-types.js +8 -0
- package/dist/docker/pty-types.js.map +1 -1
- package/dist/docker/sse-extractor.js +55 -5
- package/dist/docker/sse-extractor.js.map +1 -1
- package/dist/docker/stream-delay.d.ts +86 -0
- package/dist/docker/stream-delay.js +218 -0
- package/dist/docker/stream-delay.js.map +1 -0
- package/dist/docker/token-stream-types.d.ts +15 -0
- package/dist/docker/trajectory-reassembler.d.ts +2 -2
- package/dist/docker/trajectory-reassembler.js +22 -2
- package/dist/docker/trajectory-reassembler.js.map +1 -1
- package/dist/docker/trajectory-tap.js +2 -2
- package/dist/docker/trajectory-tap.js.map +1 -1
- package/dist/docker/types.d.ts +63 -5
- package/dist/doctor/checks.d.ts +3 -3
- package/dist/doctor/checks.js +28 -11
- package/dist/doctor/checks.js.map +1 -1
- package/dist/doctor/doctor-command.js +1 -1
- package/dist/doctor/doctor-command.js.map +1 -1
- package/dist/escalation/escalation-watcher.js +6 -0
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/{mux → escalation}/trusted-input.d.ts +7 -4
- package/dist/{mux → escalation}/trusted-input.js +8 -5
- package/dist/escalation/trusted-input.js.map +1 -0
- package/dist/hash.js.map +1 -1
- package/dist/index.js +37 -6
- package/dist/index.js.map +1 -1
- package/dist/mux/mux-app.d.ts +7 -0
- package/dist/mux/mux-app.js +17 -16
- package/dist/mux/mux-app.js.map +1 -1
- package/dist/mux/mux-command.js +8 -3
- package/dist/mux/mux-command.js.map +1 -1
- package/dist/mux/mux-input-handler.d.ts +26 -2
- package/dist/mux/mux-input-handler.js +93 -11
- package/dist/mux/mux-input-handler.js.map +1 -1
- package/dist/mux/mux-renderer.d.ts +2 -1
- package/dist/mux/mux-renderer.js +67 -0
- package/dist/mux/mux-renderer.js.map +1 -1
- package/dist/mux/provider-profile-snapshot.d.ts +44 -0
- package/dist/mux/provider-profile-snapshot.js +56 -0
- package/dist/mux/provider-profile-snapshot.js.map +1 -0
- package/dist/mux/types.d.ts +3 -2
- package/dist/mux/types.js +6 -2
- package/dist/mux/types.js.map +1 -1
- package/dist/observe/observe-command.js.map +1 -1
- package/dist/pipeline/constitution-compiler.js.map +1 -1
- package/dist/pipeline/generate-with-repair.js.map +1 -1
- package/dist/pipeline/refresh-lists.js.map +1 -1
- package/dist/pipeline/scenario-schema-validator.js.map +1 -1
- package/dist/{mux → pty}/pty-bridge.d.ts +39 -3
- package/dist/{mux → pty}/pty-bridge.js +68 -18
- package/dist/pty/pty-bridge.js.map +1 -0
- package/dist/pty/resolve-ironcurtain-bin.d.ts +17 -0
- package/dist/pty/resolve-ironcurtain-bin.js +22 -0
- package/dist/pty/resolve-ironcurtain-bin.js.map +1 -0
- package/dist/servers/fetch-server.js +2 -0
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +11 -2
- package/dist/session/index.js.map +1 -1
- package/dist/session/preflight.d.ts +19 -5
- package/dist/session/preflight.js +41 -25
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/resource-budget-tracker.js +8 -0
- package/dist/session/resource-budget-tracker.js.map +1 -1
- package/dist/session/session-manager.d.ts +7 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/types.d.ts +14 -0
- package/dist/signal/signal-bot-daemon.d.ts +1 -0
- package/dist/signal/signal-bot-daemon.js +9 -2
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/trusted-process/mcp-error-utils.js +4 -1
- package/dist/trusted-process/mcp-error-utils.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.js +2 -0
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/tcp-server-transport.d.ts +7 -0
- package/dist/trusted-process/tcp-server-transport.js +48 -10
- package/dist/trusted-process/tcp-server-transport.js.map +1 -1
- package/dist/utils/preflight-checks.js +1 -3
- package/dist/utils/preflight-checks.js.map +1 -1
- package/dist/utils/stdio-guard.d.ts +1 -0
- package/dist/utils/stdio-guard.js +27 -0
- package/dist/utils/stdio-guard.js.map +1 -0
- package/dist/web-ui/__tests__/config-dispatch.test.d.ts +13 -0
- package/dist/web-ui/__tests__/config-dispatch.test.js +383 -0
- package/dist/web-ui/__tests__/config-dispatch.test.js.map +1 -0
- package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +6 -0
- package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -1
- package/dist/web-ui/dispatch/config-dispatch.d.ts +15 -0
- package/dist/web-ui/dispatch/config-dispatch.js +277 -0
- package/dist/web-ui/dispatch/config-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/escalation-dispatch.js +11 -2
- package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -1
- package/dist/web-ui/dispatch/pty-dispatch.d.ts +15 -0
- package/dist/web-ui/dispatch/pty-dispatch.js +66 -0
- package/dist/web-ui/dispatch/pty-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/session-dispatch.js +69 -4
- package/dist/web-ui/dispatch/session-dispatch.js.map +1 -1
- package/dist/web-ui/dispatch/types.d.ts +12 -0
- package/dist/web-ui/dispatch/types.js +38 -1
- package/dist/web-ui/dispatch/types.js.map +1 -1
- package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -1
- package/dist/web-ui/json-rpc-dispatch.js +3 -0
- package/dist/web-ui/json-rpc-dispatch.js.map +1 -1
- package/dist/web-ui/pty-session-manager.d.ts +234 -0
- package/dist/web-ui/pty-session-manager.js +599 -0
- package/dist/web-ui/pty-session-manager.js.map +1 -0
- package/dist/web-ui/web-event-bus.d.ts +9 -0
- package/dist/web-ui/web-event-bus.js.map +1 -1
- package/dist/web-ui/web-session-transport.js.map +1 -1
- package/dist/web-ui/web-ui-server.d.ts +8 -0
- package/dist/web-ui/web-ui-server.js +25 -0
- package/dist/web-ui/web-ui-server.js.map +1 -1
- package/dist/web-ui/web-ui-types.d.ts +80 -1
- package/dist/web-ui/web-ui-types.js.map +1 -1
- package/dist/web-ui-static/assets/index-CCVI4nEK.css +1 -0
- package/dist/web-ui-static/assets/index-Dm012gvY.js +114 -0
- package/dist/web-ui-static/index.html +2 -2
- package/dist/workflow/daemon-gate-commands.js +38 -42
- package/dist/workflow/daemon-gate-commands.js.map +1 -1
- package/dist/workflow/machine-builder.js.map +1 -1
- package/dist/workflow/orchestrator.js +10 -7
- package/dist/workflow/orchestrator.js.map +1 -1
- package/dist/workflow/shutdown-signals.d.ts +7 -0
- package/dist/workflow/shutdown-signals.js +40 -0
- package/dist/workflow/shutdown-signals.js.map +1 -0
- package/dist/workflow/validate.js.map +1 -1
- package/dist/workflow/workflow-command.js +10 -8
- package/dist/workflow/workflow-command.js.map +1 -1
- package/dist/workflow/workflows/evolve/README.md +66 -0
- package/dist/workflow/workflows/vuln-discovery/README.md +54 -0
- package/docker/entrypoint-claude-code.sh +22 -0
- package/package.json +31 -27
- package/src/workflow/workflows/evolve/README.md +66 -0
- package/src/workflow/workflows/vuln-discovery/README.md +54 -0
- package/dist/mux/pty-bridge.js.map +0 -1
- package/dist/mux/trusted-input.js.map +0 -1
- package/dist/web-ui-static/assets/index-BD5U6ILh.css +0 -1
- package/dist/web-ui-static/assets/index-FMsHWEfK.js +0 -79
- package/packages/web-ui/README.md +0 -113
|
@@ -9,8 +9,9 @@ import * as p from '@clack/prompts';
|
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
10
|
import { resolve, dirname } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { loadUserConfig, saveUserConfig, validateModelId, ESCALATION_TIMEOUT_MIN, ESCALATION_TIMEOUT_MAX, WEB_SEARCH_PROVIDERS, WEB_SEARCH_PROVIDER_LABELS, WEB_SEARCH_PROVIDER_URLS, GOOSE_PROVIDERS, DOCKER_AGENTS, SESSION_MODES, CONTAINER_RUNTIMES, } from './user-config.js';
|
|
12
|
+
import { loadUserConfig, saveUserConfig, validateModelId, ESCALATION_TIMEOUT_MIN, ESCALATION_TIMEOUT_MAX, WEB_SEARCH_PROVIDERS, WEB_SEARCH_PROVIDER_LABELS, WEB_SEARCH_PROVIDER_URLS, GOOSE_PROVIDERS, DOCKER_AGENTS, SESSION_MODES, CONTAINER_RUNTIMES, NATIVE_PROFILE_NAME, DEFAULT_GLM_SLUG, maskApiKey, cloneProviderPreference, } from './user-config.js';
|
|
13
13
|
import { getUserConfigPath } from './paths.js';
|
|
14
|
+
import { listOpenrouterModels, catalogEnforces, } from './openrouter-catalog.js';
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
/** Known model options for selection prompts. */
|
|
16
17
|
const KNOWN_MODELS = [
|
|
@@ -57,17 +58,97 @@ export function formatCost(n) {
|
|
|
57
58
|
return 'disabled';
|
|
58
59
|
return `$${n.toFixed(2)}`;
|
|
59
60
|
}
|
|
60
|
-
export function maskApiKey(key) {
|
|
61
|
-
if (!key)
|
|
62
|
-
return 'none';
|
|
63
|
-
if (key.length <= 6)
|
|
64
|
-
return '***';
|
|
65
|
-
return key.slice(0, 3) + '...' + key.slice(-3);
|
|
66
|
-
}
|
|
67
61
|
function formatModelShort(id) {
|
|
68
62
|
const known = KNOWN_MODELS.find((m) => m.value === id);
|
|
69
63
|
return known ? known.label : id;
|
|
70
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The current editor view of the `profiles` record, in input shape (openrouter
|
|
67
|
+
* profiles carry the fields the editor mutates). A pending `profiles` record,
|
|
68
|
+
* when present, is already the whole record (read-modify-write) and replaces the
|
|
69
|
+
* resolved view; otherwise the resolved profiles are converted to input shape.
|
|
70
|
+
* The implicit `native` profile is intentionally excluded — it is never
|
|
71
|
+
* persisted and is always shown separately as read-only.
|
|
72
|
+
*/
|
|
73
|
+
function currentProfiles(resolved, pending) {
|
|
74
|
+
if (pending.modelProviders?.profiles) {
|
|
75
|
+
return { ...pending.modelProviders.profiles };
|
|
76
|
+
}
|
|
77
|
+
const view = {};
|
|
78
|
+
for (const [name, profile] of Object.entries(resolved.modelProviders.profiles)) {
|
|
79
|
+
if (name === NATIVE_PROFILE_NAME)
|
|
80
|
+
continue;
|
|
81
|
+
view[name] = resolvedProfileToInput(profile);
|
|
82
|
+
}
|
|
83
|
+
return view;
|
|
84
|
+
}
|
|
85
|
+
/** Converts a resolved profile back to its input shape (drops resolution-only defaults where empty). */
|
|
86
|
+
function resolvedProfileToInput(profile) {
|
|
87
|
+
if (profile.type === 'native')
|
|
88
|
+
return { type: 'native' };
|
|
89
|
+
const input = { type: 'openrouter' };
|
|
90
|
+
if (profile.apiKey)
|
|
91
|
+
input.apiKey = profile.apiKey;
|
|
92
|
+
// A default-tracking profile OMITS `modelMap` so an edit round-trip re-persists
|
|
93
|
+
// the omission rather than pinning today's materialized DEFAULT_MODEL_MAP.
|
|
94
|
+
if (!profile.usesDefaultMap)
|
|
95
|
+
input.modelMap = profile.modelMap.map((r) => ({ match: r.match, model: r.model }));
|
|
96
|
+
const perAgent = {};
|
|
97
|
+
for (const agent of DOCKER_AGENTS) {
|
|
98
|
+
const slug = profile.perAgent[agent];
|
|
99
|
+
if (slug)
|
|
100
|
+
perAgent[agent] = slug;
|
|
101
|
+
}
|
|
102
|
+
if (Object.keys(perAgent).length > 0)
|
|
103
|
+
input.perAgent = perAgent;
|
|
104
|
+
if (profile.providerPreference) {
|
|
105
|
+
input.providerPreference = cloneProviderPreference(profile.providerPreference);
|
|
106
|
+
}
|
|
107
|
+
input.sessionAffinity = profile.sessionAffinity;
|
|
108
|
+
return input;
|
|
109
|
+
}
|
|
110
|
+
/** The current default profile name (pending override wins over the resolved value). */
|
|
111
|
+
function currentDefault(resolved, pending) {
|
|
112
|
+
return pending.modelProviders?.default ?? resolved.modelProviders.default;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* F10 — re-point a dangling `default` to `native`. Returns `NATIVE_PROFILE_NAME`
|
|
116
|
+
* when the current default no longer names a profile in `remainingNames` (and is
|
|
117
|
+
* not itself `native`), otherwise returns the current default unchanged. Pure so
|
|
118
|
+
* the delete-repoints-default invariant is unit-testable without the interactive
|
|
119
|
+
* flow. Persisting a `default` that names a missing profile would make the next
|
|
120
|
+
* `loadUserConfig` a HARD error (the Zod `.refine`), so callers must apply this
|
|
121
|
+
* in the same pending write that deletes a profile.
|
|
122
|
+
*/
|
|
123
|
+
export function repointDefaultAfterDelete(currentDefaultName, remainingNames) {
|
|
124
|
+
if (currentDefaultName === NATIVE_PROFILE_NAME)
|
|
125
|
+
return currentDefaultName;
|
|
126
|
+
return remainingNames.includes(currentDefaultName) ? currentDefaultName : NATIVE_PROFILE_NAME;
|
|
127
|
+
}
|
|
128
|
+
/** Compact one-line summary of an openrouter profile for list/hint rendering. */
|
|
129
|
+
function summarizeOpenrouterProfile(profile) {
|
|
130
|
+
const parts = [];
|
|
131
|
+
const map = profile.modelMap;
|
|
132
|
+
if (map && map.length === 0) {
|
|
133
|
+
parts.push('per-agent only');
|
|
134
|
+
}
|
|
135
|
+
else if (map && map.length > 0) {
|
|
136
|
+
parts.push(map.length === 1 ? `-> ${map[0].model}` : `${map.length} map rules`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
parts.push('default map');
|
|
140
|
+
}
|
|
141
|
+
parts.push(`key: ${maskApiKey(profile.apiKey)}`);
|
|
142
|
+
return parts.join(', ');
|
|
143
|
+
}
|
|
144
|
+
/** Compact rendering of a modelMap for diffs: `*sonnet*->z-ai/glm-5.2; *opus*->…`. */
|
|
145
|
+
function formatModelMap(map) {
|
|
146
|
+
if (!map)
|
|
147
|
+
return 'default';
|
|
148
|
+
if (map.length === 0)
|
|
149
|
+
return 'per-agent only (empty map)';
|
|
150
|
+
return map.map((r) => `${r.match}->${r.model}`).join('; ');
|
|
151
|
+
}
|
|
71
152
|
export function computeDiff(resolved, pending) {
|
|
72
153
|
const diffs = [];
|
|
73
154
|
const topLevelKeys = [
|
|
@@ -142,8 +223,77 @@ export function computeDiff(resolved, pending) {
|
|
|
142
223
|
}
|
|
143
224
|
}
|
|
144
225
|
}
|
|
226
|
+
diffModelProviders(resolved, pending, diffs);
|
|
145
227
|
return diffs;
|
|
146
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Dedicated `modelProviders` diff branch (not the generic nestedSections loop,
|
|
231
|
+
* which compares by reference and would spuriously diff freshly-materialized
|
|
232
|
+
* profile objects — m14). Deep-compares each profile field via JSON.stringify so
|
|
233
|
+
* a no-op read-modify-write yields an EMPTY diff; masks every `apiKey`; renders
|
|
234
|
+
* modelMap rows compactly.
|
|
235
|
+
*/
|
|
236
|
+
function diffModelProviders(resolved, pending, diffs) {
|
|
237
|
+
const pendingMp = pending.modelProviders;
|
|
238
|
+
if (!pendingMp)
|
|
239
|
+
return;
|
|
240
|
+
// default selector
|
|
241
|
+
const resolvedDefault = resolved.modelProviders.default;
|
|
242
|
+
if (pendingMp.default !== undefined && pendingMp.default !== resolvedDefault) {
|
|
243
|
+
diffs.push(['modelProviders.default', { from: resolvedDefault, to: pendingMp.default }]);
|
|
244
|
+
}
|
|
245
|
+
if (!pendingMp.profiles)
|
|
246
|
+
return;
|
|
247
|
+
const resolvedProfiles = resolved.modelProviders.profiles;
|
|
248
|
+
const pendingProfiles = pendingMp.profiles;
|
|
249
|
+
// Added / changed profiles.
|
|
250
|
+
for (const [name, pendingProfile] of Object.entries(pendingProfiles)) {
|
|
251
|
+
if (name === NATIVE_PROFILE_NAME)
|
|
252
|
+
continue;
|
|
253
|
+
const before = name in resolvedProfiles ? resolvedProfileToInput(resolvedProfiles[name]) : undefined;
|
|
254
|
+
diffOneProfile(name, before, pendingProfile, diffs);
|
|
255
|
+
}
|
|
256
|
+
// Removed profiles (present in resolved, absent in the whole-record pending write).
|
|
257
|
+
for (const name of Object.keys(resolvedProfiles)) {
|
|
258
|
+
if (name === NATIVE_PROFILE_NAME)
|
|
259
|
+
continue;
|
|
260
|
+
if (!(name in pendingProfiles)) {
|
|
261
|
+
diffs.push([`modelProviders.profiles.${name}`, { from: 'configured', to: 'removed' }]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Field-by-field diff of one profile, masking apiKey and rendering modelMap compactly. */
|
|
266
|
+
function diffOneProfile(name, before, after, diffs) {
|
|
267
|
+
const prefix = `modelProviders.profiles.${name}`;
|
|
268
|
+
if (!before) {
|
|
269
|
+
diffs.push([prefix, { from: 'none', to: `added (${after.type})` }]);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (before.type !== after.type) {
|
|
273
|
+
diffs.push([`${prefix}.type`, { from: before.type, to: after.type }]);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (before.type === 'native' || after.type === 'native')
|
|
277
|
+
return;
|
|
278
|
+
if (before.apiKey !== after.apiKey) {
|
|
279
|
+
diffs.push([`${prefix}.apiKey`, { from: maskApiKey(before.apiKey), to: maskApiKey(after.apiKey) }]);
|
|
280
|
+
}
|
|
281
|
+
if (JSON.stringify(before.modelMap) !== JSON.stringify(after.modelMap)) {
|
|
282
|
+
diffs.push([`${prefix}.modelMap`, { from: formatModelMap(before.modelMap), to: formatModelMap(after.modelMap) }]);
|
|
283
|
+
}
|
|
284
|
+
if (JSON.stringify(before.perAgent) !== JSON.stringify(after.perAgent)) {
|
|
285
|
+
diffs.push([`${prefix}.perAgent`, { from: before.perAgent ?? {}, to: after.perAgent ?? {} }]);
|
|
286
|
+
}
|
|
287
|
+
if (JSON.stringify(before.providerPreference) !== JSON.stringify(after.providerPreference)) {
|
|
288
|
+
diffs.push([
|
|
289
|
+
`${prefix}.providerPreference`,
|
|
290
|
+
{ from: before.providerPreference ?? 'default', to: after.providerPreference ?? 'default' },
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
if (before.sessionAffinity !== after.sessionAffinity) {
|
|
294
|
+
diffs.push([`${prefix}.sessionAffinity`, { from: before.sessionAffinity, to: after.sessionAffinity }]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
147
297
|
function formatDiffValue(key, value) {
|
|
148
298
|
if (value === null)
|
|
149
299
|
return 'disabled';
|
|
@@ -157,7 +307,11 @@ function formatDiffValue(key, value) {
|
|
|
157
307
|
return formatSeconds(value);
|
|
158
308
|
if (key.includes('Cost'))
|
|
159
309
|
return formatCost(value);
|
|
160
|
-
|
|
310
|
+
if (typeof value === 'string')
|
|
311
|
+
return value;
|
|
312
|
+
if (typeof value === 'number')
|
|
313
|
+
return String(value);
|
|
314
|
+
return JSON.stringify(value);
|
|
161
315
|
}
|
|
162
316
|
// ─── Model prompt ────────────────────────────────────────────
|
|
163
317
|
async function promptModelId(message, current) {
|
|
@@ -648,6 +802,437 @@ async function handleWebSearch(resolved, pending) {
|
|
|
648
802
|
};
|
|
649
803
|
}
|
|
650
804
|
}
|
|
805
|
+
// ─── Model Providers ─────────────────────────────────────────
|
|
806
|
+
/** Returns a shallow copy of `obj` without the given key (avoids `delete obj[dynamicKey]`). */
|
|
807
|
+
function omitKey(obj, key) {
|
|
808
|
+
const copy = { ...obj };
|
|
809
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- key is intentionally dynamic here
|
|
810
|
+
delete copy[key];
|
|
811
|
+
return copy;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Writes the whole `profiles` record + `default` into pending (read-modify-write,
|
|
815
|
+
* required by the shallow `deepMergeConfig`). Callers always pass the complete
|
|
816
|
+
* view so no unmentioned profile is dropped unintentionally.
|
|
817
|
+
*/
|
|
818
|
+
function commitModelProviders(pending, profiles, defaultName) {
|
|
819
|
+
pending.modelProviders = { default: defaultName, profiles };
|
|
820
|
+
}
|
|
821
|
+
async function handleModelProviders(resolved, pending) {
|
|
822
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
|
|
823
|
+
while (true) {
|
|
824
|
+
const profiles = currentProfiles(resolved, pending);
|
|
825
|
+
const defaultName = currentDefault(resolved, pending);
|
|
826
|
+
const options = [
|
|
827
|
+
{
|
|
828
|
+
value: `profile:${NATIVE_PROFILE_NAME}`,
|
|
829
|
+
label: NATIVE_PROFILE_NAME,
|
|
830
|
+
hint: defaultName === NATIVE_PROFILE_NAME ? 'native routing (default, not editable)' : 'native routing',
|
|
831
|
+
},
|
|
832
|
+
];
|
|
833
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
834
|
+
const summary = profile.type === 'openrouter' ? summarizeOpenrouterProfile(profile) : 'native';
|
|
835
|
+
options.push({
|
|
836
|
+
value: `profile:${name}`,
|
|
837
|
+
label: name,
|
|
838
|
+
hint: `${profile.type}, ${summary}${name === defaultName ? ' (default)' : ''}`,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
options.push({ value: 'add', label: 'Add profile...' });
|
|
842
|
+
options.push({ value: 'default', label: 'Set default', hint: defaultName });
|
|
843
|
+
options.push({ value: 'back', label: 'Back' });
|
|
844
|
+
const action = await p.select({ message: 'Model Providers', options });
|
|
845
|
+
if (isCancelled(action) || action === 'back')
|
|
846
|
+
return;
|
|
847
|
+
if (action === 'add') {
|
|
848
|
+
await addProfile(resolved, pending);
|
|
849
|
+
}
|
|
850
|
+
else if (action === 'default') {
|
|
851
|
+
await setDefaultProfile(resolved, pending);
|
|
852
|
+
}
|
|
853
|
+
else if (typeof action === 'string' && action.startsWith('profile:')) {
|
|
854
|
+
const name = action.slice('profile:'.length);
|
|
855
|
+
if (name === NATIVE_PROFILE_NAME) {
|
|
856
|
+
p.note('The native profile uses today’s canonical routing and cannot be edited or deleted.', 'native');
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
await editProfile(resolved, pending, name);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
async function addProfile(resolved, pending) {
|
|
864
|
+
const profiles = currentProfiles(resolved, pending);
|
|
865
|
+
const name = await p.text({
|
|
866
|
+
message: 'Profile name:',
|
|
867
|
+
placeholder: 'e.g. glm-5.2',
|
|
868
|
+
validate: (val) => {
|
|
869
|
+
if (!val || val.trim() === '')
|
|
870
|
+
return 'Name is required';
|
|
871
|
+
if (val === NATIVE_PROFILE_NAME)
|
|
872
|
+
return `"${NATIVE_PROFILE_NAME}" is a reserved profile name.`;
|
|
873
|
+
if (val in profiles)
|
|
874
|
+
return `Profile "${val}" already exists.`;
|
|
875
|
+
return undefined;
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
if (isCancelled(name))
|
|
879
|
+
return;
|
|
880
|
+
// v1 supports only the 'openrouter' type; native is implicit and never user-defined.
|
|
881
|
+
p.note('OpenRouter routes Docker agents through openrouter.ai with a bound model map + key.\n' +
|
|
882
|
+
'Paste an sk-or-v1-... key; defaults map *sonnet*/*opus*/*haiku* -> ' +
|
|
883
|
+
`${DEFAULT_GLM_SLUG} with a soft z-ai cache pin.`, 'openrouter');
|
|
884
|
+
const apiKey = await p.text({
|
|
885
|
+
message: 'OpenRouter API key (sk-or-v1-...):',
|
|
886
|
+
placeholder: 'set via OPENROUTER_API_KEY env, or leave blank',
|
|
887
|
+
validate: () => undefined,
|
|
888
|
+
});
|
|
889
|
+
if (isCancelled(apiKey))
|
|
890
|
+
return;
|
|
891
|
+
const profile = { type: 'openrouter' };
|
|
892
|
+
if (apiKey)
|
|
893
|
+
profile.apiKey = apiKey;
|
|
894
|
+
profiles[name] = profile;
|
|
895
|
+
commitModelProviders(pending, profiles, currentDefault(resolved, pending));
|
|
896
|
+
}
|
|
897
|
+
async function setDefaultProfile(resolved, pending) {
|
|
898
|
+
const profiles = currentProfiles(resolved, pending);
|
|
899
|
+
const currentName = currentDefault(resolved, pending);
|
|
900
|
+
const options = [
|
|
901
|
+
{
|
|
902
|
+
value: NATIVE_PROFILE_NAME,
|
|
903
|
+
label: NATIVE_PROFILE_NAME,
|
|
904
|
+
hint: currentName === NATIVE_PROFILE_NAME ? '(current)' : undefined,
|
|
905
|
+
},
|
|
906
|
+
...Object.keys(profiles).map((name) => ({
|
|
907
|
+
value: name,
|
|
908
|
+
label: name,
|
|
909
|
+
hint: name === currentName ? '(current)' : undefined,
|
|
910
|
+
})),
|
|
911
|
+
];
|
|
912
|
+
const selected = await p.select({
|
|
913
|
+
message: 'Default profile (applies when no per-session choice is made):',
|
|
914
|
+
options,
|
|
915
|
+
});
|
|
916
|
+
if (isCancelled(selected))
|
|
917
|
+
return;
|
|
918
|
+
const name = selected;
|
|
919
|
+
if (name !== currentName) {
|
|
920
|
+
commitModelProviders(pending, profiles, name);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function editProfile(resolved, pending, name) {
|
|
924
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
|
|
925
|
+
while (true) {
|
|
926
|
+
const profiles = currentProfiles(resolved, pending);
|
|
927
|
+
if (!(name in profiles))
|
|
928
|
+
return;
|
|
929
|
+
const profile = profiles[name];
|
|
930
|
+
if (profile.type !== 'openrouter')
|
|
931
|
+
return;
|
|
932
|
+
const field = await p.select({
|
|
933
|
+
message: `Profile: ${name}`,
|
|
934
|
+
options: [
|
|
935
|
+
{ value: 'apiKey', label: 'API key', hint: maskApiKey(profile.apiKey) },
|
|
936
|
+
{ value: 'modelMap', label: 'Model map (glob -> slug)', hint: formatModelMap(profile.modelMap) },
|
|
937
|
+
{ value: 'perAgent', label: 'Per-agent model overrides', hint: perAgentSummary(profile) },
|
|
938
|
+
{
|
|
939
|
+
value: 'providerPreference',
|
|
940
|
+
label: 'Provider preference (cache pinning)',
|
|
941
|
+
hint: providerPreferenceSummary(profile.providerPreference),
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
value: 'sessionAffinity',
|
|
945
|
+
label: 'Session affinity (GLM cache)',
|
|
946
|
+
hint: (profile.sessionAffinity ?? true) ? 'on' : 'off',
|
|
947
|
+
},
|
|
948
|
+
{ value: 'delete', label: 'Delete profile' },
|
|
949
|
+
{ value: 'back', label: 'Back' },
|
|
950
|
+
],
|
|
951
|
+
});
|
|
952
|
+
if (isCancelled(field) || field === 'back')
|
|
953
|
+
return;
|
|
954
|
+
if (field === 'delete') {
|
|
955
|
+
const confirmed = await p.confirm({ message: `Delete profile "${name}"?`, initialValue: false });
|
|
956
|
+
if (isCancelled(confirmed) || !confirmed)
|
|
957
|
+
continue;
|
|
958
|
+
const remaining = omitKey(profiles, name);
|
|
959
|
+
// F10: never persist a `default` that names the just-deleted profile.
|
|
960
|
+
const nextDefault = repointDefaultAfterDelete(currentDefault(resolved, pending), Object.keys(remaining));
|
|
961
|
+
commitModelProviders(pending, remaining, nextDefault);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const updated = await editProfileField(field, profile);
|
|
965
|
+
if (updated) {
|
|
966
|
+
profiles[name] = updated;
|
|
967
|
+
commitModelProviders(pending, profiles, currentDefault(resolved, pending));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** Edits one field of an openrouter profile, returning the updated profile or undefined (no change). */
|
|
972
|
+
async function editProfileField(field, profile) {
|
|
973
|
+
if (field === 'apiKey') {
|
|
974
|
+
const apiKey = await p.text({
|
|
975
|
+
message: 'OpenRouter API key (sk-or-v1-...):',
|
|
976
|
+
placeholder: profile.apiKey ? '(keep current)' : 'leave blank to use OPENROUTER_API_KEY env',
|
|
977
|
+
validate: () => undefined,
|
|
978
|
+
});
|
|
979
|
+
if (isCancelled(apiKey))
|
|
980
|
+
return undefined;
|
|
981
|
+
const next = { ...profile };
|
|
982
|
+
if (apiKey)
|
|
983
|
+
next.apiKey = apiKey;
|
|
984
|
+
else
|
|
985
|
+
delete next.apiKey;
|
|
986
|
+
return next;
|
|
987
|
+
}
|
|
988
|
+
if (field === 'modelMap')
|
|
989
|
+
return editModelMap(profile);
|
|
990
|
+
if (field === 'perAgent')
|
|
991
|
+
return editPerAgent(profile);
|
|
992
|
+
if (field === 'providerPreference')
|
|
993
|
+
return editProviderPreference(profile);
|
|
994
|
+
if (field === 'sessionAffinity') {
|
|
995
|
+
const enabled = await p.confirm({
|
|
996
|
+
message: 'Inject a stable session_id for GLM cache affinity?',
|
|
997
|
+
initialValue: profile.sessionAffinity ?? true,
|
|
998
|
+
});
|
|
999
|
+
if (isCancelled(enabled))
|
|
1000
|
+
return undefined;
|
|
1001
|
+
return { ...profile, sessionAffinity: enabled };
|
|
1002
|
+
}
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Builds the option list for a model-slug autocomplete from the resolved catalog.
|
|
1007
|
+
*
|
|
1008
|
+
* - Every catalog slug becomes a `{ value, label }` option.
|
|
1009
|
+
* - `allowNone` prepends a `(none)` sentinel (value `''`) FIRST — the only way a
|
|
1010
|
+
* selection-only prompt can clear a per-agent override.
|
|
1011
|
+
* - A non-empty `current` absent from the catalog is appended as a grandfather
|
|
1012
|
+
* option, so editing a legacy/delisted slug never traps the user.
|
|
1013
|
+
*
|
|
1014
|
+
* There is intentionally NO refresh sentinel: `ironcurtain config` is short-lived
|
|
1015
|
+
* and fetches the catalog once, so re-running it IS the refresh path.
|
|
1016
|
+
*/
|
|
1017
|
+
export function buildSlugOptions(catalog, current, opts) {
|
|
1018
|
+
const options = catalog.models.map((slug) => ({ value: slug, label: slug }));
|
|
1019
|
+
if (opts.allowNone) {
|
|
1020
|
+
options.unshift({ value: '', label: '(none — use model map)' });
|
|
1021
|
+
}
|
|
1022
|
+
if (current !== '' && !catalog.models.includes(current)) {
|
|
1023
|
+
options.push({ value: current, label: `${current} (current, unverified)` });
|
|
1024
|
+
}
|
|
1025
|
+
return options;
|
|
1026
|
+
}
|
|
1027
|
+
/** Autocomplete (hard-block) for an authoritative catalog; free text for the bundled floor. */
|
|
1028
|
+
export function slugPromptMode(source) {
|
|
1029
|
+
return catalogEnforces(source) ? 'autocomplete' : 'freetext';
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Prompts for one OpenRouter model slug. Fetches the catalog lazily (module-cached)
|
|
1033
|
+
* so apiKey / provider-preference-only edits never pay the network cost.
|
|
1034
|
+
*
|
|
1035
|
+
* Authoritative catalog (`live`/`cache`): a selection-only `autocomplete` whose
|
|
1036
|
+
* options ARE the allowlist — a structural hard-block — with `validate` as
|
|
1037
|
+
* belt-and-suspenders. For an UNSET field the `(none)` sentinel is first, so a
|
|
1038
|
+
* no-op Enter keeps it unset — clack auto-preselects the first option and collapses
|
|
1039
|
+
* a falsy `initialValue` to undefined; a non-empty `current` is passed as
|
|
1040
|
+
* `initialValue` so editing an existing slug starts on it.
|
|
1041
|
+
*
|
|
1042
|
+
* Bundled floor (offline): the list is known-incomplete, so free-text entry with a
|
|
1043
|
+
* one-line warning — the value is accepted as-is.
|
|
1044
|
+
*
|
|
1045
|
+
* Returns the chosen slug (`''` clears when `allowNone`), or `undefined` on cancel.
|
|
1046
|
+
*/
|
|
1047
|
+
async function promptSlug(message, opts) {
|
|
1048
|
+
const { current, allowNone } = opts;
|
|
1049
|
+
const catalog = await listOpenrouterModels();
|
|
1050
|
+
if (slugPromptMode(catalog.source) === 'autocomplete') {
|
|
1051
|
+
// Allowlist = catalog slugs, plus `current` (grandfather a legacy/delisted slug)
|
|
1052
|
+
// and '' ONLY when the field is clearable (per-agent). A required field
|
|
1053
|
+
// (map-row model, allowNone:false) never admits an empty submit.
|
|
1054
|
+
const allowlist = new Set([...catalog.models, ...(current ? [current] : []), ...(allowNone ? [''] : [])]);
|
|
1055
|
+
const picked = await p.autocomplete({
|
|
1056
|
+
message,
|
|
1057
|
+
options: buildSlugOptions(catalog, current, { allowNone }),
|
|
1058
|
+
initialValue: current || '',
|
|
1059
|
+
initialUserInput: current || undefined,
|
|
1060
|
+
maxItems: 8,
|
|
1061
|
+
validate: (v) => (typeof v === 'string' && !allowlist.has(v) ? `Unknown model: ${v}` : undefined),
|
|
1062
|
+
});
|
|
1063
|
+
if (isCancelled(picked))
|
|
1064
|
+
return undefined;
|
|
1065
|
+
return picked;
|
|
1066
|
+
}
|
|
1067
|
+
p.note("Model catalog unavailable — slug can't be verified; entered value accepted as-is.");
|
|
1068
|
+
const typed = await p.text({
|
|
1069
|
+
message,
|
|
1070
|
+
placeholder: current || DEFAULT_GLM_SLUG,
|
|
1071
|
+
validate: allowNone ? () => undefined : (val) => (!val || !val.trim() ? 'Target slug is required' : undefined),
|
|
1072
|
+
});
|
|
1073
|
+
if (isCancelled(typed))
|
|
1074
|
+
return undefined;
|
|
1075
|
+
// Trim: a free-typed slug is persisted as-is, and " " would pass the schema's
|
|
1076
|
+
// min(1) yet break routing (and defeat "blank to clear" for per-agent). The web
|
|
1077
|
+
// path already trims (editableToDto / validateSlugs); keep the CLI consistent.
|
|
1078
|
+
return typed.trim();
|
|
1079
|
+
}
|
|
1080
|
+
async function editModelMap(profile) {
|
|
1081
|
+
const rows = [...(profile.modelMap ?? [])];
|
|
1082
|
+
p.note('Ordered glob -> slug rules; first match wins (matched against the requested model id).\n' +
|
|
1083
|
+
'An EMPTY map means "per-agent-only mode": no glob mapping, rely on per-agent overrides.\n' +
|
|
1084
|
+
'Leaving the map unset uses the built-in defaults (*sonnet*/*opus*/*haiku* -> ' +
|
|
1085
|
+
`${DEFAULT_GLM_SLUG}).`, 'Model map');
|
|
1086
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
|
|
1087
|
+
while (true) {
|
|
1088
|
+
const options = rows.map((row, i) => ({
|
|
1089
|
+
value: `row:${i}`,
|
|
1090
|
+
label: `${row.match} -> ${row.model}`,
|
|
1091
|
+
hint: 'select to remove',
|
|
1092
|
+
}));
|
|
1093
|
+
options.push({ value: 'add', label: 'Add rule...' });
|
|
1094
|
+
options.push({ value: 'done', label: 'Done', hint: rows.length === 0 ? 'empty = per-agent-only mode' : undefined });
|
|
1095
|
+
const action = await p.select({ message: `Model map (${rows.length} rule(s))`, options });
|
|
1096
|
+
if (isCancelled(action) || action === 'done')
|
|
1097
|
+
break;
|
|
1098
|
+
if (action === 'add') {
|
|
1099
|
+
const match = await p.text({
|
|
1100
|
+
message: 'Glob to match (e.g. *sonnet*):',
|
|
1101
|
+
validate: (val) => (!val ? 'Match glob is required' : undefined),
|
|
1102
|
+
});
|
|
1103
|
+
if (isCancelled(match))
|
|
1104
|
+
continue;
|
|
1105
|
+
const model = await promptSlug('Target OpenRouter slug (e.g. z-ai/glm-5.2):', {
|
|
1106
|
+
current: '',
|
|
1107
|
+
allowNone: false,
|
|
1108
|
+
});
|
|
1109
|
+
if (model === undefined)
|
|
1110
|
+
continue;
|
|
1111
|
+
rows.push({ match: match, model });
|
|
1112
|
+
}
|
|
1113
|
+
else if (typeof action === 'string' && action.startsWith('row:')) {
|
|
1114
|
+
const index = Number(action.slice('row:'.length));
|
|
1115
|
+
rows.splice(index, 1);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return { ...profile, modelMap: rows };
|
|
1119
|
+
}
|
|
1120
|
+
async function editPerAgent(profile) {
|
|
1121
|
+
let perAgent = { ...(profile.perAgent ?? {}) };
|
|
1122
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
|
|
1123
|
+
while (true) {
|
|
1124
|
+
const options = DOCKER_AGENTS.map((agent) => ({
|
|
1125
|
+
value: agent,
|
|
1126
|
+
label: DOCKER_AGENT_LABELS[agent],
|
|
1127
|
+
hint: perAgent[agent] ?? 'none (use model map)',
|
|
1128
|
+
}));
|
|
1129
|
+
const selected = await p.select({
|
|
1130
|
+
message: 'Per-agent model overrides (wins over the model map)',
|
|
1131
|
+
options: [...options, { value: 'back', label: 'Back', hint: '' }],
|
|
1132
|
+
});
|
|
1133
|
+
if (isCancelled(selected) || selected === 'back')
|
|
1134
|
+
break;
|
|
1135
|
+
const agent = selected;
|
|
1136
|
+
const slug = await promptSlug(`${DOCKER_AGENT_LABELS[agent]} model slug (blank to clear):`, {
|
|
1137
|
+
current: perAgent[agent] ?? '',
|
|
1138
|
+
allowNone: true,
|
|
1139
|
+
});
|
|
1140
|
+
if (slug === undefined)
|
|
1141
|
+
continue;
|
|
1142
|
+
if (slug)
|
|
1143
|
+
perAgent[agent] = slug;
|
|
1144
|
+
else
|
|
1145
|
+
perAgent = omitKey(perAgent, agent);
|
|
1146
|
+
}
|
|
1147
|
+
const next = { ...profile };
|
|
1148
|
+
if (Object.keys(perAgent).length > 0)
|
|
1149
|
+
next.perAgent = perAgent;
|
|
1150
|
+
else
|
|
1151
|
+
delete next.perAgent;
|
|
1152
|
+
return next;
|
|
1153
|
+
}
|
|
1154
|
+
async function editProviderPreference(profile) {
|
|
1155
|
+
const pref = { ...(profile.providerPreference ?? {}) };
|
|
1156
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
|
|
1157
|
+
while (true) {
|
|
1158
|
+
const field = await p.select({
|
|
1159
|
+
message: 'Provider preference (leave unset for the default soft z-ai pin on z-ai/* slugs)',
|
|
1160
|
+
options: [
|
|
1161
|
+
{ value: 'order', label: 'order (soft pin)', hint: pref.order?.join(', ') ?? 'none' },
|
|
1162
|
+
{ value: 'only', label: 'only (strict pin)', hint: pref.only?.join(', ') ?? 'none' },
|
|
1163
|
+
{
|
|
1164
|
+
value: 'allowFallbacks',
|
|
1165
|
+
label: 'allowFallbacks',
|
|
1166
|
+
hint: pref.allowFallbacks === undefined ? 'default (true)' : pref.allowFallbacks ? 'true' : 'false',
|
|
1167
|
+
},
|
|
1168
|
+
{ value: 'clear', label: 'Clear (revert to default pin)' },
|
|
1169
|
+
{ value: 'back', label: 'Back' },
|
|
1170
|
+
],
|
|
1171
|
+
});
|
|
1172
|
+
if (isCancelled(field) || field === 'back')
|
|
1173
|
+
break;
|
|
1174
|
+
if (field === 'clear') {
|
|
1175
|
+
return { ...profile, providerPreference: undefined };
|
|
1176
|
+
}
|
|
1177
|
+
if (field === 'order' || field === 'only') {
|
|
1178
|
+
const input = await p.text({
|
|
1179
|
+
message: `${field} — comma-separated provider slugs (e.g. z-ai), blank to clear:`,
|
|
1180
|
+
placeholder: pref[field]?.join(', ') ?? 'z-ai',
|
|
1181
|
+
validate: () => undefined,
|
|
1182
|
+
});
|
|
1183
|
+
if (isCancelled(input))
|
|
1184
|
+
continue;
|
|
1185
|
+
const list = input
|
|
1186
|
+
.split(',')
|
|
1187
|
+
.map((s) => s.trim())
|
|
1188
|
+
.filter((s) => s.length > 0);
|
|
1189
|
+
const next = list.length > 0 ? list : undefined;
|
|
1190
|
+
if (field === 'order')
|
|
1191
|
+
pref.order = next;
|
|
1192
|
+
else
|
|
1193
|
+
pref.only = next;
|
|
1194
|
+
}
|
|
1195
|
+
else if (field === 'allowFallbacks') {
|
|
1196
|
+
const enabled = await p.confirm({
|
|
1197
|
+
message: 'Allow OpenRouter to fall back to other providers?',
|
|
1198
|
+
initialValue: pref.allowFallbacks ?? true,
|
|
1199
|
+
});
|
|
1200
|
+
if (isCancelled(enabled))
|
|
1201
|
+
continue;
|
|
1202
|
+
pref.allowFallbacks = enabled;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const cleaned = {};
|
|
1206
|
+
if (pref.order !== undefined)
|
|
1207
|
+
cleaned.order = pref.order;
|
|
1208
|
+
if (pref.only !== undefined)
|
|
1209
|
+
cleaned.only = pref.only;
|
|
1210
|
+
if (pref.allowFallbacks !== undefined)
|
|
1211
|
+
cleaned.allowFallbacks = pref.allowFallbacks;
|
|
1212
|
+
const hasAny = Object.keys(cleaned).length > 0;
|
|
1213
|
+
return { ...profile, providerPreference: hasAny ? cleaned : undefined };
|
|
1214
|
+
}
|
|
1215
|
+
function perAgentSummary(profile) {
|
|
1216
|
+
const perAgent = profile.perAgent;
|
|
1217
|
+
if (!perAgent)
|
|
1218
|
+
return 'none';
|
|
1219
|
+
const entries = Object.entries(perAgent).filter(([, v]) => v);
|
|
1220
|
+
if (entries.length === 0)
|
|
1221
|
+
return 'none';
|
|
1222
|
+
return entries.map(([agent, slug]) => `${agent}=${slug}`).join(', ');
|
|
1223
|
+
}
|
|
1224
|
+
function providerPreferenceSummary(pref) {
|
|
1225
|
+
if (!pref)
|
|
1226
|
+
return 'default (soft z-ai pin)';
|
|
1227
|
+
const parts = [];
|
|
1228
|
+
if (pref.only)
|
|
1229
|
+
parts.push(`only: ${pref.only.join(', ')}`);
|
|
1230
|
+
if (pref.order)
|
|
1231
|
+
parts.push(`order: ${pref.order.join(', ')}`);
|
|
1232
|
+
if (pref.allowFallbacks !== undefined)
|
|
1233
|
+
parts.push(`fallbacks: ${pref.allowFallbacks ? 'on' : 'off'}`);
|
|
1234
|
+
return parts.length > 0 ? parts.join(', ') : 'default';
|
|
1235
|
+
}
|
|
651
1236
|
// ─── Server Credentials ──────────────────────────────────────
|
|
652
1237
|
/** Loads server names from mcp-servers.json for the credential editor. */
|
|
653
1238
|
function loadServerNames() {
|
|
@@ -760,12 +1345,12 @@ async function handleServerCredentials(resolved, pending) {
|
|
|
760
1345
|
// ─── Session Mode ─────────────────────────────────────────────
|
|
761
1346
|
/** Human-readable labels for session modes. */
|
|
762
1347
|
const SESSION_MODE_LABELS = {
|
|
763
|
-
|
|
1348
|
+
container: 'Container (recommended)',
|
|
764
1349
|
builtin: 'Builtin (V8 sandbox)',
|
|
765
1350
|
};
|
|
766
1351
|
/** Short labels used in hints (no parenthetical). */
|
|
767
1352
|
const SESSION_MODE_SHORT_LABELS = {
|
|
768
|
-
|
|
1353
|
+
container: 'Container',
|
|
769
1354
|
builtin: 'Builtin',
|
|
770
1355
|
};
|
|
771
1356
|
/** Human-readable labels for container runtime backends. */
|
|
@@ -1052,6 +1637,14 @@ function webSearchHint(resolved, pending) {
|
|
|
1052
1637
|
const provider = pending.webSearch?.provider ?? resolved.webSearch.provider;
|
|
1053
1638
|
return provider ? WEB_SEARCH_PROVIDER_LABELS[provider] : 'not configured';
|
|
1054
1639
|
}
|
|
1640
|
+
function modelProvidersHint(resolved, pending) {
|
|
1641
|
+
const profiles = currentProfiles(resolved, pending);
|
|
1642
|
+
const count = Object.keys(profiles).length;
|
|
1643
|
+
const defaultName = currentDefault(resolved, pending);
|
|
1644
|
+
if (count === 0)
|
|
1645
|
+
return `default: ${defaultName}`;
|
|
1646
|
+
return `${count} profile${count > 1 ? 's' : ''}, default: ${defaultName}`;
|
|
1647
|
+
}
|
|
1055
1648
|
function serverCredentialsHint(resolved, pending) {
|
|
1056
1649
|
const creds = { ...resolved.serverCredentials, ...pending.serverCredentials };
|
|
1057
1650
|
const configured = Object.entries(creds).filter(([, v]) => Object.keys(v).length > 0);
|
|
@@ -1114,6 +1707,7 @@ export async function runConfigCommand() {
|
|
|
1114
1707
|
{ value: 'resources', label: `Resource Limits (${resourceHint(resolved, pending)})` },
|
|
1115
1708
|
{ value: 'compact', label: `Auto-Compact (${autoCompactHint(resolved, pending)})` },
|
|
1116
1709
|
{ value: 'websearch', label: `Web Search (${webSearchHint(resolved, pending)})` },
|
|
1710
|
+
{ value: 'modelProviders', label: `Model Providers (${modelProvidersHint(resolved, pending)})` },
|
|
1117
1711
|
{ value: 'credentials', label: `Server Credentials (${serverCredentialsHint(resolved, pending)})` },
|
|
1118
1712
|
{ value: 'memory', label: `Memory (${memoryHint(resolved, pending)})` },
|
|
1119
1713
|
{ value: 'snapshots', label: `Container Snapshots (${snapshotHint(resolved, pending)})` },
|
|
@@ -1143,6 +1737,9 @@ export async function runConfigCommand() {
|
|
|
1143
1737
|
case 'websearch':
|
|
1144
1738
|
await handleWebSearch(resolved, pending);
|
|
1145
1739
|
break;
|
|
1740
|
+
case 'modelProviders':
|
|
1741
|
+
await handleModelProviders(resolved, pending);
|
|
1742
|
+
break;
|
|
1146
1743
|
case 'credentials':
|
|
1147
1744
|
await handleServerCredentials(resolved, pending);
|
|
1148
1745
|
break;
|