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.
@@ -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
+ }
@@ -14,9 +14,17 @@ import {
14
14
  sessionOutboxStatus,
15
15
  touchSessionFlushLock,
16
16
  } from "../sessionCollector.js";
17
- import { loadConfig, redactSecretText } from "../config.js";
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") maybeRepairManagedCodexApp(flags.tenant);
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.
@@ -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
  }
@@ -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;
@@ -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
- export const CURRENT_CONFIG_VERSION = 44;
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;
@@ -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: Array.isArray(model.service_tiers) ? model.service_tiers : (entry.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
+ }