@velum-labs/routekit-tool-codex 0.17.0 → 0.17.2

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/dist/install.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
2
2
  export type CodexInstallProfile = {
3
3
  modelId: string;
4
- /** 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
+ */
5
8
  profileId?: string;
6
9
  description?: string;
7
10
  reasoning?: ModelReasoningCapabilities;
@@ -16,7 +19,16 @@ export type CodexInstallOwner = {
16
19
  };
17
20
  export type CodexInstallInput = {
18
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
+ */
19
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;
20
32
  owner: CodexInstallOwner;
21
33
  codexHome?: string;
22
34
  };
package/dist/install.js CHANGED
@@ -26,11 +26,7 @@ function catalogFileName(ownerId) {
26
26
  }
27
27
  return `.${ownerId}-model-catalog.json`;
28
28
  }
29
- function profileSelector(profile) {
30
- return profile.profileId ?? profile.modelId;
31
- }
32
- function profileFileName(profile) {
33
- const selector = profileSelector(profile);
29
+ function profileFileName(selector) {
34
30
  if (selector.length === 0 ||
35
31
  selector.includes("/") ||
36
32
  selector.includes("\\") ||
@@ -39,6 +35,24 @@ function profileFileName(profile) {
39
35
  }
40
36
  return `${selector}.config.toml`;
41
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
+ }
42
56
  /** Serialize one additive, owner-marked Codex provider block. */
43
57
  export function codexIntegrationBlock(input) {
44
58
  const base = trimTrailingSlashes(input.gatewayUrl);
@@ -47,6 +61,7 @@ export function codexIntegrationBlock(input) {
47
61
  const filesComment = profileFilesComment(input.owner.id);
48
62
  const catalogComment = catalogFileComment(input.owner.id);
49
63
  const catalogFile = catalogFileName(input.owner.id);
64
+ const profileId = selectedProfileId(input);
50
65
  const body = tomlStringify({
51
66
  model_providers: {
52
67
  [input.owner.providerId]: {
@@ -63,9 +78,10 @@ export function codexIntegrationBlock(input) {
63
78
  `# Managed by \`${input.owner.installCommand}\`; do not edit between these markers.`,
64
79
  `# Rerun that command to update; use \`${input.owner.uninstallCommand}\` to remove.`,
65
80
  `# Start the gateway first: ${input.owner.startCommand}`,
66
- `# Then launch: codex --profile ${input.profiles[0] !== undefined ? profileSelector(input.profiles[0]) : "gateway-model"}`,
67
- ...input.profiles.map((profile) => `# codex --profile ${profileSelector(profile)}${profile.description !== undefined ? ` (${profile.description})` : ""}`),
68
- `${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)}`,
69
85
  `${catalogComment} ${catalogFile}`,
70
86
  "",
71
87
  body.trimEnd(),
@@ -134,14 +150,6 @@ function assertNoConflicts(outside, input) {
134
150
  throw new Error(`your Codex config already defines [model_providers.${input.owner.providerId}] outside the ` +
135
151
  `${input.owner.id}-managed block; remove or rename it, then rerun \`${input.owner.installCommand}\``);
136
152
  }
137
- const legacyProfiles = outside.profiles;
138
- for (const profile of input.profiles) {
139
- const selector = profileSelector(profile);
140
- if (isRecord(legacyProfiles) && legacyProfiles[selector] !== undefined) {
141
- throw new Error(`your Codex config already defines [profiles.${selector}] outside the ` +
142
- `${input.owner.id}-managed block; remove or rename it, then rerun \`${input.owner.installCommand}\``);
143
- }
144
- }
145
153
  }
146
154
  function normalize(content) {
147
155
  const trimmed = content.replace(/\s+$/, "");
@@ -159,9 +167,19 @@ function removeOwnedProfileFile(path, ownerId) {
159
167
  // Best-effort cleanup; an orphaned profile does not alter the main config.
160
168
  }
161
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
+ }
162
178
  export function installCodexIntegration(input) {
163
179
  if (input.profiles.length === 0)
164
- throw new Error("at least one Codex profile is required");
180
+ throw new Error("at least one Codex catalog model is required");
181
+ const defaultModel = selectedDefaultModel(input);
182
+ const profileId = selectedProfileId(input);
165
183
  const configPath = codexIntegrationConfigPath(input.codexHome);
166
184
  const codexHome = dirname(configPath);
167
185
  const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
@@ -182,25 +200,28 @@ export function installCodexIntegration(input) {
182
200
  if (!isRecord(providers) || providers[input.owner.providerId] === undefined) {
183
201
  throw new Error("internal error: the assembled Codex config lost its managed provider block");
184
202
  }
203
+ const persistentProfilePath = join(codexHome, profileFileName(profileId));
204
+ assertProfileFileCanBeManaged(persistentProfilePath, input.owner.id);
185
205
  mkdirSync(codexHome, { recursive: true });
186
- writeFileSync(catalogPath, codexPersistentModelCatalogJson(input.profiles.map((profile) => ({
206
+ writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.profiles, defaultModel).map((profile) => ({
187
207
  id: profile.modelId,
188
208
  ...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {})
189
209
  })), readCodexHomeModelsCache(codexHome)[0]), { mode: 0o600 });
190
- const nextFiles = new Set(input.profiles.map((profile) => join(codexHome, profileFileName(profile))));
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]);
191
216
  for (const stale of ownedProfileFiles(managed, codexHome, input.owner.id)) {
192
217
  if (!nextFiles.has(stale))
193
218
  removeOwnedProfileFile(stale, input.owner.id);
194
219
  }
195
- writeFileSync(configPath, next);
196
- for (const profile of input.profiles) {
197
- writeFileSync(join(codexHome, profileFileName(profile)), `# Managed by ${input.owner.id}\n${codexProfileFileToml(profile.modelId, input.owner.providerId, catalogPath)}`, { mode: 0o600 });
198
- }
199
220
  return {
200
221
  configPath,
201
222
  catalogPath,
202
223
  action: managed !== undefined ? "updated" : "installed",
203
- profiles: input.profiles.map(profileSelector)
224
+ profiles: [profileId]
204
225
  };
205
226
  }
206
227
  export function uninstallCodexIntegration(input) {
@@ -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,33 +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);
33
38
  assert.equal(existsSync(installed.catalogPath), true);
34
- assert.match(readFileSync(join(home, "opaque-primary.config.toml"), "utf8"), /model_catalog_json/);
35
- assert.deepEqual(JSON.parse(readFileSync(installed.catalogPath, "utf8")).models.map((model) => model.slug), ["opaque-primary", "opaque-secondary"]);
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"]);
36
42
  const updated = installCodexIntegration({
37
43
  gatewayUrl: "http://127.0.0.1:8888",
38
44
  owner: OWNER,
39
45
  profiles: [{ modelId: "opaque-primary" }],
46
+ defaultModel: "opaque-primary",
40
47
  codexHome: home
41
48
  });
42
49
  assert.equal(updated.action, "updated");
43
- 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"/);
44
52
  assert.equal(uninstallCodexIntegration({ ownerId: OWNER.id, codexHome: home }).removed, true);
45
53
  assert.equal(readFileSync(configPath, "utf8"), 'model = "user-default"\n');
46
- assert.equal(existsSync(join(home, "opaque-primary.config.toml")), false);
54
+ assert.equal(existsSync(join(home, "routekit.config.toml")), false);
47
55
  assert.equal(existsSync(installed.catalogPath), false);
48
56
  }
49
57
  finally {
50
58
  rmSync(home, { recursive: true, force: true });
51
59
  }
52
60
  });
53
- 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", () => {
54
62
  const home = mkdtempSync(join(tmpdir(), "routekit-codex-opaque-"));
55
63
  try {
56
64
  const result = installCodexIntegration({
@@ -67,3 +75,65 @@ test("Codex profiles can preserve an opaque model id behind a safe selector", ()
67
75
  rmSync(home, { recursive: true, force: true });
68
76
  }
69
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
+ });
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.17.0",
4
+ "version": "0.17.2",
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.17.0",
33
- "@velum-labs/routekit-harness-core": "0.17.0",
34
- "@velum-labs/routekit-registry": "0.17.0",
35
- "@velum-labs/routekit-runtime": "0.17.0",
36
- "@velum-labs/routekit-tools": "0.17.0"
32
+ "@velum-labs/routekit-contracts": "0.17.2",
33
+ "@velum-labs/routekit-harness-core": "0.17.2",
34
+ "@velum-labs/routekit-runtime": "0.17.2",
35
+ "@velum-labs/routekit-tools": "0.17.2",
36
+ "@velum-labs/routekit-registry": "0.17.2"
37
37
  },
38
38
  "keywords": [
39
39
  "llm",