@velum-labs/routekit-tool-codex 0.16.9 → 0.17.1

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 CHANGED
@@ -18,3 +18,10 @@ import { codexTool } from "@velum-labs/routekit-tool-codex";
18
18
 
19
19
  - CLI reference: [../../docs/cli.md](../../docs/cli.md)
20
20
  - Maintainer reference: [../../docs/typescript-reference.md](../../docs/typescript-reference.md)
21
+
22
+ ## Native client ownership
23
+
24
+ The launcher uses the user's normal `CODEX_HOME`, applies gateway routing as
25
+ process-scoped configuration, and keeps credentials in the environment. RouteKit
26
+ does not observe, store, resume, or delete Codex sessions; use Codex's native
27
+ history and commands directly.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ToolIntegration } from "@velum-labs/routekit-tools";
2
2
  export declare const codexTool: ToolIntegration;
3
- export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
4
3
  export type { CodexDriverConfig } from "./driver.js";
5
- export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexModelsCache } from "./launch.js";
6
- export type { CodexAgentRole, CodexModelPreset } from "./launch.js";
7
- export { codexIntegrationBlock, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
4
+ export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
8
5
  export type { CodexInstallInput, CodexInstallOwner, CodexInstallProfile, CodexInstallResult } from "./install.js";
6
+ export { codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
7
+ export type { CodexAgentRole, CodexModelPreset } from "./launch.js";
8
+ export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache } from "./launch.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export const codexTool = {
9
9
  binary: "codex",
10
10
  packageName: "@velum-labs/routekit-tool-codex",
11
11
  installHint: "install the Codex CLI: https://github.com/openai/codex",
12
- authSummary: "Codex uses an ephemeral gateway-backed provider.",
12
+ authSummary: "Codex uses RouteKit's OpenAI-compatible gateway provider.",
13
13
  setupSnippet: ({ gatewayUrl, model = "gateway-model" }) => codexLaunchConfigToml({
14
14
  gatewayUrl,
15
15
  defaultModel: model
@@ -36,5 +36,5 @@ export const codexTool = {
36
36
  }
37
37
  };
38
38
  export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
39
- export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexModelsCache } from "./launch.js";
40
- export { codexIntegrationBlock, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
39
+ export { codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
40
+ export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache } from "./launch.js";
package/dist/install.d.ts CHANGED
@@ -1,8 +1,13 @@
1
+ import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
1
2
  export type CodexInstallProfile = {
2
3
  modelId: string;
3
- /** Safe Codex profile selector; defaults to `modelId`. */
4
+ /**
5
+ * Legacy selector for the one persistent profile. New callers should use
6
+ * `CodexInstallInput.profileId`; when both are absent it is `routekit`.
7
+ */
4
8
  profileId?: string;
5
9
  description?: string;
10
+ reasoning?: ModelReasoningCapabilities;
6
11
  };
7
12
  export type CodexInstallOwner = {
8
13
  id: string;
@@ -14,15 +19,26 @@ export type CodexInstallOwner = {
14
19
  };
15
20
  export type CodexInstallInput = {
16
21
  gatewayUrl: string;
22
+ /**
23
+ * The RouteKit models made available through Codex's model picker. This
24
+ * remains named `profiles` for API compatibility, although persistent
25
+ * installs now write one RouteKit profile rather than one file per model.
26
+ */
17
27
  profiles: readonly CodexInstallProfile[];
28
+ /** Model selected when the single RouteKit profile is first opened. */
29
+ defaultModel?: string;
30
+ /** Safe selector for the one persistent RouteKit profile. */
31
+ profileId?: string;
18
32
  owner: CodexInstallOwner;
19
33
  codexHome?: string;
20
34
  };
21
35
  export type CodexInstallResult = {
22
36
  configPath: string;
37
+ catalogPath: string;
23
38
  action: "installed" | "updated";
24
39
  profiles: string[];
25
40
  };
41
+ export declare function codexIntegrationConfigPath(codexHome: string | undefined): string;
26
42
  /** Serialize one additive, owner-marked Codex provider block. */
27
43
  export declare function codexIntegrationBlock(input: CodexInstallInput): string;
28
44
  export declare function installCodexIntegration(input: CodexInstallInput): CodexInstallResult;
package/dist/install.js CHANGED
@@ -4,8 +4,8 @@ import { dirname, join } from "node:path";
4
4
  import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
5
5
  import { SUBSCRIPTIONS } from "@velum-labs/routekit-registry";
6
6
  import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
7
- import { codexProfileFileToml } from "./launch.js";
8
- function codexConfigPath(codexHome) {
7
+ import { codexPersistentModelCatalogJson, codexProfileFileToml, readCodexHomeModelsCache } from "./launch.js";
8
+ export function codexIntegrationConfigPath(codexHome) {
9
9
  if (codexHome !== undefined)
10
10
  return join(codexHome, "config.toml");
11
11
  const registryPath = SUBSCRIPTIONS.codex.configPath ?? "~/.codex/config.toml";
@@ -17,11 +17,16 @@ function marker(ownerId, edge) {
17
17
  function profileFilesComment(ownerId) {
18
18
  return `# ${ownerId}-profile-files:`;
19
19
  }
20
- function profileSelector(profile) {
21
- return profile.profileId ?? profile.modelId;
20
+ function catalogFileComment(ownerId) {
21
+ return `# ${ownerId}-catalog-file:`;
22
22
  }
23
- function profileFileName(profile) {
24
- const selector = profileSelector(profile);
23
+ function catalogFileName(ownerId) {
24
+ if (!/^[A-Za-z0-9_-]+$/.test(ownerId)) {
25
+ throw new Error(`Codex integration owner id is not path-safe: ${JSON.stringify(ownerId)}`);
26
+ }
27
+ return `.${ownerId}-model-catalog.json`;
28
+ }
29
+ function profileFileName(selector) {
25
30
  if (selector.length === 0 ||
26
31
  selector.includes("/") ||
27
32
  selector.includes("\\") ||
@@ -30,19 +35,41 @@ function profileFileName(profile) {
30
35
  }
31
36
  return `${selector}.config.toml`;
32
37
  }
38
+ function selectedProfileId(input) {
39
+ return input.profileId ?? input.profiles[0]?.profileId ?? "routekit";
40
+ }
41
+ function selectedDefaultModel(input) {
42
+ const model = input.defaultModel ?? input.profiles[0]?.modelId;
43
+ if (model === undefined)
44
+ throw new Error("at least one Codex catalog model is required");
45
+ if (!input.profiles.some((profile) => profile.modelId === model)) {
46
+ throw new Error(`the Codex default model ${JSON.stringify(model)} is not in the RouteKit catalog`);
47
+ }
48
+ return model;
49
+ }
50
+ function orderedCatalogProfiles(profiles, defaultModel) {
51
+ return [
52
+ ...profiles.filter((profile) => profile.modelId === defaultModel),
53
+ ...profiles.filter((profile) => profile.modelId !== defaultModel)
54
+ ];
55
+ }
33
56
  /** Serialize one additive, owner-marked Codex provider block. */
34
57
  export function codexIntegrationBlock(input) {
35
58
  const base = trimTrailingSlashes(input.gatewayUrl);
36
59
  const begin = marker(input.owner.id, "begin");
37
60
  const end = marker(input.owner.id, "end");
38
61
  const filesComment = profileFilesComment(input.owner.id);
62
+ const catalogComment = catalogFileComment(input.owner.id);
63
+ const catalogFile = catalogFileName(input.owner.id);
64
+ const profileId = selectedProfileId(input);
39
65
  const body = tomlStringify({
40
66
  model_providers: {
41
67
  [input.owner.providerId]: {
42
68
  name: `${input.owner.displayName} gateway`,
43
69
  base_url: `${base}/v1`,
44
70
  wire_api: "responses",
45
- requires_openai_auth: false
71
+ requires_openai_auth: false,
72
+ env_key: "ROUTEKIT_GATEWAY_TOKEN"
46
73
  }
47
74
  }
48
75
  });
@@ -51,9 +78,11 @@ export function codexIntegrationBlock(input) {
51
78
  `# Managed by \`${input.owner.installCommand}\`; do not edit between these markers.`,
52
79
  `# Rerun that command to update; use \`${input.owner.uninstallCommand}\` to remove.`,
53
80
  `# Start the gateway first: ${input.owner.startCommand}`,
54
- `# Then launch: codex --profile ${input.profiles[0] !== undefined ? profileSelector(input.profiles[0]) : "gateway-model"}`,
55
- ...input.profiles.map((profile) => `# codex --profile ${profileSelector(profile)}${profile.description !== undefined ? ` (${profile.description})` : ""}`),
56
- `${filesComment} ${input.profiles.map(profileFileName).join(" ")}`,
81
+ `# Then launch: codex --profile ${profileId}`,
82
+ "# That one profile keeps the RouteKit provider selected while Codex's model picker",
83
+ "# reads the catalog below. It does not change your normal Codex default model.",
84
+ `${filesComment} ${profileFileName(profileId)}`,
85
+ `${catalogComment} ${catalogFile}`,
57
86
  "",
58
87
  body.trimEnd(),
59
88
  "",
@@ -73,6 +102,19 @@ function ownedProfileFiles(managed, codexHome, ownerId) {
73
102
  .filter((name) => name.endsWith(".config.toml") && !name.includes("/") && !name.includes("\\"))
74
103
  .map((name) => join(codexHome, name));
75
104
  }
105
+ function ownedCatalogFile(managed, codexHome, ownerId) {
106
+ if (managed === undefined)
107
+ return undefined;
108
+ const prefix = catalogFileComment(ownerId);
109
+ const line = managed.split("\n").find((entry) => entry.startsWith(prefix));
110
+ if (line === undefined)
111
+ return undefined;
112
+ const file = line.slice(prefix.length).trim();
113
+ if (file.length === 0 || file.includes("/") || file.includes("\\") || file !== catalogFileName(ownerId)) {
114
+ return undefined;
115
+ }
116
+ return join(codexHome, file);
117
+ }
76
118
  function splitManagedBlock(content, ownerId) {
77
119
  const beginMarker = marker(ownerId, "begin");
78
120
  const endMarker = marker(ownerId, "end");
@@ -108,14 +150,6 @@ function assertNoConflicts(outside, input) {
108
150
  throw new Error(`your Codex config already defines [model_providers.${input.owner.providerId}] outside the ` +
109
151
  `${input.owner.id}-managed block; remove or rename it, then rerun \`${input.owner.installCommand}\``);
110
152
  }
111
- const legacyProfiles = outside.profiles;
112
- for (const profile of input.profiles) {
113
- const selector = profileSelector(profile);
114
- if (isRecord(legacyProfiles) && legacyProfiles[selector] !== undefined) {
115
- throw new Error(`your Codex config already defines [profiles.${selector}] outside the ` +
116
- `${input.owner.id}-managed block; remove or rename it, then rerun \`${input.owner.installCommand}\``);
117
- }
118
- }
119
153
  }
120
154
  function normalize(content) {
121
155
  const trimmed = content.replace(/\s+$/, "");
@@ -133,15 +167,30 @@ function removeOwnedProfileFile(path, ownerId) {
133
167
  // Best-effort cleanup; an orphaned profile does not alter the main config.
134
168
  }
135
169
  }
170
+ function assertProfileFileCanBeManaged(path, ownerId) {
171
+ if (!existsSync(path))
172
+ return;
173
+ if (readFileSync(path, "utf8").includes(`Managed by ${ownerId}`))
174
+ return;
175
+ throw new Error(`refusing to overwrite an existing Codex profile: ${path}; ` +
176
+ `rename it, then rerun the RouteKit install`);
177
+ }
136
178
  export function installCodexIntegration(input) {
137
179
  if (input.profiles.length === 0)
138
- throw new Error("at least one Codex profile is required");
139
- const configPath = codexConfigPath(input.codexHome);
180
+ throw new Error("at least one Codex catalog model is required");
181
+ const defaultModel = selectedDefaultModel(input);
182
+ const profileId = selectedProfileId(input);
183
+ const configPath = codexIntegrationConfigPath(input.codexHome);
140
184
  const codexHome = dirname(configPath);
141
185
  const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
142
186
  const { before, managed, after } = splitManagedBlock(existing, input.owner.id);
143
187
  const outside = parseTomlOrThrow(`${normalize(before)}\n${normalize(after)}`, `your Codex config (${configPath})`);
144
188
  assertNoConflicts(outside, input);
189
+ const catalogPath = join(codexHome, catalogFileName(input.owner.id));
190
+ if (managed === undefined && existsSync(catalogPath)) {
191
+ throw new Error(`refusing to overwrite an existing RouteKit catalog file: ${catalogPath}; ` +
192
+ `move it aside, then rerun \`${input.owner.installCommand}\``);
193
+ }
145
194
  const block = codexIntegrationBlock(input);
146
195
  const head = normalize(before);
147
196
  const tail = normalize(after);
@@ -151,24 +200,32 @@ export function installCodexIntegration(input) {
151
200
  if (!isRecord(providers) || providers[input.owner.providerId] === undefined) {
152
201
  throw new Error("internal error: the assembled Codex config lost its managed provider block");
153
202
  }
203
+ const persistentProfilePath = join(codexHome, profileFileName(profileId));
204
+ assertProfileFileCanBeManaged(persistentProfilePath, input.owner.id);
154
205
  mkdirSync(codexHome, { recursive: true });
155
- const nextFiles = new Set(input.profiles.map((profile) => join(codexHome, profileFileName(profile))));
206
+ writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.profiles, defaultModel).map((profile) => ({
207
+ id: profile.modelId,
208
+ ...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {})
209
+ })), readCodexHomeModelsCache(codexHome)[0]), { mode: 0o600 });
210
+ writeFileSync(persistentProfilePath, `# Managed by ${input.owner.id}\n${codexProfileFileToml(defaultModel, input.owner.providerId, catalogPath)}`, { mode: 0o600 });
211
+ // Write the new profile before replacing the managed block, then clean up
212
+ // legacy per-model files last. An interrupted migration therefore leaves a
213
+ // working old or new profile, never a config that points at a missing file.
214
+ writeFileSync(configPath, next);
215
+ const nextFiles = new Set([persistentProfilePath]);
156
216
  for (const stale of ownedProfileFiles(managed, codexHome, input.owner.id)) {
157
217
  if (!nextFiles.has(stale))
158
218
  removeOwnedProfileFile(stale, input.owner.id);
159
219
  }
160
- writeFileSync(configPath, next);
161
- for (const profile of input.profiles) {
162
- writeFileSync(join(codexHome, profileFileName(profile)), `# Managed by ${input.owner.id}\n${codexProfileFileToml(profile.modelId, input.owner.providerId)}`);
163
- }
164
220
  return {
165
221
  configPath,
222
+ catalogPath,
166
223
  action: managed !== undefined ? "updated" : "installed",
167
- profiles: input.profiles.map(profileSelector)
224
+ profiles: [profileId]
168
225
  };
169
226
  }
170
227
  export function uninstallCodexIntegration(input) {
171
- const configPath = codexConfigPath(input.codexHome);
228
+ const configPath = codexIntegrationConfigPath(input.codexHome);
172
229
  if (!existsSync(configPath))
173
230
  return { configPath, removed: false };
174
231
  const existing = readFileSync(configPath, "utf8");
@@ -178,6 +235,9 @@ export function uninstallCodexIntegration(input) {
178
235
  for (const owned of ownedProfileFiles(managed, dirname(configPath), input.ownerId)) {
179
236
  removeOwnedProfileFile(owned, input.ownerId);
180
237
  }
238
+ const catalogPath = ownedCatalogFile(managed, dirname(configPath), input.ownerId);
239
+ if (catalogPath !== undefined)
240
+ rmSync(catalogPath, { force: true });
181
241
  const head = normalize(before);
182
242
  const tail = normalize(after);
183
243
  writeFileSync(configPath, `${head}${head.length > 0 && tail.length > 0 ? "\n" : ""}${tail}`);
package/dist/launch.d.ts CHANGED
@@ -1,8 +1,17 @@
1
+ import { spawn } from "node:child_process";
2
+ import { type ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
1
3
  import type { AgentProfile, ToolLaunchContext, ToolLaunchSpec } from "@velum-labs/routekit-tools";
2
4
  export type CodexModelPreset = Record<string, unknown>;
5
+ /** Metadata persisted beside a RouteKit-owned Codex profile. */
6
+ export type CodexPersistentCatalogModel = {
7
+ id: string;
8
+ label?: string;
9
+ reasoning?: ModelReasoningCapabilities;
10
+ };
3
11
  export declare function isCodexConfigFailure(code: number, stderr: string): boolean;
4
12
  export declare function tomlKey(name: string): string;
5
13
  export declare function readCodexModelsCache(home?: string): CodexModelPreset[];
14
+ export declare function readCodexHomeModelsCache(codexHome: string): CodexModelPreset[];
6
15
  export declare function readCodexCatalogTemplate(home?: string): CodexModelPreset | undefined;
7
16
  export declare function codexAuthPath(home?: string): string;
8
17
  export declare function hasCodexLogin(home?: string): boolean;
@@ -21,7 +30,13 @@ export declare function codexCatalogEntries(spec: Pick<ToolLaunchSpec, "defaultM
21
30
  export declare function codexModelCatalogJson(spec: Pick<ToolLaunchSpec, "defaultModel" | "models">, template: CodexModelPreset, stockModels?: readonly CodexModelPreset[], options?: {
22
31
  appendUnlistedStock?: boolean;
23
32
  }): string;
24
- export declare function codexProfileFileToml(model: string, provider?: string): string;
33
+ /**
34
+ * Serialize a versioned ModelInfo catalog for persistent profiles. It uses a
35
+ * target Codex home's stock metadata when available, and a complete fallback
36
+ * for first-run homes that have not downloaded `models_cache.json` yet.
37
+ */
38
+ export declare function codexPersistentModelCatalogJson(models: readonly CodexPersistentCatalogModel[], template?: CodexModelPreset | undefined): string;
39
+ export declare function codexProfileFileToml(model: string, provider?: string, modelCatalogPath?: string): string;
25
40
  export declare function codexProfileFiles(home: string, models: readonly string[], provider?: string): string[];
26
41
  export type CodexAgentRole = AgentProfile & {
27
42
  configPath: string;
@@ -29,4 +44,10 @@ export type CodexAgentRole = AgentProfile & {
29
44
  export declare function codexAgentRoles(home: string, profiles: readonly AgentProfile[]): CodexAgentRole[];
30
45
  export declare function codexAgentRoleToml(profile: AgentProfile): string;
31
46
  export declare function codexLaunchConfigToml(spec: Pick<ToolLaunchSpec, "gatewayUrl" | "defaultModel" | "reasoning" | "auth">, modelCatalogPath?: string, roles?: readonly CodexAgentRole[]): string;
32
- export declare function launchCodex(ctx: ToolLaunchContext): Promise<number>;
47
+ export type CodexLaunchDependencies = {
48
+ spawnProcess?: typeof spawn;
49
+ env?: Record<string, string | undefined>;
50
+ };
51
+ export declare function resolveCodexHome(env?: Record<string, string | undefined>): string;
52
+ /** Launch against the user's real Codex home; only generated catalog data is temporary. */
53
+ export declare function launchCodex(ctx: ToolLaunchContext, deps?: CodexLaunchDependencies): Promise<number>;
package/dist/launch.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { stringify as tomlStringify } from "smol-toml";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
6
5
  import { isCodexPickerEligibleModel, reasoningEffortDescriptors } from "@velum-labs/routekit-contracts";
7
6
  import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
7
+ import { stringify as tomlStringify } from "smol-toml";
8
8
  const PROVIDER_ID = "routekit";
9
9
  const CATALOG_FILE = "model-catalog.json";
10
10
  /** Model-agnostic agent prompt, matching the gateway's synthesized entries. */
@@ -29,9 +29,9 @@ export function tomlKey(name) {
29
29
  function modelsCachePath(home) {
30
30
  return join(home, ".codex", "models_cache.json");
31
31
  }
32
- export function readCodexModelsCache(home = homedir()) {
32
+ function readCodexModelsCachePath(path) {
33
33
  try {
34
- const parsed = JSON.parse(readFileSync(modelsCachePath(home), "utf8"));
34
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
35
35
  return Array.isArray(parsed.models)
36
36
  ? parsed.models.filter((entry) => entry !== null && typeof entry === "object")
37
37
  : [];
@@ -40,6 +40,12 @@ export function readCodexModelsCache(home = homedir()) {
40
40
  return [];
41
41
  }
42
42
  }
43
+ export function readCodexModelsCache(home = homedir()) {
44
+ return readCodexModelsCachePath(modelsCachePath(home));
45
+ }
46
+ export function readCodexHomeModelsCache(codexHome) {
47
+ return readCodexModelsCachePath(join(codexHome, "models_cache.json"));
48
+ }
43
49
  export function readCodexCatalogTemplate(home = homedir()) {
44
50
  return readCodexModelsCache(home)[0];
45
51
  }
@@ -79,9 +85,7 @@ export function codexListedStockSlugs(home = homedir()) {
79
85
  });
80
86
  }
81
87
  function codexModelId(modelId) {
82
- return modelId.startsWith("codex/")
83
- ? modelId.slice("codex/".length)
84
- : modelId;
88
+ return modelId.startsWith("codex/") ? modelId.slice("codex/".length) : modelId;
85
89
  }
86
90
  function catalogModels(spec) {
87
91
  return spec.models.filter((model) => model.id === spec.defaultModel ||
@@ -90,10 +94,10 @@ function catalogModels(spec) {
90
94
  }
91
95
  function catalogIds(spec) {
92
96
  return [
93
- ...new Set([spec.defaultModel, ...catalogModels(spec).flatMap((model) => [
94
- model.id,
95
- ...(model.aliases ?? [])
96
- ])].map(codexModelId))
97
+ ...new Set([
98
+ spec.defaultModel,
99
+ ...catalogModels(spec).flatMap((model) => [model.id, ...(model.aliases ?? [])])
100
+ ].map(codexModelId))
97
101
  ];
98
102
  }
99
103
  /** True when a bare catalog id was projected from a `codex/`-namespaced model. */
@@ -189,8 +193,93 @@ export function codexCatalogEntries(spec, template, stockModels = [], options =
189
193
  export function codexModelCatalogJson(spec, template, stockModels = [], options = {}) {
190
194
  return JSON.stringify({ models: codexCatalogEntries(spec, template, stockModels, options) }, null, 2);
191
195
  }
192
- export function codexProfileFileToml(model, provider = PROVIDER_ID) {
193
- return `${tomlStringify({ model, model_provider: provider }).trimEnd()}\n`;
196
+ /**
197
+ * Serialize a versioned ModelInfo catalog for persistent profiles. It uses a
198
+ * target Codex home's stock metadata when available, and a complete fallback
199
+ * for first-run homes that have not downloaded `models_cache.json` yet.
200
+ */
201
+ export function codexPersistentModelCatalogJson(models, template = undefined) {
202
+ const unique = new Set();
203
+ const catalogModels = models.flatMap((model) => {
204
+ if (model.id.length === 0 || unique.has(model.id))
205
+ return [];
206
+ unique.add(model.id);
207
+ return [
208
+ {
209
+ id: model.id,
210
+ ...(model.label !== undefined ? { label: model.label } : {}),
211
+ ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
212
+ }
213
+ ];
214
+ });
215
+ const defaultModel = catalogModels[0]?.id;
216
+ if (defaultModel === undefined)
217
+ throw new Error("at least one Codex catalog model is required");
218
+ // A normal Codex home has a version-matched stock entry we can use as the
219
+ // schema template. This fallback has the full required ModelInfo shape for
220
+ // first-run/empty homes, then codexCatalogEntries neutralizes the fields
221
+ // that would otherwise leak stock-model behavior into a routed model.
222
+ const fallbackTemplate = {
223
+ slug: "routekit-template",
224
+ display_name: "RouteKit",
225
+ description: "Gateway-routed model.",
226
+ default_reasoning_level: "medium",
227
+ supported_reasoning_levels: [],
228
+ shell_type: "shell_command",
229
+ visibility: "list",
230
+ supported_in_api: true,
231
+ priority: 0,
232
+ additional_speed_tiers: [],
233
+ service_tiers: [],
234
+ default_service_tier: "",
235
+ availability_nux: null,
236
+ upgrade: null,
237
+ base_instructions: NEUTRAL_INSTRUCTIONS,
238
+ model_messages: { instructions_template: NEUTRAL_INSTRUCTIONS },
239
+ include_skills_usage_instructions: false,
240
+ default_reasoning_summary: "none",
241
+ support_verbosity: true,
242
+ default_verbosity: "low",
243
+ apply_patch_tool_type: "freeform",
244
+ web_search_tool_type: "text_and_image",
245
+ truncation_policy: { mode: "tokens", limit: 10_000 },
246
+ supports_parallel_tool_calls: true,
247
+ supports_image_detail_original: true,
248
+ context_window: 272_000,
249
+ max_context_window: 272_000,
250
+ comp_hash: "3000",
251
+ effective_context_window_percent: 95,
252
+ experimental_supported_tools: [],
253
+ input_modalities: ["text", "image"],
254
+ supports_search_tool: true,
255
+ use_responses_lite: false,
256
+ tool_mode: "code_mode",
257
+ multi_agent_version: "v2"
258
+ };
259
+ const generated = codexCatalogEntries({ defaultModel, models: catalogModels }, template ?? fallbackTemplate, [], { appendUnlistedStock: false });
260
+ // The launcher strips the `codex/` namespace because it supplies a runtime
261
+ // provider override. Persistent profiles deliberately retain the gateway's
262
+ // public model id, so restore that exact spelling in their metadata too.
263
+ const entries = catalogModels.map((model, priority) => {
264
+ const generatedEntry = generated.find((entry) => entry.slug === codexModelId(model.id));
265
+ if (generatedEntry === undefined) {
266
+ throw new Error(`persistent Codex catalog omitted model ${JSON.stringify(model.id)}`);
267
+ }
268
+ return {
269
+ ...generatedEntry,
270
+ slug: model.id,
271
+ display_name: model.label ?? model.id,
272
+ priority
273
+ };
274
+ });
275
+ return JSON.stringify({ models: entries }, null, 2);
276
+ }
277
+ export function codexProfileFileToml(model, provider = PROVIDER_ID, modelCatalogPath) {
278
+ return `${tomlStringify({
279
+ model,
280
+ model_provider: provider,
281
+ ...(modelCatalogPath !== undefined ? { model_catalog_json: modelCatalogPath } : {})
282
+ }).trimEnd()}\n`;
194
283
  }
195
284
  export function codexProfileFiles(home, models, provider = PROVIDER_ID) {
196
285
  const written = [];
@@ -233,9 +322,7 @@ export function codexLaunchConfigToml(spec, modelCatalogPath, roles = []) {
233
322
  if (modelCatalogPath !== undefined) {
234
323
  lines.push(`model_catalog_json = ${JSON.stringify(modelCatalogPath)}`);
235
324
  }
236
- lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(`${trimTrailingSlashes(spec.gatewayUrl)}/v1`)}`, `wire_api = "responses"`, `requires_openai_auth = false`, ...(spec.auth?.token !== undefined
237
- ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`]
238
- : []), "");
325
+ lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(`${trimTrailingSlashes(spec.gatewayUrl)}/v1`)}`, `wire_api = "responses"`, `requires_openai_auth = false`, ...(spec.auth?.token !== undefined ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`] : []), "");
239
326
  if (roles.length > 0) {
240
327
  lines.push("[features]", "multi_agent = true", "", "[agents]", "max_depth = 1", "");
241
328
  for (const role of roles) {
@@ -244,66 +331,78 @@ export function codexLaunchConfigToml(spec, modelCatalogPath, roles = []) {
244
331
  }
245
332
  return lines.join("\n");
246
333
  }
247
- function spawnCodex(args, home, cwd, token) {
334
+ export function resolveCodexHome(env = process.env) {
335
+ const configured = env.CODEX_HOME;
336
+ if (configured !== undefined && configured.length > 0) {
337
+ if (!isAbsolute(configured))
338
+ throw new Error("CODEX_HOME must be an absolute path");
339
+ return configured;
340
+ }
341
+ return join(env.HOME ?? env.USERPROFILE ?? homedir(), ".codex");
342
+ }
343
+ function tomlValue(value) {
344
+ return JSON.stringify(value);
345
+ }
346
+ function codexLaunchOverrides(spec, catalogPath, roles = []) {
347
+ const values = [
348
+ ["model", codexModelId(spec.defaultModel)],
349
+ ["model_provider", PROVIDER_ID],
350
+ [`model_providers.${PROVIDER_ID}.name`, "RouteKit gateway"],
351
+ [`model_providers.${PROVIDER_ID}.base_url`, `${trimTrailingSlashes(spec.gatewayUrl)}/v1`],
352
+ [`model_providers.${PROVIDER_ID}.wire_api`, "responses"],
353
+ [`model_providers.${PROVIDER_ID}.requires_openai_auth`, false]
354
+ ];
355
+ if (spec.auth?.token !== undefined)
356
+ values.push([`model_providers.${PROVIDER_ID}.env_key`, "ROUTEKIT_GATEWAY_TOKEN"]);
357
+ if (spec.reasoning?.mode === "effort")
358
+ values.push(["model_reasoning_effort", spec.reasoning.effort]);
359
+ if (catalogPath !== undefined)
360
+ values.push(["model_catalog_json", catalogPath]);
361
+ if (roles.length > 0) {
362
+ values.push(["features.multi_agent", true], ["agents.max_depth", 1]);
363
+ for (const role of roles) {
364
+ values.push([`agents.${role.id}.description`, role.description], [`agents.${role.id}.config_file`, role.configPath]);
365
+ }
366
+ }
367
+ return values.flatMap(([key, value]) => [
368
+ "-c",
369
+ `${key}=${typeof value === "string" ? tomlValue(value) : String(value)}`
370
+ ]);
371
+ }
372
+ function spawnCodex(args, home, cwd, token, dependencies) {
373
+ const spawnProcess = dependencies.spawnProcess ?? spawn;
374
+ const child = spawnProcess("codex", [...args], {
375
+ stdio: "inherit",
376
+ env: {
377
+ ...(dependencies.env ?? process.env),
378
+ CODEX_HOME: home,
379
+ ...(token !== undefined ? { ROUTEKIT_GATEWAY_TOKEN: token } : {})
380
+ },
381
+ ...(cwd !== undefined ? { cwd } : {})
382
+ });
248
383
  return new Promise((resolve, reject) => {
249
- const child = spawn("codex", args, {
250
- stdio: ["inherit", "inherit", "pipe"],
251
- // env-spread-allowed: interactive user tool inherits the user's shell configuration
252
- env: {
253
- ...process.env,
254
- CODEX_HOME: home,
255
- ...(token !== undefined ? { ROUTEKIT_GATEWAY_TOKEN: token } : {})
256
- },
257
- ...(cwd !== undefined ? { cwd } : {})
258
- });
259
- let stderr = "";
260
- child.stderr?.on("data", (chunk) => {
261
- process.stderr.write(chunk);
262
- stderr = (stderr + chunk.toString("utf8")).slice(-8192);
263
- });
264
- child.on("error", reject);
265
- child.on("exit", (code) => resolve({ code: code ?? 0, stderr }));
384
+ child.once("error", reject);
385
+ child.once("exit", (code, signal) => resolve(code ?? (signal === null ? 0 : 1)));
266
386
  });
267
387
  }
268
- export async function launchCodex(ctx) {
269
- const { spec } = ctx;
270
- const home = createIsolatedCodexHome("routekit-codex-");
271
- ctx.registerDisposer(() => rmSync(home, { recursive: true, force: true }));
272
- if (hasCodexLogin() && spec.auth?.token === undefined) {
273
- copyFileSync(codexAuthPath(), join(home, "auth.json"));
274
- }
275
- const ids = [...catalogIds(spec), ...codexListedStockSlugs()];
276
- codexProfileFiles(home, ids);
277
- const template = readCodexCatalogTemplate();
278
- const catalogPath = template === undefined ? undefined : join(home, CATALOG_FILE);
388
+ /** Launch against the user's real Codex home; only generated catalog data is temporary. */
389
+ export async function launchCodex(ctx, deps = {}) {
390
+ const home = resolveCodexHome(deps.env ?? process.env);
391
+ const temp = mkdtempSync(join(tmpdir(), "rk-codex-"));
392
+ ctx.registerDisposer(() => rmSync(temp, { recursive: true, force: true }));
393
+ const stockModels = readCodexHomeModelsCache(home);
394
+ const template = stockModels[0];
395
+ const catalogPath = template === undefined ? undefined : join(temp, CATALOG_FILE);
279
396
  if (catalogPath !== undefined && template !== undefined) {
280
- // The stock cache supplies verbatim ModelInfo for gateway models that are
281
- // Codex-native. Unlisted stock models are not appended: without a codex
282
- // route in the gateway catalog they would not resolve.
283
- writeFileSync(catalogPath, codexModelCatalogJson(spec, template, readCodexModelsCache(), {
284
- appendUnlistedStock: false
285
- }));
397
+ writeFileSync(catalogPath, codexModelCatalogJson(ctx.spec, template, stockModels, { appendUnlistedStock: false }), { mode: 0o600 });
286
398
  }
287
- const roles = codexAgentRoles(home, spec.agentProfiles ?? []);
399
+ const roles = codexAgentRoles(temp, ctx.spec.agentProfiles ?? []);
288
400
  if (roles.length > 0) {
289
- mkdirSync(join(home, PROFILE_DIR), { recursive: true });
401
+ mkdirSync(join(temp, PROFILE_DIR), { recursive: true, mode: 0o700 });
290
402
  for (const role of roles)
291
- writeFileSync(role.configPath, codexAgentRoleToml(role));
403
+ writeFileSync(role.configPath, codexAgentRoleToml(role), { mode: 0o600 });
292
404
  }
293
- const configPath = join(home, "config.toml");
294
- const writeConfig = (catalog, activeRoles) => {
295
- writeFileSync(configPath, codexLaunchConfigToml(spec, catalog, activeRoles));
296
- };
297
- writeConfig(catalogPath, roles);
405
+ const overrides = codexLaunchOverrides(ctx.spec, catalogPath, roles);
298
406
  ctx.prepareForPassthrough();
299
- let result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
300
- if (catalogPath !== undefined && isCodexConfigFailure(result.code, result.stderr)) {
301
- writeConfig(undefined, roles);
302
- result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
303
- }
304
- if (roles.length > 0 && isCodexConfigFailure(result.code, result.stderr)) {
305
- writeConfig(undefined, []);
306
- result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
307
- }
308
- return result.code;
407
+ return await spawnCodex([...overrides, ...ctx.spec.args], home, ctx.spec.cwd, ctx.spec.auth?.token, deps);
309
408
  }
@@ -12,7 +12,7 @@ const OWNER = {
12
12
  uninstallCommand: "example uninstall codex",
13
13
  startCommand: "example serve"
14
14
  };
15
- test("Codex managed install updates and removes only owner-marked config", () => {
15
+ test("Codex managed install adds one picker-backed profile and removes only owner-marked config", () => {
16
16
  const home = mkdtempSync(join(tmpdir(), "routekit-codex-install-"));
17
17
  const configPath = join(home, "config.toml");
18
18
  writeFileSync(configPath, 'model = "user-default"\n');
@@ -24,29 +24,41 @@ test("Codex managed install updates and removes only owner-marked config", () =>
24
24
  { modelId: "opaque-primary" },
25
25
  { modelId: "opaque-secondary", description: "Secondary route" }
26
26
  ],
27
+ defaultModel: "opaque-secondary",
27
28
  codexHome: home
28
29
  });
29
30
  assert.equal(installed.action, "installed");
31
+ assert.deepEqual(installed.profiles, ["routekit"]);
30
32
  assert.match(readFileSync(configPath, "utf8"), /model = "user-default"/);
31
33
  assert.match(readFileSync(configPath, "utf8"), /base_url = "http:\/\/127\.0\.0\.1:9999\/v1"/);
32
- assert.equal(existsSync(join(home, "opaque-secondary.config.toml")), true);
34
+ assert.match(readFileSync(configPath, "utf8"), /codex --profile routekit/);
35
+ assert.equal(existsSync(join(home, "routekit.config.toml")), true);
36
+ assert.equal(existsSync(join(home, "opaque-primary.config.toml")), false);
37
+ assert.equal(existsSync(join(home, "opaque-secondary.config.toml")), false);
38
+ assert.equal(existsSync(installed.catalogPath), true);
39
+ assert.match(readFileSync(join(home, "routekit.config.toml"), "utf8"), /model = "opaque-secondary"/);
40
+ assert.match(readFileSync(join(home, "routekit.config.toml"), "utf8"), /model_catalog_json/);
41
+ assert.deepEqual(JSON.parse(readFileSync(installed.catalogPath, "utf8")).models.map((model) => model.slug), ["opaque-secondary", "opaque-primary"]);
33
42
  const updated = installCodexIntegration({
34
43
  gatewayUrl: "http://127.0.0.1:8888",
35
44
  owner: OWNER,
36
45
  profiles: [{ modelId: "opaque-primary" }],
46
+ defaultModel: "opaque-primary",
37
47
  codexHome: home
38
48
  });
39
49
  assert.equal(updated.action, "updated");
40
- assert.equal(existsSync(join(home, "opaque-secondary.config.toml")), false);
50
+ assert.deepEqual(updated.profiles, ["routekit"]);
51
+ assert.match(readFileSync(join(home, "routekit.config.toml"), "utf8"), /model = "opaque-primary"/);
41
52
  assert.equal(uninstallCodexIntegration({ ownerId: OWNER.id, codexHome: home }).removed, true);
42
53
  assert.equal(readFileSync(configPath, "utf8"), 'model = "user-default"\n');
43
- assert.equal(existsSync(join(home, "opaque-primary.config.toml")), false);
54
+ assert.equal(existsSync(join(home, "routekit.config.toml")), false);
55
+ assert.equal(existsSync(installed.catalogPath), false);
44
56
  }
45
57
  finally {
46
58
  rmSync(home, { recursive: true, force: true });
47
59
  }
48
60
  });
49
- test("Codex profiles can preserve an opaque model id behind a safe selector", () => {
61
+ test("Codex's single persistent profile can use a safe custom selector", () => {
50
62
  const home = mkdtempSync(join(tmpdir(), "routekit-codex-opaque-"));
51
63
  try {
52
64
  const result = installCodexIntegration({
@@ -63,3 +75,65 @@ test("Codex profiles can preserve an opaque model id behind a safe selector", ()
63
75
  rmSync(home, { recursive: true, force: true });
64
76
  }
65
77
  });
78
+ test("Codex reinstall migrates a legacy per-model installation to one RouteKit profile", () => {
79
+ const home = mkdtempSync(join(tmpdir(), "routekit-codex-migrate-"));
80
+ const configPath = join(home, "config.toml");
81
+ const legacyProfiles = ["routekit-model-1.config.toml", "routekit-model-2.config.toml"];
82
+ writeFileSync(configPath, [
83
+ 'model = "user-default"',
84
+ "",
85
+ "# >>> example-host integration >>>",
86
+ "# example-host-profile-files: routekit-model-1.config.toml routekit-model-2.config.toml",
87
+ "# example-host-catalog-file: .example-host-model-catalog.json",
88
+ "",
89
+ "[model_providers.example_route]",
90
+ 'name = "Example Host gateway"',
91
+ 'base_url = "http://127.0.0.1:9999/v1"',
92
+ 'wire_api = "responses"',
93
+ "requires_openai_auth = false",
94
+ 'env_key = "ROUTEKIT_GATEWAY_TOKEN"',
95
+ "",
96
+ "# <<< example-host integration <<<",
97
+ ""
98
+ ].join("\n"));
99
+ for (const profile of legacyProfiles) {
100
+ writeFileSync(join(home, profile), "# Managed by example-host\nmodel = \"opaque-primary\"\n");
101
+ }
102
+ writeFileSync(join(home, ".example-host-model-catalog.json"), "{\"models\":[]}\n");
103
+ try {
104
+ const result = installCodexIntegration({
105
+ gatewayUrl: "http://127.0.0.1:8888",
106
+ owner: OWNER,
107
+ profiles: [{ modelId: "opaque-primary" }, { modelId: "opaque-secondary" }],
108
+ defaultModel: "opaque-secondary",
109
+ codexHome: home
110
+ });
111
+ assert.equal(result.action, "updated");
112
+ assert.deepEqual(result.profiles, ["routekit"]);
113
+ assert.equal(existsSync(join(home, "routekit.config.toml")), true);
114
+ for (const profile of legacyProfiles)
115
+ assert.equal(existsSync(join(home, profile)), false);
116
+ assert.match(readFileSync(configPath, "utf8"), /example-host-profile-files: routekit\.config\.toml/);
117
+ }
118
+ finally {
119
+ rmSync(home, { recursive: true, force: true });
120
+ }
121
+ });
122
+ test("Codex install refuses to overwrite a user-owned routekit profile", () => {
123
+ const home = mkdtempSync(join(tmpdir(), "routekit-codex-profile-conflict-"));
124
+ const configPath = join(home, "config.toml");
125
+ writeFileSync(join(home, "routekit.config.toml"), 'model = "user-profile"\n');
126
+ try {
127
+ assert.throws(() => installCodexIntegration({
128
+ gatewayUrl: "http://127.0.0.1:9999",
129
+ owner: OWNER,
130
+ profiles: [{ modelId: "opaque-primary" }],
131
+ codexHome: home
132
+ }), /refusing to overwrite an existing Codex profile/);
133
+ assert.equal(existsSync(configPath), false);
134
+ assert.equal(existsSync(join(home, ".example-host-model-catalog.json")), false);
135
+ }
136
+ finally {
137
+ rmSync(home, { recursive: true, force: true });
138
+ }
139
+ });
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "node:test";
6
- import { codexAgentRoleToml, codexCatalogEntries, createIsolatedCodexHome, codexLaunchConfigToml, codexModelCatalogJson } from "../launch.js";
6
+ import { codexAgentRoleToml, codexCatalogEntries, codexLaunchConfigToml, codexModelCatalogJson, codexPersistentModelCatalogJson, createIsolatedCodexHome, resolveCodexHome } from "../launch.js";
7
7
  const SPEC = {
8
8
  gatewayUrl: "http://127.0.0.1:9999",
9
9
  defaultModel: "opaque-primary",
@@ -50,13 +50,7 @@ test("Codex launcher serializes namespaced models without interpreting provider
50
50
  { effort: "quick", description: "quick" },
51
51
  { effort: "deep", description: "deep" }
52
52
  ]);
53
- // Codex rejects the whole catalog file when any entry omits this field, so
54
- // undiscovered models must serialize an explicit empty list.
55
53
  assert.deepEqual(entries[2]?.supported_reasoning_levels, []);
56
- assert.equal(entries[2]?.default_reasoning_level, undefined);
57
- assert.ok(entries
58
- .slice(0, 3)
59
- .every((entry) => Array.isArray(entry.supported_reasoning_levels)), "every gateway-routed entry carries supported_reasoning_levels");
60
54
  assert.deepEqual(JSON.parse(codexModelCatalogJson(SPEC, template)).models, entries.slice(0, 3));
61
55
  });
62
56
  test("Codex launcher filters incompatible OpenRouter models and aliases", () => {
@@ -75,190 +69,81 @@ test("Codex launcher filters incompatible OpenRouter models and aliases", () =>
75
69
  ],
76
70
  args: []
77
71
  };
78
- const entries = codexCatalogEntries(spec, { slug: "stock" }, [], {
79
- appendUnlistedStock: false
80
- });
81
- assert.deepEqual(entries.map((entry) => entry.slug), ["openai/unknown", "openrouter/reasoning", "reasoning-alias", "openai-alias"]);
82
- });
83
- test("Codex launcher retains an incompatible selected default deterministically", () => {
84
- const spec = {
85
- gatewayUrl: "http://127.0.0.1:9999",
86
- defaultModel: "selected-alias",
87
- models: [
88
- {
89
- id: "openrouter/chat-only",
90
- provider: "openrouter",
91
- aliases: ["selected-alias", "second-alias"]
92
- },
93
- { id: "openrouter/hidden", provider: "openrouter", aliases: ["hidden-alias"] }
94
- ],
95
- args: []
96
- };
97
- assert.deepEqual(codexCatalogEntries(spec, { slug: "stock" }, [], {
98
- appendUnlistedStock: false
99
- }).map((entry) => entry.slug), ["selected-alias", "openrouter/chat-only", "second-alias"]);
100
- });
101
- test("Codex launcher exposes only provider-discovered Claude effort levels", () => {
102
- const spec = {
103
- gatewayUrl: "http://127.0.0.1:9999",
104
- defaultModel: "claude-code/claude-fable-5",
105
- models: [
106
- {
107
- id: "claude-code/claude-fable-5",
108
- reasoning: {
109
- status: "supported",
110
- efforts: [{ id: "low" }, { id: "high" }, { id: "max" }],
111
- budget: { minTokens: 1_024 },
112
- adaptive: true,
113
- wireShape: "anthropic",
114
- provenance: "provider"
115
- }
116
- }
117
- ],
118
- args: []
119
- };
120
- const [entry] = codexCatalogEntries(spec, {
121
- slug: "stock",
122
- visibility: "list",
123
- supported_reasoning_levels: [{ effort: "template" }],
124
- default_reasoning_level: "template"
125
- });
126
- assert.deepEqual(entry?.supported_reasoning_levels, [
127
- { effort: "low", description: "low" },
128
- { effort: "high", description: "high" },
129
- { effort: "max", description: "max" }
130
- ]);
131
- assert.equal(entry?.default_reasoning_level, undefined);
72
+ assert.deepEqual(codexCatalogEntries(spec, { slug: "stock" }, [], { appendUnlistedStock: false }).map((entry) => entry.slug), ["openai/unknown", "openrouter/reasoning", "reasoning-alias", "openai-alias"]);
132
73
  });
133
- test("Codex launcher neutralizes stock-model behavior fields from the template", () => {
74
+ test("Codex launcher neutralizes stock-model behavior for gateway-routed models", () => {
134
75
  const template = {
135
76
  slug: "gpt-stock",
136
- display_name: "Stock",
137
- visibility: "list",
138
- supported_reasoning_levels: [{ effort: "medium" }],
139
- default_reasoning_level: "medium",
140
- // Real stock entries carry fields that change how Codex talks to the
141
- // model; none of them may leak into gateway-routed entries.
142
77
  tool_mode: "code_mode_only",
143
78
  use_responses_lite: true,
144
79
  additional_speed_tiers: ["fast"],
145
- service_tiers: [{ id: "priority", name: "Fast" }],
80
+ service_tiers: [{ id: "priority" }],
146
81
  default_service_tier: "priority",
147
82
  base_instructions: "You are Codex, an agent based on GPT-5.",
148
- model_messages: {
149
- instructions_template: "You are Codex, an agent based on GPT-5.",
150
- instructions_variables: null
151
- }
83
+ model_messages: { instructions_template: "You are Codex, an agent based on GPT-5." }
152
84
  };
153
85
  const [entry] = codexCatalogEntries(SPEC, template);
154
86
  assert.ok(entry);
155
87
  assert.equal("tool_mode" in entry, false);
156
- assert.equal("default_service_tier" in entry, false);
157
88
  assert.equal(entry.use_responses_lite, false);
158
89
  assert.deepEqual(entry.additional_speed_tiers, []);
159
90
  assert.deepEqual(entry.service_tiers, []);
160
- // The developer message must not claim a stock model's identity.
161
91
  assert.equal(entry.base_instructions, "You are a coding agent.");
162
- assert.deepEqual(entry.model_messages, {
163
- instructions_template: "You are a coding agent.",
164
- instructions_variables: null
165
- });
166
- // A minimal template gains no wire-shape fields it never had.
167
- const [minimal] = codexCatalogEntries(SPEC, { slug: "s", visibility: "list" });
168
- assert.ok(minimal);
169
- assert.equal("use_responses_lite" in minimal, false);
170
- assert.equal("service_tiers" in minimal, false);
92
+ assert.deepEqual(entry.model_messages, { instructions_template: "You are a coding agent." });
171
93
  });
172
- test("Codex launcher passes stock ModelInfo through for codex-native models only", () => {
94
+ test("Codex keeps real stock ModelInfo only for Codex-native models", () => {
173
95
  const spec = {
174
96
  gatewayUrl: "http://127.0.0.1:9999",
175
97
  defaultModel: "codex/gpt-5.5",
176
- models: [
177
- { id: "codex/gpt-5.5" },
178
- // A foreign model that happens to collide with a stock slug must NOT
179
- // inherit the stock entry: it is not the Codex-native model.
180
- { id: "claude-code/gpt-5.4" },
181
- { id: "claude-code/claude-sonnet-5" }
182
- ],
98
+ models: [{ id: "codex/gpt-5.5" }, { id: "claude-code/gpt-5.4" }],
183
99
  args: []
184
100
  };
185
- const template = { slug: "stock", display_name: "Stock", visibility: "list" };
186
- const stock = [
187
- {
188
- slug: "gpt-5.5",
189
- display_name: "GPT-5.5",
190
- description: "Stock Codex model.",
191
- base_instructions: "You are Codex, an agent based on GPT-5.",
192
- tool_mode: "code_mode_only",
193
- use_responses_lite: true,
194
- supported_reasoning_levels: [{ effort: "xhigh" }],
195
- default_reasoning_level: "xhigh",
196
- visibility: "hidden"
197
- },
198
- { slug: "gpt-5.4", display_name: "GPT-5.4" },
199
- { slug: "gpt-unrelated", display_name: "Unrelated" }
200
- ];
201
- const entries = codexCatalogEntries(spec, template, stock, {
101
+ const stock = [{ slug: "gpt-5.5", tool_mode: "code_mode_only" }, { slug: "gpt-5.4" }];
102
+ const [native, foreign] = codexCatalogEntries(spec, { slug: "stock" }, stock, {
202
103
  appendUnlistedStock: false
203
104
  });
204
- assert.deepEqual(entries.map((entry) => entry.slug), ["gpt-5.5", "claude-code/gpt-5.4", "claude-code/claude-sonnet-5"]);
205
- const [native, foreignCollision, foreign] = entries;
206
- // Native passthrough keeps the tuned stock behavior, pinned to list + HTTP.
207
- assert.equal(native?.base_instructions, "You are Codex, an agent based on GPT-5.");
208
105
  assert.equal(native?.tool_mode, "code_mode_only");
209
- assert.equal(native?.use_responses_lite, true);
210
- assert.equal(native?.default_reasoning_level, "xhigh");
211
- assert.equal(native?.visibility, "list");
212
- assert.equal(native?.prefer_websockets, false);
213
- // Foreign models never inherit stock entries, colliding slug or not.
214
- assert.equal(foreignCollision?.base_instructions, undefined);
215
- assert.equal("tool_mode" in (foreignCollision ?? {}), false);
216
- assert.equal("tool_mode" in (foreign ?? {}), false);
217
- // Unlisted stock models stay out when appending is disabled.
218
- assert.ok(!entries.some((entry) => entry.slug === "gpt-unrelated"));
106
+ assert.equal(foreign?.tool_mode, undefined);
219
107
  });
220
- test("Codex launcher serializes one gateway provider and generic agent profiles", () => {
108
+ test("Codex launcher serializes a gateway provider and generic agent profiles", () => {
221
109
  const role = { ...PROFILE, configPath: "/tmp/reviewer.toml" };
222
- const config = codexLaunchConfigToml(SPEC, "/tmp/catalog.json", [role]);
110
+ const config = codexLaunchConfigToml({ ...SPEC, auth: { token: "test" } }, "/tmp/catalog.json", [
111
+ role
112
+ ]);
223
113
  assert.match(config, /model = "opaque-primary"/);
224
114
  assert.match(config, /base_url = "http:\/\/127\.0\.0\.1:9999\/v1"/);
115
+ assert.match(config, /env_key = "ROUTEKIT_GATEWAY_TOKEN"/);
225
116
  assert.match(config, /config_file = "\/tmp\/reviewer\.toml"/);
226
- const profile = codexAgentRoleToml(PROFILE);
227
- assert.match(profile, /model = "opaque-secondary"/);
228
- assert.match(profile, /developer_instructions = "Return concise findings\."/);
117
+ assert.match(codexAgentRoleToml(PROFILE), /developer_instructions = "Return concise findings\."/);
229
118
  });
230
- test("Codex launcher projects codex models to native picker ids", () => {
231
- const spec = {
232
- gatewayUrl: "http://127.0.0.1:9999",
233
- defaultModel: "codex/gpt-5.5",
234
- models: [
235
- { id: "codex/gpt-5.5", label: "GPT-5.5 subscription" },
236
- { id: "claude-code/claude-sonnet-4-6" }
237
- ],
238
- args: []
239
- };
240
- const template = {
241
- slug: "stock",
242
- display_name: "Stock",
243
- visibility: "list"
244
- };
245
- assert.deepEqual(codexCatalogEntries(spec, template).map((entry) => [
246
- entry.slug,
247
- entry.display_name
248
- ]), [
249
- ["gpt-5.5", "GPT-5.5 subscription"],
250
- [
251
- "claude-code/claude-sonnet-4-6",
252
- "claude-code/claude-sonnet-4-6"
253
- ]
119
+ test("persistent Codex profiles carry matching model metadata", () => {
120
+ const catalog = JSON.parse(codexPersistentModelCatalogJson([
121
+ {
122
+ id: "openai/gpt-4o-mini",
123
+ reasoning: {
124
+ status: "supported",
125
+ efforts: [{ id: "low" }, { id: "high" }],
126
+ defaultEffort: "low",
127
+ provenance: "provider"
128
+ }
129
+ },
130
+ { id: "codex/gpt-5.6" }
131
+ ]));
132
+ assert.deepEqual(catalog.models.map((model) => model.slug), [
133
+ "openai/gpt-4o-mini",
134
+ "codex/gpt-5.6"
254
135
  ]);
255
- assert.match(codexLaunchConfigToml(spec), /model = "gpt-5\.5"/);
256
- assert.match(codexAgentRoleToml({
257
- ...PROFILE,
258
- model: "codex/gpt-5.5"
259
- }), /model = "gpt-5\.5"/);
136
+ assert.deepEqual(catalog.models[0]?.supported_reasoning_levels, [
137
+ { effort: "low", description: "low" },
138
+ { effort: "high", description: "high" }
139
+ ]);
140
+ assert.equal(catalog.models[0]?.supports_reasoning_summaries, true);
141
+ });
142
+ test("Codex normal home honors absolute CODEX_HOME and rejects relative values", () => {
143
+ assert.equal(resolveCodexHome({ CODEX_HOME: "/private/codex" }), "/private/codex");
144
+ assert.throws(() => resolveCodexHome({ CODEX_HOME: "relative" }), /absolute path/);
260
145
  });
261
- test("isolated Codex homes live under the user cache instead of the system temp root", () => {
146
+ test("isolated Codex homes used by the harness live under the user cache", () => {
262
147
  const root = mkdtempSync(join(tmpdir(), "routekit-codex-home-test-"));
263
148
  const userHome = join(root, "home");
264
149
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-tool-codex",
3
3
  "private": false,
4
- "version": "0.16.9",
4
+ "version": "0.17.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -29,11 +29,11 @@
29
29
  "@openai/codex-sdk": "0.145.0",
30
30
  "smol-toml": "1.7.0",
31
31
  "zod": "4.4.3",
32
- "@velum-labs/routekit-contracts": "0.16.9",
33
- "@velum-labs/routekit-harness-core": "0.16.9",
34
- "@velum-labs/routekit-registry": "0.16.9",
35
- "@velum-labs/routekit-runtime": "0.16.9",
36
- "@velum-labs/routekit-tools": "0.16.9"
32
+ "@velum-labs/routekit-contracts": "0.17.1",
33
+ "@velum-labs/routekit-harness-core": "0.17.1",
34
+ "@velum-labs/routekit-registry": "0.17.1",
35
+ "@velum-labs/routekit-runtime": "0.17.1",
36
+ "@velum-labs/routekit-tools": "0.17.1"
37
37
  },
38
38
  "keywords": [
39
39
  "llm",