impel-cli 0.20.56 → 0.20.57
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/RELEASE_NOTES.md +54 -0
- package/package.json +1 -1
- package/src/apps.js +6 -104
- package/src/cli.js +6 -0
- package/src/cliProfiles.js +28 -6
- package/src/commands/apps.js +43 -0
- package/src/commands/converge.js +2 -0
- package/src/commands/doctor.js +41 -3
- package/src/commands/launch.js +16 -2
- package/src/commands/models.js +160 -0
- package/src/commands/sessions.js +56 -3
- package/src/commands/setup.js +2 -0
- package/src/commands/token.js +11 -1
- package/src/extension/index.js +2 -0
- package/src/gatewayModels.js +58 -0
- package/src/macSetup.js +3 -2
- package/src/managedProfileVersion.js +6 -1
- package/src/modelCatalog.js +109 -1
- package/src/modelSync.js +197 -0
- package/src/modelsManifest.js +132 -0
- package/src/platformSetup.js +3 -2
- package/src/provisioning.js +3 -1
- package/src/runtimeBrand.js +1 -1
- package/src/updates.js +10 -0
- package/src/windowsSetup.js +3 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `impel models sync|list` — the explicit surface over the CLI Codex model
|
|
2
|
+
// catalog layering (D13). `sync` fetches the tenant's gateway catalog and
|
|
3
|
+
// rewrites the isolated `impel codex` models.json (with provenance in
|
|
4
|
+
// models-manifest.json); `list` is near-free diagnostics over the same files:
|
|
5
|
+
// what the picker will show next launch, where it came from (gateway sync or
|
|
6
|
+
// the offline floor), and how stale it is. Launches never depend on this
|
|
7
|
+
// command — the same sync runs launch-layered, TTL-gated, and from the token
|
|
8
|
+
// heartbeat; this is the direct repair/inspection path.
|
|
9
|
+
|
|
10
|
+
import { parseFlags } from "../args.js";
|
|
11
|
+
import {
|
|
12
|
+
crossAppModelsEnabled,
|
|
13
|
+
loadConfig,
|
|
14
|
+
normalizeGatewayUrl,
|
|
15
|
+
redactSecretText,
|
|
16
|
+
resolveDefaultGateway,
|
|
17
|
+
} from "../config.js";
|
|
18
|
+
import { codexCatalogStatus, syncCodexModels } from "../modelSync.js";
|
|
19
|
+
import { MODEL_SYNC_TTL_MS } from "../modelsManifest.js";
|
|
20
|
+
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
21
|
+
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
22
|
+
|
|
23
|
+
const HELP = `impel models - inspect or refresh the isolated Codex model catalog
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
impel models list [--tenant <org>] [--json]
|
|
27
|
+
impel models sync [--tenant <org>] [--stale-only]
|
|
28
|
+
|
|
29
|
+
list shows the catalog \`impel codex\` will load next launch: each model, and
|
|
30
|
+
whether the file is the last gateway sync (with its age) or the built-in
|
|
31
|
+
offline floor. sync fetches the tenant's live gateway catalog and rewrites the
|
|
32
|
+
catalog atomically; --stale-only exits without fetching while the last sync is
|
|
33
|
+
inside its ${MODEL_SYNC_TTL_MS / (60 * 60 * 1000)}h window. Launches never block on this: they layer the same
|
|
34
|
+
sync after the offline floor write and keep the last good catalog on failure.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function formatAge(ageMs) {
|
|
38
|
+
const minutes = Math.floor(ageMs / 60_000);
|
|
39
|
+
if (minutes < 1) return "just now";
|
|
40
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
41
|
+
const hours = Math.floor(minutes / 60);
|
|
42
|
+
if (hours < 48) return `${hours}h ago`;
|
|
43
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function describeCatalogStatus(status) {
|
|
47
|
+
if (status.catalogError === "missing") {
|
|
48
|
+
return `not initialized (run \`${RUNTIME_BRAND.cli.command} setup\` or \`${RUNTIME_BRAND.cli.command} codex\` first)`;
|
|
49
|
+
}
|
|
50
|
+
if (status.catalogError) return "unreadable (rerun the launcher to regenerate it)";
|
|
51
|
+
if (status.source === "floor") {
|
|
52
|
+
return `offline floor (never synced; run \`${RUNTIME_BRAND.cli.command} models sync\`)`;
|
|
53
|
+
}
|
|
54
|
+
const age = status.ageMs === null ? "unknown age" : formatAge(status.ageMs);
|
|
55
|
+
const version = status.manifest?.catalogVersion == null ? "" : `, catalog v${status.manifest.catalogVersion}`;
|
|
56
|
+
return `gateway sync ${age}${version}, ${status.fresh ? "fresh" : "stale"}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cmdModels(argv, overrides = {}) {
|
|
60
|
+
const [action, ...rest] = argv;
|
|
61
|
+
if (action === undefined || ["help", "--help", "-h"].includes(action)) {
|
|
62
|
+
console.log(HELP);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!["sync", "list"].includes(action)) {
|
|
66
|
+
console.error(`impel models: unknown action "${action}". Use \`sync\` or \`list\`.`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const { flags, positionals } = parseFlags(rest, {
|
|
71
|
+
tenant: { type: "string" },
|
|
72
|
+
"stale-only": { type: "boolean" },
|
|
73
|
+
json: { type: "boolean" },
|
|
74
|
+
});
|
|
75
|
+
if (positionals.length > 0) {
|
|
76
|
+
console.error(`impel models: unexpected argument "${positionals[0]}"`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const io = {
|
|
82
|
+
loadConfig,
|
|
83
|
+
ensureTenantSelection,
|
|
84
|
+
syncModels: syncCodexModels,
|
|
85
|
+
catalogStatus: codexCatalogStatus,
|
|
86
|
+
log: (message) => console.log(message),
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
const config = io.loadConfig();
|
|
90
|
+
if (!config?.pat) {
|
|
91
|
+
console.error(`impel models: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const tenantId = flags.tenant
|
|
98
|
+
? normalizeTenantId(flags.tenant)
|
|
99
|
+
: (await io.ensureTenantSelection(config)).tenantId;
|
|
100
|
+
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
101
|
+
const crossAppModels = crossAppModelsEnabled(config);
|
|
102
|
+
|
|
103
|
+
if (action === "sync") {
|
|
104
|
+
const result = await io.syncModels({
|
|
105
|
+
gatewayUrl,
|
|
106
|
+
credential: tenantCredential(config.pat, tenantId),
|
|
107
|
+
tenantId,
|
|
108
|
+
crossAppModels,
|
|
109
|
+
staleOnly: Boolean(flags["stale-only"]),
|
|
110
|
+
});
|
|
111
|
+
if (result.skipped && result.reason === "uninitialized") {
|
|
112
|
+
console.error(
|
|
113
|
+
`impel models: the isolated Codex profile for tenant "${tenantId}" is not initialized. `
|
|
114
|
+
+ `Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} codex\`) first.`,
|
|
115
|
+
);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (result.skipped) {
|
|
120
|
+
io.log(`Models: catalog for tenant ${tenantId} is fresh; nothing to sync.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
io.log(
|
|
124
|
+
`Models: synced ${result.models} model${result.models === 1 ? "" : "s"} for tenant ${tenantId}`
|
|
125
|
+
+ `${result.catalogVersion == null ? "" : ` (catalog v${result.catalogVersion})`}. `
|
|
126
|
+
+ "The picker updates on the next launch.",
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const status = io.catalogStatus(tenantId, { gatewayUrl, crossAppModels });
|
|
132
|
+
if (flags.json) {
|
|
133
|
+
io.log(JSON.stringify({
|
|
134
|
+
tenantId: status.tenantId,
|
|
135
|
+
catalogPath: status.catalogPath,
|
|
136
|
+
source: status.catalogError ? null : status.source,
|
|
137
|
+
catalogError: status.catalogError,
|
|
138
|
+
syncedAt: status.syncedAt,
|
|
139
|
+
catalogVersion: status.manifest?.catalogVersion ?? null,
|
|
140
|
+
fresh: status.fresh,
|
|
141
|
+
crossAppModels,
|
|
142
|
+
models: status.models.map((model) => model.slug),
|
|
143
|
+
}, null, 2));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
io.log(`Tenant: ${tenantId}`);
|
|
147
|
+
io.log(`Catalog: ${status.catalogPath}`);
|
|
148
|
+
io.log(`Status: ${describeCatalogStatus(status)}`);
|
|
149
|
+
if (crossAppModels) io.log("Experiments: cross-app models enabled");
|
|
150
|
+
if (status.models.length > 0) {
|
|
151
|
+
io.log(`Models (${status.models.length}):`);
|
|
152
|
+
for (const model of status.models) {
|
|
153
|
+
io.log(` ${model.slug}${model.display_name && model.display_name !== model.slug ? ` — ${model.display_name}` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`impel models: ${redactSecretText(error?.message || error)}`);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/commands/sessions.js
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
sessionOutboxStatus,
|
|
15
15
|
touchSessionFlushLock,
|
|
16
16
|
} from "../sessionCollector.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
crossAppModelsEnabled,
|
|
19
|
+
loadConfig,
|
|
20
|
+
normalizeGatewayUrl,
|
|
21
|
+
redactSecretText,
|
|
22
|
+
resolveDefaultGateway,
|
|
23
|
+
} from "../config.js";
|
|
24
|
+
import { tenantCliProfilePaths } from "../cliProfiles.js";
|
|
25
|
+
import { cliCodexCatalogStale } from "../modelSync.js";
|
|
18
26
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
19
|
-
import { spawnDetachedAppRefresh } from "../updates.js";
|
|
27
|
+
import { spawnDetachedAppRefresh, spawnDetachedModelSync } from "../updates.js";
|
|
20
28
|
import { brandedEnvironmentName, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
21
29
|
|
|
22
30
|
const MANAGED_SESSION_HOOK_FLAG = `${RUNTIME_BRAND.cli.command}-managed-session-hook-v1`;
|
|
@@ -71,6 +79,45 @@ export function maybeRepairManagedApps(tenantId, {
|
|
|
71
79
|
return true;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The model-catalog half of the token-helper heartbeat: when the isolated CLI
|
|
84
|
+
* Codex catalog for this tenant exists but is past its sync TTL (or its
|
|
85
|
+
* manifest no longer matches the file), kick one detached
|
|
86
|
+
* `models sync --stale-only`. Same shape as maybeRepairManagedApps — the
|
|
87
|
+
* vendor apps and Codex's auth command invoke `impel token` every ~5 minutes,
|
|
88
|
+
* so this is the channel that keeps catalogs converging on machines where
|
|
89
|
+
* nobody relaunches. The heartbeat lock stops repeated token calls from
|
|
90
|
+
* stacking sync children while the gateway is unreachable.
|
|
91
|
+
*/
|
|
92
|
+
export function maybeSyncCliModels(tenantId, {
|
|
93
|
+
config = null,
|
|
94
|
+
catalogStale = cliCodexCatalogStale,
|
|
95
|
+
lockIsFresh = sessionFlushLockIsFresh,
|
|
96
|
+
touchLock = touchSessionFlushLock,
|
|
97
|
+
spawnModelSync = spawnDetachedModelSync,
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (!tenantId) return false;
|
|
100
|
+
let stored = config;
|
|
101
|
+
if (stored === null) {
|
|
102
|
+
try {
|
|
103
|
+
stored = loadConfig();
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!stored?.pat) return false;
|
|
109
|
+
const gatewayUrl = normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway());
|
|
110
|
+
if (!catalogStale(tenantId, {
|
|
111
|
+
gatewayUrl,
|
|
112
|
+
crossAppModels: crossAppModelsEnabled(stored),
|
|
113
|
+
})) return false;
|
|
114
|
+
const lock = path.join(tenantCliProfilePaths(tenantId).codexHome, "models-sync-heartbeat");
|
|
115
|
+
if (lockIsFresh(lock, 60_000)) return false;
|
|
116
|
+
touchLock(lock);
|
|
117
|
+
spawnModelSync(tenantId);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
/**
|
|
75
122
|
* The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
|
|
76
123
|
* drift exactly as before the helper was generalized (the codex hook fires on
|
|
@@ -127,7 +174,13 @@ export async function cmdSessions(argv) {
|
|
|
127
174
|
config,
|
|
128
175
|
flush: false,
|
|
129
176
|
});
|
|
130
|
-
if (flags.provider === "codex")
|
|
177
|
+
if (flags.provider === "codex") {
|
|
178
|
+
maybeRepairManagedCodexApp(flags.tenant);
|
|
179
|
+
// Same guaranteed-to-run channel, second artifact: keep the isolated
|
|
180
|
+
// CLI Codex model catalog converging from codex session events too
|
|
181
|
+
// (TTL + heartbeat-locked, detached `models sync --stale-only`).
|
|
182
|
+
maybeSyncCliModels(flags.tenant);
|
|
183
|
+
}
|
|
131
184
|
// The Claude mirror of the codex repair above: Claude Desktop rewrites
|
|
132
185
|
// its co-owned profile files too, and its session hooks are the only
|
|
133
186
|
// Impel code guaranteed to still run afterward.
|
package/src/commands/setup.js
CHANGED
|
@@ -344,6 +344,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
344
344
|
tenantId: selected.id,
|
|
345
345
|
platform: io.platform,
|
|
346
346
|
skipInstall: true,
|
|
347
|
+
crossAppModels: config.experimental?.crossAppModels === true,
|
|
347
348
|
});
|
|
348
349
|
let prepared = inspected;
|
|
349
350
|
if (inspected.missingAfter.length && !inspectOnly && !flags["skip-clis"]) {
|
|
@@ -376,6 +377,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
376
377
|
platform: io.platform,
|
|
377
378
|
skipInstall: false,
|
|
378
379
|
installTools,
|
|
380
|
+
crossAppModels: config.experimental?.crossAppModels === true,
|
|
379
381
|
});
|
|
380
382
|
}
|
|
381
383
|
}
|
package/src/commands/token.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { parseFlags } from "../args.js";
|
|
3
3
|
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
4
|
-
import { maybeRepairManagedApps } from "./sessions.js";
|
|
4
|
+
import { maybeRepairManagedApps, maybeSyncCliModels } from "./sessions.js";
|
|
5
5
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
6
6
|
|
|
7
7
|
// This is the `apiKeyHelper` / auth-command contract: stdout (and only
|
|
@@ -12,6 +12,7 @@ export async function cmdToken(argv = [], overrides = {}) {
|
|
|
12
12
|
loadConfig,
|
|
13
13
|
ensureTenantSelection,
|
|
14
14
|
repairManagedApps: maybeRepairManagedApps,
|
|
15
|
+
syncCliModels: maybeSyncCliModels,
|
|
15
16
|
...overrides,
|
|
16
17
|
};
|
|
17
18
|
const { flags } = parseFlags(argv, { tenant: { type: "string" } });
|
|
@@ -39,6 +40,15 @@ export async function cmdToken(argv = [], overrides = {}) {
|
|
|
39
40
|
} catch {
|
|
40
41
|
// Opportunistic only; the token contract is already fulfilled.
|
|
41
42
|
}
|
|
43
|
+
// Same heartbeat, second surface: Codex invokes this helper every ~5
|
|
44
|
+
// minutes, so it is also where a stale isolated-CLI model catalog gets
|
|
45
|
+
// its detached `models sync --stale-only` (D13). Identical contract:
|
|
46
|
+
// after the token bytes, never stdout, never the exit code.
|
|
47
|
+
try {
|
|
48
|
+
io.syncCliModels(tenantId, { config });
|
|
49
|
+
} catch {
|
|
50
|
+
// Opportunistic only; the token contract is already fulfilled.
|
|
51
|
+
}
|
|
42
52
|
} catch (error) {
|
|
43
53
|
process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
|
|
44
54
|
process.exitCode = 1;
|
package/src/extension/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const ALIASES = Object.freeze({
|
|
|
12
12
|
tenants: "tenant",
|
|
13
13
|
org: "tenant",
|
|
14
14
|
skill: "skills",
|
|
15
|
+
model: "models",
|
|
15
16
|
agent: "agents",
|
|
16
17
|
upgrade: "update",
|
|
17
18
|
on: "use",
|
|
@@ -39,6 +40,7 @@ function help(version) {
|
|
|
39
40
|
if (enabled.has("mcp")) lines.push(` ${command} mcp Run the authenticated MCP transport`);
|
|
40
41
|
if (enabled.has("sessions")) lines.push(` ${command} sessions ... Run managed session lifecycle hooks`);
|
|
41
42
|
if (enabled.has("skills")) lines.push(` ${command} skills sync [...] Sync gateway skills into managed clients`);
|
|
43
|
+
if (enabled.has("models")) lines.push(` ${command} models list|sync Inspect or refresh the isolated Codex model catalog`);
|
|
42
44
|
if (enabled.has("agents")) lines.push(` ${command} agents sync [...] Sync tenant agents into managed clients`);
|
|
43
45
|
if (enabled.has("status")) lines.push(` ${command} status Show local readiness`);
|
|
44
46
|
if (enabled.has("doctor")) lines.push(` ${command} doctor [...] Run gateway and provider diagnostics`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The authenticated tenant model catalog fetch (GET /v1/models), shared by
|
|
2
|
+
// the desktop app writers in apps.js and the CLI catalog sync in
|
|
3
|
+
// modelSync.js. Leaf module (no imports): the model-catalog side of the CLI
|
|
4
|
+
// must be loadable without pulling in the desktop bundle machinery, so this
|
|
5
|
+
// cannot live in apps.js. apps.js re-exports it for existing importers.
|
|
6
|
+
|
|
7
|
+
function isGatewayModel(model) {
|
|
8
|
+
return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A tenant whose subscription genuinely has no Claude/Codex seats returns an
|
|
13
|
+
* explicitly marked empty catalog. Only lifecycle reconciliation may treat
|
|
14
|
+
* that as valid emptiness (allowEmpty) — every other caller keeps failing
|
|
15
|
+
* closed so a gateway outage can never masquerade as "no models".
|
|
16
|
+
*/
|
|
17
|
+
function isExplicitNoSeatCatalog(payload) {
|
|
18
|
+
const statuses = payload?.provider_status;
|
|
19
|
+
return Boolean(statuses
|
|
20
|
+
&& typeof statuses === "object"
|
|
21
|
+
&& !Array.isArray(statuses)
|
|
22
|
+
&& ["claude", "codex"].every((provider) => (
|
|
23
|
+
statuses[provider]?.state === "no_seat"
|
|
24
|
+
&& statuses[provider]?.routable === false
|
|
25
|
+
)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
|
|
33
|
+
headers: { authorization: `Bearer ${config.pat}` },
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
37
|
+
const payload = await response.json();
|
|
38
|
+
if (config.tenantId && payload?.org_id !== config.tenantId) {
|
|
39
|
+
throw new Error("gateway model catalog tenant did not match the selected tenant");
|
|
40
|
+
}
|
|
41
|
+
if (config.productAccess && payload?.product_access !== config.productAccess) {
|
|
42
|
+
throw new Error("gateway model catalog product access did not match the live entitlement");
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
|
|
45
|
+
// The envelope is additive at schema version 3: overlay-aware gateways
|
|
46
|
+
// also send entitlement_available and org_defaults beside the filtered
|
|
47
|
+
// data[], whose per-model default/family_default flags are already
|
|
48
|
+
// rewritten to the tenant's effective defaults. Clients trust the served
|
|
49
|
+
// flags as-is and tolerate unknown envelope fields.
|
|
50
|
+
const models = payload.data.filter(isGatewayModel);
|
|
51
|
+
if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
|
|
52
|
+
throw new Error("gateway returned no supported models");
|
|
53
|
+
}
|
|
54
|
+
return { models, source: "gateway", version: payload.version ?? null };
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/macSetup.js
CHANGED
|
@@ -178,6 +178,7 @@ export async function prepareMacClis({
|
|
|
178
178
|
skipInstall = false,
|
|
179
179
|
inspectOnly = false,
|
|
180
180
|
installTools = ["claude", "codex"],
|
|
181
|
+
crossAppModels = false,
|
|
181
182
|
} = {}, dependencies = {}) {
|
|
182
183
|
const io = {
|
|
183
184
|
environment: process.env,
|
|
@@ -244,8 +245,8 @@ export async function prepareMacClis({
|
|
|
244
245
|
const binaries = installAttempted
|
|
245
246
|
? detectMacClis(io.find, io.environment, io.verify)
|
|
246
247
|
: before;
|
|
247
|
-
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
|
|
248
|
-
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
|
|
248
|
+
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
249
|
+
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
249
250
|
if (binaries.claude) {
|
|
250
251
|
await io.syncSkills({
|
|
251
252
|
client: "claude",
|
|
@@ -9,4 +9,9 @@
|
|
|
9
9
|
// v44 disables the vendor background auto-updater in managed Claude CLI
|
|
10
10
|
// profiles so a session on the reviewed release cannot move the shared
|
|
11
11
|
// launcher past the pin (the Windows drift behind the gateway lockouts).
|
|
12
|
-
|
|
12
|
+
// v45 layers `impel models sync` over the isolated CLI Codex catalog: the
|
|
13
|
+
// profile writer keeps a manifest-verified synced catalog instead of always
|
|
14
|
+
// reasserting the FALLBACK_MODELS floor, records provenance in
|
|
15
|
+
// models-manifest.json, and (with cross-app models enabled) routes the CLI
|
|
16
|
+
// provider through the experimental OpenAI-compatible gateway path.
|
|
17
|
+
export const CURRENT_CONFIG_VERSION = 45;
|
package/src/modelCatalog.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// tenant profiles in cliProfiles.js can consume it without an import cycle —
|
|
5
5
|
// the 0.20.39 regression happened precisely because the CLI carried its own
|
|
6
6
|
// hand-frozen copy of this data.
|
|
7
|
+
import { redactSecretText } from "./config.js";
|
|
7
8
|
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
8
9
|
|
|
9
10
|
const STANDARD_REASONING_LEVELS = [
|
|
@@ -65,6 +66,7 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
65
66
|
multi_agent_version: null,
|
|
66
67
|
};
|
|
67
68
|
const entry = vendorModel ? { ...generated, ...vendorModel } : generated;
|
|
69
|
+
const serviceTiers = Array.isArray(model.service_tiers) ? model.service_tiers : (entry.service_tiers || []);
|
|
68
70
|
return {
|
|
69
71
|
...entry,
|
|
70
72
|
slug: model.id,
|
|
@@ -74,7 +76,16 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
74
76
|
supported_reasoning_levels: Array.isArray(model.supported_reasoning_levels)
|
|
75
77
|
? model.supported_reasoning_levels
|
|
76
78
|
: entry.supported_reasoning_levels,
|
|
77
|
-
service_tiers:
|
|
79
|
+
service_tiers: serviceTiers,
|
|
80
|
+
// The vendor catalog always pairs the priority ("Fast") service tier with
|
|
81
|
+
// additional_speed_tiers: ["fast"], and the pinned Codex runtime
|
|
82
|
+
// serializes both to the renderer. Reconstruct the pair from the final
|
|
83
|
+
// service tiers (vendor enrichment may still carry its own value) so the
|
|
84
|
+
// Fast surface matches vendor shape — the impel-apps writer has always
|
|
85
|
+
// done this and the two projections must not drift (D15).
|
|
86
|
+
additional_speed_tiers: Array.isArray(vendorModel?.additional_speed_tiers)
|
|
87
|
+
? vendorModel.additional_speed_tiers
|
|
88
|
+
: (serviceTiers.some((tier) => tier?.id === "priority") ? ["fast"] : []),
|
|
78
89
|
visibility: "list",
|
|
79
90
|
supported_in_api: true,
|
|
80
91
|
priority: Number.isInteger(model.priority) ? model.priority : (entry.priority || index + 1),
|
|
@@ -86,6 +97,76 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
86
97
|
};
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Project a gateway Claude model into the Codex picker. Fail-closed: every
|
|
102
|
+
* capability field must arrive verified in client_capabilities.codex, or the
|
|
103
|
+
* model is omitted (never guessed). Lives in this leaf (not apps.js) so the
|
|
104
|
+
* CLI catalog sync and the desktop writers share one projection; apps.js
|
|
105
|
+
* re-exports it for existing importers.
|
|
106
|
+
*/
|
|
107
|
+
export function foreignClaudeCodexCatalogEntry(model, index) {
|
|
108
|
+
const capability = model.client_capabilities?.codex;
|
|
109
|
+
const levels = capability?.supported_reasoning_levels;
|
|
110
|
+
const tiers = capability?.service_tiers;
|
|
111
|
+
const modalities = capability?.input_modalities;
|
|
112
|
+
const valid = capability && typeof capability === "object"
|
|
113
|
+
&& Number.isInteger(capability.context_window) && capability.context_window > 0
|
|
114
|
+
&& Number.isInteger(capability.max_context_window) && capability.max_context_window >= capability.context_window
|
|
115
|
+
&& Array.isArray(levels) && levels.every((level) => (
|
|
116
|
+
level && typeof level.effort === "string" && typeof level.description === "string"
|
|
117
|
+
))
|
|
118
|
+
&& Array.isArray(tiers) && tiers.every((tier) => (
|
|
119
|
+
tier && typeof tier.id === "string" && typeof tier.name === "string" && typeof tier.description === "string"
|
|
120
|
+
))
|
|
121
|
+
&& Array.isArray(modalities) && modalities.every((value) => typeof value === "string")
|
|
122
|
+
&& ["supports_parallel_tool_calls", "supports_reasoning_summaries", "supports_verbosity", "supports_search_tool", "supports_image_detail_original"]
|
|
123
|
+
.every((key) => typeof capability[key] === "boolean")
|
|
124
|
+
&& (capability.default_reasoning_level == null
|
|
125
|
+
|| capability.default_reasoning_level === ""
|
|
126
|
+
|| levels.some((level) => level.effort === capability.default_reasoning_level))
|
|
127
|
+
&& (capability.apply_patch_tool_type == null || typeof capability.apply_patch_tool_type === "string")
|
|
128
|
+
&& (capability.web_search_tool_type == null || typeof capability.web_search_tool_type === "string");
|
|
129
|
+
if (!valid) {
|
|
130
|
+
console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel ChatGPT: missing verified Codex capabilities`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
slug: model.id,
|
|
135
|
+
display_name: model.display_name || model.id,
|
|
136
|
+
description: model.description || `Claude model available through the Impel gateway (${model.id}).`,
|
|
137
|
+
...(capability.default_reasoning_level ? { default_reasoning_level: capability.default_reasoning_level } : {}),
|
|
138
|
+
supported_reasoning_levels: levels,
|
|
139
|
+
service_tiers: tiers,
|
|
140
|
+
// Same vendor-shape pairing rule as codexCatalogEntry: a priority tier
|
|
141
|
+
// always travels with the "fast" speed tier.
|
|
142
|
+
additional_speed_tiers: tiers.some((tier) => tier?.id === "priority") ? ["fast"] : [],
|
|
143
|
+
shell_type: "shell_command",
|
|
144
|
+
visibility: "list",
|
|
145
|
+
supported_in_api: true,
|
|
146
|
+
priority: Number.isInteger(model.priority) ? model.priority : index + 1,
|
|
147
|
+
base_instructions: "You are an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
|
|
148
|
+
include_skills_usage_instructions: false,
|
|
149
|
+
supports_reasoning_summaries: capability.supports_reasoning_summaries,
|
|
150
|
+
support_verbosity: capability.supports_verbosity,
|
|
151
|
+
...(capability.supports_reasoning_summaries ? { default_reasoning_summary: "none" } : {}),
|
|
152
|
+
...(capability.supports_verbosity ? { default_verbosity: "low" } : {}),
|
|
153
|
+
...(capability.apply_patch_tool_type ? { apply_patch_tool_type: capability.apply_patch_tool_type } : {}),
|
|
154
|
+
...(capability.web_search_tool_type ? { web_search_tool_type: capability.web_search_tool_type } : {}),
|
|
155
|
+
truncation_policy: { mode: "tokens", limit: 10000 },
|
|
156
|
+
context_window: capability.context_window,
|
|
157
|
+
max_context_window: capability.max_context_window,
|
|
158
|
+
effective_context_window_percent: 95,
|
|
159
|
+
experimental_supported_tools: [],
|
|
160
|
+
input_modalities: modalities,
|
|
161
|
+
supports_parallel_tool_calls: capability.supports_parallel_tool_calls,
|
|
162
|
+
supports_image_detail_original: capability.supports_image_detail_original,
|
|
163
|
+
supports_search_tool: capability.supports_search_tool,
|
|
164
|
+
use_responses_lite: false,
|
|
165
|
+
tool_mode: null,
|
|
166
|
+
multi_agent_version: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
89
170
|
/**
|
|
90
171
|
* The managed CLI Codex catalog, projected from the same model registry the
|
|
91
172
|
* desktop app uses so reasoning efforts, service tiers, and tool modes can
|
|
@@ -101,3 +182,30 @@ export function managedCodexCliCatalog(now = new Date()) {
|
|
|
101
182
|
.map((model, index) => codexCatalogEntry(model, index)),
|
|
102
183
|
};
|
|
103
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The same catalog document, projected from live gateway models instead of
|
|
188
|
+
* the FALLBACK_MODELS floor (`impel models sync`). The gateway payload never
|
|
189
|
+
* flows through: every entry is reconstructed by codexCatalogEntry /
|
|
190
|
+
* foreignClaudeCodexCatalogEntry from validated fields, exactly like the
|
|
191
|
+
* desktop writer (D15). Claude models join the picker only when crossAppModels
|
|
192
|
+
* is enabled AND each carries verified client_capabilities.codex (fail-closed
|
|
193
|
+
* in the foreign projection). Returns null when nothing projects — an empty
|
|
194
|
+
* catalog must never replace the floor.
|
|
195
|
+
*/
|
|
196
|
+
export function gatewayCodexCliCatalog(models, { crossAppModels = false, now = new Date() } = {}) {
|
|
197
|
+
const ordered = crossAppModels
|
|
198
|
+
? [...models.filter((model) => model.provider === "codex"), ...models.filter((model) => model.provider === "claude")]
|
|
199
|
+
: models.filter((model) => model.provider === "codex");
|
|
200
|
+
const codexModels = ordered
|
|
201
|
+
.map((model, index) => model.provider === "codex"
|
|
202
|
+
? codexCatalogEntry(model, index)
|
|
203
|
+
: foreignClaudeCodexCatalogEntry(model, index))
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
if (codexModels.length === 0) return null;
|
|
206
|
+
return {
|
|
207
|
+
fetched_at: now.toISOString(),
|
|
208
|
+
client_version: `${RUNTIME_BRAND.cli.command}-managed-2`,
|
|
209
|
+
models: codexModels,
|
|
210
|
+
};
|
|
211
|
+
}
|