@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 +7 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/install.d.ts +17 -1
- package/dist/install.js +87 -27
- package/dist/launch.d.ts +23 -2
- package/dist/launch.js +170 -71
- package/dist/test/install.test.js +79 -5
- package/dist/test/launch.test.js +43 -158
- package/package.json +6 -6
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 {
|
|
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
|
|
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 {
|
|
40
|
-
export {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
21
|
-
return
|
|
20
|
+
function catalogFileComment(ownerId) {
|
|
21
|
+
return `# ${ownerId}-catalog-file:`;
|
|
22
22
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
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 ${
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
139
|
-
const
|
|
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
|
-
|
|
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:
|
|
224
|
+
profiles: [profileId]
|
|
168
225
|
};
|
|
169
226
|
}
|
|
170
227
|
export function uninstallCodexIntegration(input) {
|
|
171
|
-
const configPath =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
32
|
+
function readCodexModelsCachePath(path) {
|
|
33
33
|
try {
|
|
34
|
-
const parsed = JSON.parse(readFileSync(
|
|
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([
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
const home =
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
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
|
-
|
|
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(
|
|
399
|
+
const roles = codexAgentRoles(temp, ctx.spec.agentProfiles ?? []);
|
|
288
400
|
if (roles.length > 0) {
|
|
289
|
-
mkdirSync(join(
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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, "
|
|
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
|
|
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
|
+
});
|
package/dist/test/launch.test.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
186
|
-
const
|
|
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(
|
|
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
|
|
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", [
|
|
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
|
-
|
|
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
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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.
|
|
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.
|
|
33
|
-
"@velum-labs/routekit-harness-core": "0.
|
|
34
|
-
"@velum-labs/routekit-registry": "0.
|
|
35
|
-
"@velum-labs/routekit-runtime": "0.
|
|
36
|
-
"@velum-labs/routekit-tools": "0.
|
|
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",
|