@velum-labs/routekit-tool-codex 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { ToolIntegration } from "@velum-labs/routekit-tools";
2
+ export declare const codexTool: ToolIntegration;
3
+ export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
4
+ 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";
8
+ export type { CodexInstallInput, CodexInstallOwner, CodexInstallProfile, CodexInstallResult } from "./install.js";
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
2
+ import { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
3
+ import { codexLaunchConfigToml, launchCodex } from "./launch.js";
4
+ const driver = createCodexDriver();
5
+ export const codexTool = {
6
+ id: "codex",
7
+ displayName: "Codex",
8
+ pickerHint: "OpenAI Codex CLI",
9
+ binary: "codex",
10
+ packageName: "@velum-labs/routekit-tool-codex",
11
+ installHint: "install the Codex CLI: https://github.com/openai/codex",
12
+ authSummary: "Codex uses an ephemeral gateway-backed provider.",
13
+ setupSnippet: ({ gatewayUrl, model = "gateway-model" }) => codexLaunchConfigToml({
14
+ gatewayUrl,
15
+ defaultModel: model
16
+ }),
17
+ launch: launchCodex,
18
+ driver: {
19
+ kind: driver.kind,
20
+ driver,
21
+ configForRoute: (route) => codexDriverConfigSchema.parse({
22
+ model: route.model,
23
+ sandboxMode: "danger-full-access",
24
+ approvalPolicy: "never",
25
+ provider: {
26
+ baseUrl: `${trimTrailingSlashes(route.gatewayUrl)}/v1`,
27
+ ...(route.authToken !== undefined ? { apiKey: route.authToken } : {})
28
+ }
29
+ })
30
+ },
31
+ capabilities: {
32
+ streaming: "full",
33
+ tools: "full",
34
+ images: "degraded",
35
+ reasoning_controls: "full"
36
+ }
37
+ };
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";
@@ -0,0 +1,35 @@
1
+ export type CodexInstallProfile = {
2
+ modelId: string;
3
+ /** Safe Codex profile selector; defaults to `modelId`. */
4
+ profileId?: string;
5
+ description?: string;
6
+ };
7
+ export type CodexInstallOwner = {
8
+ id: string;
9
+ displayName: string;
10
+ providerId: string;
11
+ installCommand: string;
12
+ uninstallCommand: string;
13
+ startCommand: string;
14
+ };
15
+ export type CodexInstallInput = {
16
+ gatewayUrl: string;
17
+ profiles: readonly CodexInstallProfile[];
18
+ owner: CodexInstallOwner;
19
+ codexHome?: string;
20
+ };
21
+ export type CodexInstallResult = {
22
+ configPath: string;
23
+ action: "installed" | "updated";
24
+ profiles: string[];
25
+ };
26
+ /** Serialize one additive, owner-marked Codex provider block. */
27
+ export declare function codexIntegrationBlock(input: CodexInstallInput): string;
28
+ export declare function installCodexIntegration(input: CodexInstallInput): CodexInstallResult;
29
+ export declare function uninstallCodexIntegration(input: {
30
+ ownerId: string;
31
+ codexHome?: string;
32
+ }): {
33
+ configPath: string;
34
+ removed: boolean;
35
+ };
@@ -0,0 +1,185 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
5
+ import { SUBSCRIPTIONS } from "@velum-labs/routekit-registry";
6
+ import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
7
+ import { codexProfileFileToml } from "./launch.js";
8
+ function codexConfigPath(codexHome) {
9
+ if (codexHome !== undefined)
10
+ return join(codexHome, "config.toml");
11
+ const registryPath = SUBSCRIPTIONS.codex.configPath ?? "~/.codex/config.toml";
12
+ return registryPath.startsWith("~/") ? join(homedir(), registryPath.slice(2)) : registryPath;
13
+ }
14
+ function marker(ownerId, edge) {
15
+ return `# ${edge === "begin" ? ">>>" : "<<<"} ${ownerId} integration ${edge === "begin" ? ">>>" : "<<<"}`;
16
+ }
17
+ function profileFilesComment(ownerId) {
18
+ return `# ${ownerId}-profile-files:`;
19
+ }
20
+ function profileSelector(profile) {
21
+ return profile.profileId ?? profile.modelId;
22
+ }
23
+ function profileFileName(profile) {
24
+ const selector = profileSelector(profile);
25
+ if (selector.length === 0 ||
26
+ selector.includes("/") ||
27
+ selector.includes("\\") ||
28
+ selector.startsWith(".")) {
29
+ throw new Error(`Codex profile id is not a safe file name: ${JSON.stringify(selector)}`);
30
+ }
31
+ return `${selector}.config.toml`;
32
+ }
33
+ /** Serialize one additive, owner-marked Codex provider block. */
34
+ export function codexIntegrationBlock(input) {
35
+ const base = trimTrailingSlashes(input.gatewayUrl);
36
+ const begin = marker(input.owner.id, "begin");
37
+ const end = marker(input.owner.id, "end");
38
+ const filesComment = profileFilesComment(input.owner.id);
39
+ const body = tomlStringify({
40
+ model_providers: {
41
+ [input.owner.providerId]: {
42
+ name: `${input.owner.displayName} gateway`,
43
+ base_url: `${base}/v1`,
44
+ wire_api: "responses",
45
+ requires_openai_auth: false
46
+ }
47
+ }
48
+ });
49
+ return [
50
+ begin,
51
+ `# Managed by \`${input.owner.installCommand}\`; do not edit between these markers.`,
52
+ `# Rerun that command to update; use \`${input.owner.uninstallCommand}\` to remove.`,
53
+ `# 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(" ")}`,
57
+ "",
58
+ body.trimEnd(),
59
+ "",
60
+ end
61
+ ].join("\n");
62
+ }
63
+ function ownedProfileFiles(managed, codexHome, ownerId) {
64
+ if (managed === undefined)
65
+ return [];
66
+ const prefix = profileFilesComment(ownerId);
67
+ const line = managed.split("\n").find((entry) => entry.startsWith(prefix));
68
+ if (line === undefined)
69
+ return [];
70
+ return line
71
+ .slice(prefix.length)
72
+ .split(/\s+/)
73
+ .filter((name) => name.endsWith(".config.toml") && !name.includes("/") && !name.includes("\\"))
74
+ .map((name) => join(codexHome, name));
75
+ }
76
+ function splitManagedBlock(content, ownerId) {
77
+ const beginMarker = marker(ownerId, "begin");
78
+ const endMarker = marker(ownerId, "end");
79
+ const begin = content.indexOf(beginMarker);
80
+ if (begin === -1)
81
+ return { before: content, after: "" };
82
+ const end = content.indexOf(endMarker, begin);
83
+ if (end === -1) {
84
+ throw new Error(`found the ${ownerId} begin marker but no end marker in the Codex config; ` +
85
+ `remove the "${beginMarker}" line and its managed content, then retry`);
86
+ }
87
+ return {
88
+ before: content.slice(0, begin),
89
+ managed: content.slice(begin, end + endMarker.length),
90
+ after: content.slice(end + endMarker.length)
91
+ };
92
+ }
93
+ function isRecord(value) {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+ function parseTomlOrThrow(content, what) {
97
+ try {
98
+ return tomlParse(content);
99
+ }
100
+ catch (error) {
101
+ const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
102
+ throw new Error(`${what} is not valid TOML (${detail}); fix it, then rerun the command`);
103
+ }
104
+ }
105
+ function assertNoConflicts(outside, input) {
106
+ const providers = outside.model_providers;
107
+ if (isRecord(providers) && providers[input.owner.providerId] !== undefined) {
108
+ throw new Error(`your Codex config already defines [model_providers.${input.owner.providerId}] outside the ` +
109
+ `${input.owner.id}-managed block; remove or rename it, then rerun \`${input.owner.installCommand}\``);
110
+ }
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
+ }
120
+ function normalize(content) {
121
+ const trimmed = content.replace(/\s+$/, "");
122
+ return trimmed.length === 0 ? "" : `${trimmed}\n`;
123
+ }
124
+ function removeOwnedProfileFile(path, ownerId) {
125
+ try {
126
+ if (!existsSync(path))
127
+ return;
128
+ if (!readFileSync(path, "utf8").includes(`Managed by ${ownerId}`))
129
+ return;
130
+ rmSync(path);
131
+ }
132
+ catch {
133
+ // Best-effort cleanup; an orphaned profile does not alter the main config.
134
+ }
135
+ }
136
+ export function installCodexIntegration(input) {
137
+ if (input.profiles.length === 0)
138
+ throw new Error("at least one Codex profile is required");
139
+ const configPath = codexConfigPath(input.codexHome);
140
+ const codexHome = dirname(configPath);
141
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
142
+ const { before, managed, after } = splitManagedBlock(existing, input.owner.id);
143
+ const outside = parseTomlOrThrow(`${normalize(before)}\n${normalize(after)}`, `your Codex config (${configPath})`);
144
+ assertNoConflicts(outside, input);
145
+ const block = codexIntegrationBlock(input);
146
+ const head = normalize(before);
147
+ const tail = normalize(after);
148
+ const next = `${head}${head.length > 0 ? "\n" : ""}${block}\n${tail.length > 0 ? `\n${tail}` : ""}`;
149
+ const assembled = parseTomlOrThrow(next, "the updated Codex config");
150
+ const providers = assembled.model_providers;
151
+ if (!isRecord(providers) || providers[input.owner.providerId] === undefined) {
152
+ throw new Error("internal error: the assembled Codex config lost its managed provider block");
153
+ }
154
+ mkdirSync(codexHome, { recursive: true });
155
+ const nextFiles = new Set(input.profiles.map((profile) => join(codexHome, profileFileName(profile))));
156
+ for (const stale of ownedProfileFiles(managed, codexHome, input.owner.id)) {
157
+ if (!nextFiles.has(stale))
158
+ removeOwnedProfileFile(stale, input.owner.id);
159
+ }
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
+ return {
165
+ configPath,
166
+ action: managed !== undefined ? "updated" : "installed",
167
+ profiles: input.profiles.map(profileSelector)
168
+ };
169
+ }
170
+ export function uninstallCodexIntegration(input) {
171
+ const configPath = codexConfigPath(input.codexHome);
172
+ if (!existsSync(configPath))
173
+ return { configPath, removed: false };
174
+ const existing = readFileSync(configPath, "utf8");
175
+ const { before, managed, after } = splitManagedBlock(existing, input.ownerId);
176
+ if (managed === undefined)
177
+ return { configPath, removed: false };
178
+ for (const owned of ownedProfileFiles(managed, dirname(configPath), input.ownerId)) {
179
+ removeOwnedProfileFile(owned, input.ownerId);
180
+ }
181
+ const head = normalize(before);
182
+ const tail = normalize(after);
183
+ writeFileSync(configPath, `${head}${head.length > 0 && tail.length > 0 ? "\n" : ""}${tail}`);
184
+ return { configPath, removed: true };
185
+ }
@@ -0,0 +1,32 @@
1
+ import type { AgentProfile, ToolLaunchContext, ToolLaunchSpec } from "@velum-labs/routekit-tools";
2
+ export type CodexModelPreset = Record<string, unknown>;
3
+ export declare function isCodexConfigFailure(code: number, stderr: string): boolean;
4
+ export declare function tomlKey(name: string): string;
5
+ export declare function readCodexModelsCache(home?: string): CodexModelPreset[];
6
+ export declare function readCodexCatalogTemplate(home?: string): CodexModelPreset | undefined;
7
+ export declare function codexAuthPath(home?: string): string;
8
+ export declare function hasCodexLogin(home?: string): boolean;
9
+ /**
10
+ * Create an isolated Codex home outside the operating-system temp directory.
11
+ *
12
+ * Recent Codex releases refuse to install their process-scoped PATH helpers
13
+ * beneath `tmpdir()`. RouteKit still needs an isolated home so a gateway turn
14
+ * cannot read or mutate the user's real Codex configuration.
15
+ */
16
+ export declare function createIsolatedCodexHome(prefix: string, env?: Record<string, string | undefined>): string;
17
+ export declare function codexListedStockSlugs(home?: string): string[];
18
+ export declare function codexCatalogEntries(spec: Pick<ToolLaunchSpec, "defaultModel" | "models">, template: CodexModelPreset, stockModels?: readonly CodexModelPreset[], options?: {
19
+ appendUnlistedStock?: boolean;
20
+ }): Record<string, unknown>[];
21
+ export declare function codexModelCatalogJson(spec: Pick<ToolLaunchSpec, "defaultModel" | "models">, template: CodexModelPreset, stockModels?: readonly CodexModelPreset[], options?: {
22
+ appendUnlistedStock?: boolean;
23
+ }): string;
24
+ export declare function codexProfileFileToml(model: string, provider?: string): string;
25
+ export declare function codexProfileFiles(home: string, models: readonly string[], provider?: string): string[];
26
+ export type CodexAgentRole = AgentProfile & {
27
+ configPath: string;
28
+ };
29
+ export declare function codexAgentRoles(home: string, profiles: readonly AgentProfile[]): CodexAgentRole[];
30
+ export declare function codexAgentRoleToml(profile: AgentProfile): string;
31
+ 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>;
package/dist/launch.js ADDED
@@ -0,0 +1,302 @@
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";
6
+ import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
7
+ const PROVIDER_ID = "routekit";
8
+ const CATALOG_FILE = "model-catalog.json";
9
+ /** Model-agnostic agent prompt, matching the gateway's synthesized entries. */
10
+ const NEUTRAL_INSTRUCTIONS = "You are a coding agent.";
11
+ const PROFILE_DIR = "agent-profiles";
12
+ const CONFIG_FAILURE_PATTERNS = [
13
+ /config\.toml/i,
14
+ /model_catalog/i,
15
+ /duplicate agent role/i,
16
+ /error (?:reading|parsing|loading) config/i,
17
+ /invalid config/i,
18
+ /unknown field/i,
19
+ /missing field/i,
20
+ /agent role/i
21
+ ];
22
+ export function isCodexConfigFailure(code, stderr) {
23
+ return code !== 0 && CONFIG_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr));
24
+ }
25
+ export function tomlKey(name) {
26
+ return /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
27
+ }
28
+ function modelsCachePath(home) {
29
+ return join(home, ".codex", "models_cache.json");
30
+ }
31
+ export function readCodexModelsCache(home = homedir()) {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(modelsCachePath(home), "utf8"));
34
+ return Array.isArray(parsed.models)
35
+ ? parsed.models.filter((entry) => entry !== null && typeof entry === "object")
36
+ : [];
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ export function readCodexCatalogTemplate(home = homedir()) {
43
+ return readCodexModelsCache(home)[0];
44
+ }
45
+ export function codexAuthPath(home = homedir()) {
46
+ return join(home, ".codex", "auth.json");
47
+ }
48
+ export function hasCodexLogin(home = homedir()) {
49
+ return existsSync(codexAuthPath(home));
50
+ }
51
+ /**
52
+ * Create an isolated Codex home outside the operating-system temp directory.
53
+ *
54
+ * Recent Codex releases refuse to install their process-scoped PATH helpers
55
+ * beneath `tmpdir()`. RouteKit still needs an isolated home so a gateway turn
56
+ * cannot read or mutate the user's real Codex configuration.
57
+ */
58
+ export function createIsolatedCodexHome(prefix, env = process.env) {
59
+ const userHome = env.HOME ?? env.USERPROFILE ?? homedir();
60
+ const cacheRoot = env.XDG_CACHE_HOME ??
61
+ (process.platform === "win32" ? env.LOCALAPPDATA : undefined) ??
62
+ join(userHome, ".cache");
63
+ const parent = join(cacheRoot, "routekit", "codex");
64
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
65
+ return mkdtempSync(join(parent, prefix));
66
+ }
67
+ function presetSlug(entry) {
68
+ return typeof entry.slug === "string" && entry.slug.length > 0 ? entry.slug : undefined;
69
+ }
70
+ export function codexListedStockSlugs(home = homedir()) {
71
+ const seen = new Set();
72
+ return readCodexModelsCache(home).flatMap((entry) => {
73
+ const slug = presetSlug(entry);
74
+ if (slug === undefined || seen.has(slug))
75
+ return [];
76
+ seen.add(slug);
77
+ return [slug];
78
+ });
79
+ }
80
+ function codexModelId(modelId) {
81
+ return modelId.startsWith("codex/")
82
+ ? modelId.slice("codex/".length)
83
+ : modelId;
84
+ }
85
+ function catalogIds(spec) {
86
+ return [
87
+ ...new Set([spec.defaultModel, ...spec.models.flatMap((model) => [
88
+ model.id,
89
+ ...(model.aliases ?? [])
90
+ ])].map(codexModelId))
91
+ ];
92
+ }
93
+ /** True when a bare catalog id was projected from a `codex/`-namespaced model. */
94
+ function isCodexNativeId(spec, id) {
95
+ const namespaced = `codex/${id}`;
96
+ return (spec.defaultModel === namespaced ||
97
+ spec.models.some((model) => model.id === namespaced || model.aliases?.includes(namespaced) === true));
98
+ }
99
+ export function codexCatalogEntries(spec, template, stockModels = [], options = {}) {
100
+ const appendUnlistedStock = options.appendUnlistedStock ?? true;
101
+ const ids = catalogIds({ ...spec, gatewayUrl: "", args: [] });
102
+ const listed = new Set(ids);
103
+ const stockBySlug = new Map(stockModels.flatMap((entry) => {
104
+ const slug = presetSlug(entry);
105
+ return slug === undefined ? [] : [[slug, entry]];
106
+ }));
107
+ // The template (a stock Codex model entry) only exists to satisfy the
108
+ // catalog schema of the installed Codex version. Fields that change how
109
+ // Codex talks to the model must not leak from an unrelated stock model into
110
+ // gateway-routed entries: reasoning tiers are replaced by each model's
111
+ // discovered capabilities; `tool_mode` (e.g. "code_mode_only") and
112
+ // `use_responses_lite` alter (or drop entirely) the tool declarations Codex
113
+ // sends; service tiers are a stock-model billing offer; and
114
+ // `base_instructions` / `model_messages` become the developer message, so a
115
+ // stock prompt ("You are Codex, an agent based on GPT-5...") would tell
116
+ // every routed model it is GPT-5. Fields are reset to neutral values only
117
+ // when the template carries them, so the output still matches the installed
118
+ // Codex version's required fields.
119
+ const { supported_reasoning_levels: _templateLevels, default_reasoning_level: _templateDefault, supports_reasoning_summaries: _templateSummaries, tool_mode: _templateToolMode, default_service_tier: _templateServiceTier, ...neutralTemplate } = template;
120
+ for (const [field, neutral] of [
121
+ ["use_responses_lite", false],
122
+ ["additional_speed_tiers", []],
123
+ ["service_tiers", []],
124
+ ["base_instructions", NEUTRAL_INSTRUCTIONS]
125
+ ]) {
126
+ if (field in neutralTemplate)
127
+ neutralTemplate[field] = neutral;
128
+ }
129
+ if (typeof neutralTemplate.model_messages === "object" &&
130
+ neutralTemplate.model_messages !== null) {
131
+ neutralTemplate.model_messages = {
132
+ ...neutralTemplate.model_messages,
133
+ instructions_template: NEUTRAL_INSTRUCTIONS
134
+ };
135
+ }
136
+ const entries = ids.map((id, priority) => {
137
+ // A Codex-native model whose real ModelInfo is in the stock cache keeps
138
+ // it verbatim (its tuned prompt, reasoning tiers, tool mode) — through
139
+ // the gateway it still reaches the real Codex backend, so the stock
140
+ // behavior is the correct behavior. This mirrors the gateway's own
141
+ // picker merge. Only the transport hint is pinned to the gateway's HTTP.
142
+ const stock = stockBySlug.get(id);
143
+ if (stock !== undefined && isCodexNativeId(spec, id)) {
144
+ return { ...stock, slug: id, visibility: "list", priority, prefer_websockets: false };
145
+ }
146
+ const model = spec.models.find((candidate) => codexModelId(candidate.id) === id ||
147
+ candidate.aliases?.some((alias) => codexModelId(alias) === id) === true);
148
+ const levels = (model?.reasoning?.efforts ?? []).map((effort) => ({
149
+ effort: effort.id,
150
+ description: effort.description ?? effort.label ?? effort.id
151
+ }));
152
+ return {
153
+ ...neutralTemplate,
154
+ prefer_websockets: false,
155
+ slug: id,
156
+ display_name: model?.label ?? id,
157
+ description: "Gateway-routed model.",
158
+ visibility: "list",
159
+ priority,
160
+ availability_nux: null,
161
+ upgrade: null,
162
+ // Codex requires this field on every catalog entry; an empty list means
163
+ // "no discovered effort controls" without fabricating tiers.
164
+ supported_reasoning_levels: levels,
165
+ ...(model?.reasoning?.defaultEffort !== undefined
166
+ ? { default_reasoning_level: model.reasoning.defaultEffort }
167
+ : {}),
168
+ supports_reasoning_summaries: model?.reasoning?.status === "supported"
169
+ };
170
+ });
171
+ if (appendUnlistedStock) {
172
+ for (const stock of stockModels) {
173
+ const slug = presetSlug(stock);
174
+ if (slug === undefined || listed.has(slug))
175
+ continue;
176
+ listed.add(slug);
177
+ entries.push({ ...stock, priority: entries.length });
178
+ }
179
+ }
180
+ return entries;
181
+ }
182
+ export function codexModelCatalogJson(spec, template, stockModels = [], options = {}) {
183
+ return JSON.stringify({ models: codexCatalogEntries(spec, template, stockModels, options) }, null, 2);
184
+ }
185
+ export function codexProfileFileToml(model, provider = PROVIDER_ID) {
186
+ return `${tomlStringify({ model, model_provider: provider }).trimEnd()}\n`;
187
+ }
188
+ export function codexProfileFiles(home, models, provider = PROVIDER_ID) {
189
+ const written = [];
190
+ for (const model of models) {
191
+ if (model.length === 0 ||
192
+ model.includes("/") ||
193
+ model.includes("\\") ||
194
+ model.startsWith(".") ||
195
+ written.includes(model)) {
196
+ continue;
197
+ }
198
+ writeFileSync(join(home, `${model}.config.toml`), codexProfileFileToml(model, provider));
199
+ written.push(model);
200
+ }
201
+ return written;
202
+ }
203
+ export function codexAgentRoles(home, profiles) {
204
+ return profiles.map((profile) => ({
205
+ ...profile,
206
+ configPath: join(home, PROFILE_DIR, `${profile.id}.toml`)
207
+ }));
208
+ }
209
+ export function codexAgentRoleToml(profile) {
210
+ return [
211
+ `name = ${JSON.stringify(profile.id)}`,
212
+ `model = ${JSON.stringify(codexModelId(profile.model))}`,
213
+ `model_provider = ${JSON.stringify(PROVIDER_ID)}`,
214
+ `developer_instructions = ${JSON.stringify(profile.instructions)}`,
215
+ ""
216
+ ].join("\n");
217
+ }
218
+ export function codexLaunchConfigToml(spec, modelCatalogPath, roles = []) {
219
+ const lines = [
220
+ `model = ${JSON.stringify(codexModelId(spec.defaultModel))}`,
221
+ `model_provider = ${JSON.stringify(PROVIDER_ID)}`
222
+ ];
223
+ if (spec.reasoning?.mode === "effort") {
224
+ lines.push(`model_reasoning_effort = ${JSON.stringify(spec.reasoning.effort)}`);
225
+ }
226
+ if (modelCatalogPath !== undefined) {
227
+ lines.push(`model_catalog_json = ${JSON.stringify(modelCatalogPath)}`);
228
+ }
229
+ 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
230
+ ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`]
231
+ : []), "");
232
+ if (roles.length > 0) {
233
+ lines.push("[features]", "multi_agent = true", "", "[agents]", "max_depth = 1", "");
234
+ for (const role of roles) {
235
+ lines.push(`[agents.${tomlKey(role.id)}]`, `description = ${JSON.stringify(role.description)}`, `config_file = ${JSON.stringify(role.configPath)}`, "");
236
+ }
237
+ }
238
+ return lines.join("\n");
239
+ }
240
+ function spawnCodex(args, home, cwd, token) {
241
+ return new Promise((resolve, reject) => {
242
+ const child = spawn("codex", args, {
243
+ stdio: ["inherit", "inherit", "pipe"],
244
+ // env-spread-allowed: interactive user tool inherits the user's shell configuration
245
+ env: {
246
+ ...process.env,
247
+ CODEX_HOME: home,
248
+ ...(token !== undefined ? { ROUTEKIT_GATEWAY_TOKEN: token } : {})
249
+ },
250
+ ...(cwd !== undefined ? { cwd } : {})
251
+ });
252
+ let stderr = "";
253
+ child.stderr?.on("data", (chunk) => {
254
+ process.stderr.write(chunk);
255
+ stderr = (stderr + chunk.toString("utf8")).slice(-8192);
256
+ });
257
+ child.on("error", reject);
258
+ child.on("exit", (code) => resolve({ code: code ?? 0, stderr }));
259
+ });
260
+ }
261
+ export async function launchCodex(ctx) {
262
+ const { spec } = ctx;
263
+ const home = createIsolatedCodexHome("routekit-codex-");
264
+ ctx.registerDisposer(() => rmSync(home, { recursive: true, force: true }));
265
+ if (hasCodexLogin() && spec.auth?.token === undefined) {
266
+ copyFileSync(codexAuthPath(), join(home, "auth.json"));
267
+ }
268
+ const ids = [...catalogIds(spec), ...codexListedStockSlugs()];
269
+ codexProfileFiles(home, ids);
270
+ const template = readCodexCatalogTemplate();
271
+ const catalogPath = template === undefined ? undefined : join(home, CATALOG_FILE);
272
+ if (catalogPath !== undefined && template !== undefined) {
273
+ // The stock cache supplies verbatim ModelInfo for gateway models that are
274
+ // Codex-native. Unlisted stock models are not appended: without a codex
275
+ // route in the gateway catalog they would not resolve.
276
+ writeFileSync(catalogPath, codexModelCatalogJson(spec, template, readCodexModelsCache(), {
277
+ appendUnlistedStock: false
278
+ }));
279
+ }
280
+ const roles = codexAgentRoles(home, spec.agentProfiles ?? []);
281
+ if (roles.length > 0) {
282
+ mkdirSync(join(home, PROFILE_DIR), { recursive: true });
283
+ for (const role of roles)
284
+ writeFileSync(role.configPath, codexAgentRoleToml(role));
285
+ }
286
+ const configPath = join(home, "config.toml");
287
+ const writeConfig = (catalog, activeRoles) => {
288
+ writeFileSync(configPath, codexLaunchConfigToml(spec, catalog, activeRoles));
289
+ };
290
+ writeConfig(catalogPath, roles);
291
+ ctx.prepareForPassthrough();
292
+ let result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
293
+ if (catalogPath !== undefined && isCodexConfigFailure(result.code, result.stderr)) {
294
+ writeConfig(undefined, roles);
295
+ result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
296
+ }
297
+ if (roles.length > 0 && isCodexConfigFailure(result.code, result.stderr)) {
298
+ writeConfig(undefined, []);
299
+ result = await spawnCodex(spec.args, home, spec.cwd, spec.auth?.token);
300
+ }
301
+ return result.code;
302
+ }
@@ -0,0 +1 @@
1
+ export {};