@velum-labs/routekit-tool-codex 0.18.6 → 1.0.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.
- package/dist/driver.d.ts +1 -1
- package/dist/driver.js +58 -52
- package/dist/index.js +2 -2
- package/dist/install.d.ts +2 -11
- package/dist/install.js +12 -17
- package/dist/launch.js +7 -10
- package/dist/test/driver.test.js +76 -2
- package/dist/test/install.test.js +6 -49
- package/package.json +19 -7
package/dist/driver.d.ts
CHANGED
package/dist/driver.js
CHANGED
|
@@ -1,33 +1,11 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
|
-
import { z } from "zod";
|
|
3
2
|
import { Codex } from "@openai/codex-sdk";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { asHarnessError, buildChildEnv, createCachedHarnessDriver, HarnessError, nowIso, probeCliVersion, resolveDriverEnv, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "@velum-labs/routekit-harness-core";
|
|
4
|
+
import { ResourceScope } from "@velum-labs/routekit-runtime/lifecycle";
|
|
5
|
+
import { z } from "zod";
|
|
6
6
|
import { createIsolatedCodexHome } from "./launch.js";
|
|
7
7
|
const RESUME_CURSOR_VERSION = 1;
|
|
8
8
|
const DEFAULT_COMMAND = "codex";
|
|
9
|
-
/**
|
|
10
|
-
* Gateway-routed sessions run in an isolated `CODEX_HOME`: the user's own
|
|
11
|
-
* `~/.codex/config.toml` (model, reasoning effort, MCP servers, profiles) must
|
|
12
|
-
* not leak into requests routed through the gateway, and codex must not
|
|
13
|
-
* overwrite the user's real models cache with gateway catalog entries. The
|
|
14
|
-
* home is shared per process (not per instance) because codex thread rollouts
|
|
15
|
-
* live inside it and resume cursors must survive across panel turns, each of
|
|
16
|
-
* which builds a fresh driver instance.
|
|
17
|
-
*/
|
|
18
|
-
let sharedIsolatedHome;
|
|
19
|
-
function isolatedCodexHome(env) {
|
|
20
|
-
if (sharedIsolatedHome === undefined) {
|
|
21
|
-
const home = createIsolatedCodexHome("routekit-codex-driver-", env);
|
|
22
|
-
sharedIsolatedHome = home;
|
|
23
|
-
registerCleanup(() => {
|
|
24
|
-
rmSync(home, { recursive: true, force: true });
|
|
25
|
-
if (sharedIsolatedHome === home)
|
|
26
|
-
sharedIsolatedHome = undefined;
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
return sharedIsolatedHome;
|
|
30
|
-
}
|
|
31
9
|
const providerSchema = z.object({
|
|
32
10
|
/** OpenAI-compatible base URL the codex model calls go to (e.g. the gateway). */
|
|
33
11
|
baseUrl: z.string().optional(),
|
|
@@ -42,16 +20,11 @@ export const codexDriverConfigSchema = z.object({
|
|
|
42
20
|
sandboxMode: z
|
|
43
21
|
.enum(["read-only", "workspace-write", "danger-full-access"])
|
|
44
22
|
.default("workspace-write"),
|
|
45
|
-
approvalPolicy: z
|
|
46
|
-
.enum(["never", "on-request", "on-failure", "untrusted"])
|
|
47
|
-
.default("never"),
|
|
23
|
+
approvalPolicy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
|
|
48
24
|
provider: providerSchema.default({}),
|
|
49
25
|
/** Extra credential env var names forwarded into the codex child. */
|
|
50
26
|
credentialEnvNames: z.array(z.string()).default([])
|
|
51
27
|
});
|
|
52
|
-
function nowIso() {
|
|
53
|
-
return new Date().toISOString();
|
|
54
|
-
}
|
|
55
28
|
function itemTypeFor(item) {
|
|
56
29
|
switch (item.type) {
|
|
57
30
|
case "agent_message":
|
|
@@ -133,6 +106,8 @@ class CodexSession {
|
|
|
133
106
|
#thread;
|
|
134
107
|
#kind = "codex";
|
|
135
108
|
#reasoning;
|
|
109
|
+
#turns = new SingleFlightTurnController();
|
|
110
|
+
#stopped = false;
|
|
136
111
|
constructor(thread, resumedThreadId, reasoning) {
|
|
137
112
|
this.#thread = thread;
|
|
138
113
|
// Codex assigns the real thread id on the first turn; until then we track
|
|
@@ -144,19 +119,25 @@ class CodexSession {
|
|
|
144
119
|
return this.#sessionId;
|
|
145
120
|
}
|
|
146
121
|
async *sendTurn(input) {
|
|
122
|
+
if (this.#stopped)
|
|
123
|
+
throw new HarnessError("session_closed", "codex session is stopped");
|
|
147
124
|
if (input.reasoning !== undefined &&
|
|
148
125
|
JSON.stringify(input.reasoning) !== JSON.stringify(this.#reasoning)) {
|
|
149
126
|
throw new HarnessError("invalid_config", "Codex SDK reasoning must be selected before the session starts");
|
|
150
127
|
}
|
|
128
|
+
const turn = this.#turns.start(input.signal);
|
|
151
129
|
const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso() };
|
|
152
130
|
let turnId;
|
|
153
131
|
let streamed;
|
|
154
132
|
try {
|
|
155
|
-
streamed = await this.#thread.runStreamed(input.prompt, {
|
|
156
|
-
...(input.signal !== undefined ? { signal: input.signal } : {})
|
|
157
|
-
});
|
|
133
|
+
streamed = await this.#thread.runStreamed(input.prompt, { signal: turn.signal });
|
|
158
134
|
}
|
|
159
135
|
catch (error) {
|
|
136
|
+
turn.dispose();
|
|
137
|
+
if (turn.signal.aborted) {
|
|
138
|
+
yield { ...base, type: "turn.completed", endReason: "aborted" };
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
160
141
|
throw asHarnessError(error);
|
|
161
142
|
}
|
|
162
143
|
try {
|
|
@@ -168,7 +149,7 @@ class CodexSession {
|
|
|
168
149
|
}
|
|
169
150
|
}
|
|
170
151
|
catch (error) {
|
|
171
|
-
if (
|
|
152
|
+
if (turn.signal.aborted) {
|
|
172
153
|
yield {
|
|
173
154
|
...base,
|
|
174
155
|
type: "turn.completed",
|
|
@@ -186,6 +167,14 @@ class CodexSession {
|
|
|
186
167
|
message: harnessError.message
|
|
187
168
|
};
|
|
188
169
|
}
|
|
170
|
+
finally {
|
|
171
|
+
// Returning this outer iterator also returns the SDK's event generator;
|
|
172
|
+
// its finally block kills the child. Avoid aborting after the SDK has
|
|
173
|
+
// removed the child's error listener (which would surface AbortError as
|
|
174
|
+
// an uncaught process error).
|
|
175
|
+
turn.complete();
|
|
176
|
+
turn.dispose();
|
|
177
|
+
}
|
|
189
178
|
}
|
|
190
179
|
*#mapEvent(event, turnId) {
|
|
191
180
|
const raw = { source: "codex.exec.json", method: event.type };
|
|
@@ -326,60 +315,78 @@ class CodexSession {
|
|
|
326
315
|
throw new HarnessError("protocol_parse", "codex exec does not surface interactive approval requests");
|
|
327
316
|
}
|
|
328
317
|
async interrupt() {
|
|
329
|
-
|
|
330
|
-
// codex-sdk kills the child on abort. Nothing extra to do here.
|
|
318
|
+
this.#turns.interrupt();
|
|
331
319
|
}
|
|
332
320
|
resumeCursor() {
|
|
333
321
|
if (this.#sessionId === "codex:pending")
|
|
334
322
|
return undefined;
|
|
335
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
version: RESUME_CURSOR_VERSION,
|
|
325
|
+
kind: this.#kind,
|
|
326
|
+
data: { threadId: this.#sessionId }
|
|
327
|
+
};
|
|
336
328
|
}
|
|
337
329
|
async stop() {
|
|
338
|
-
|
|
339
|
-
|
|
330
|
+
if (this.#stopped)
|
|
331
|
+
return;
|
|
332
|
+
this.#stopped = true;
|
|
333
|
+
this.#turns.interrupt(new Error("codex session stopped"));
|
|
340
334
|
}
|
|
341
335
|
}
|
|
342
336
|
function resumeThreadId(resume) {
|
|
343
|
-
|
|
344
|
-
return undefined;
|
|
345
|
-
const data = resume.data;
|
|
346
|
-
return typeof data.threadId === "string" ? data.threadId : undefined;
|
|
337
|
+
return resumeStringField(resume, "codex", "threadId");
|
|
347
338
|
}
|
|
348
339
|
class CodexInstance {
|
|
349
340
|
kind = "codex";
|
|
350
341
|
#config;
|
|
351
342
|
#context;
|
|
352
343
|
#status;
|
|
344
|
+
#sessions = new SessionResourceRegistry();
|
|
345
|
+
#resources = new ResourceScope();
|
|
346
|
+
#isolatedHome;
|
|
353
347
|
constructor(config, context, status) {
|
|
354
348
|
this.#config = config;
|
|
355
349
|
this.#context = context;
|
|
356
350
|
this.#status = status;
|
|
351
|
+
this.#isolatedHome = this.#createOwnedHome();
|
|
352
|
+
// Registered after the home so sessions stop before their rollout files
|
|
353
|
+
// are removed during LIFO disposal.
|
|
354
|
+
this.#resources.own(this.#sessions, {
|
|
355
|
+
finalize: async (sessions) => await sessions.dispose()
|
|
356
|
+
});
|
|
357
357
|
}
|
|
358
358
|
status() {
|
|
359
359
|
return this.#status;
|
|
360
360
|
}
|
|
361
|
-
/**
|
|
362
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Gateway-routed sessions use an instance-owned `CODEX_HOME`, preventing the
|
|
363
|
+
* user's config and model cache from leaking into the routed session. An
|
|
364
|
+
* explicitly supplied home remains borrowed and is never removed here.
|
|
365
|
+
*/
|
|
366
|
+
#createOwnedHome() {
|
|
363
367
|
if (this.#config.provider.baseUrl === undefined)
|
|
364
368
|
return undefined;
|
|
365
369
|
const env = resolveDriverEnv(this.#context);
|
|
366
370
|
if (env.CODEX_HOME !== undefined)
|
|
367
371
|
return undefined;
|
|
368
|
-
|
|
372
|
+
const home = createIsolatedCodexHome("routekit-codex-driver-", env);
|
|
373
|
+
this.#resources.defer(() => rmSync(home, { recursive: true, force: true }));
|
|
374
|
+
return home;
|
|
369
375
|
}
|
|
370
376
|
async startSession(options) {
|
|
377
|
+
this.#sessions.assertOpen();
|
|
371
378
|
if (options.reasoning !== undefined &&
|
|
372
379
|
options.reasoning.mode !== "auto" &&
|
|
373
380
|
options.reasoning.mode !== "effort") {
|
|
374
381
|
throw new HarnessError("invalid_config", `Codex SDK cannot represent reasoning mode "${options.reasoning.mode}"`);
|
|
375
382
|
}
|
|
376
|
-
const codex = new Codex(codexOptionsFor(this.#config, this.#context, this.#
|
|
383
|
+
const codex = new Codex(codexOptionsFor(this.#config, this.#context, this.#isolatedHome));
|
|
377
384
|
const threadOptions = {
|
|
378
385
|
sandboxMode: this.#config.sandboxMode,
|
|
379
386
|
approvalPolicy: this.#config.approvalPolicy,
|
|
380
387
|
workingDirectory: options.cwd,
|
|
381
388
|
skipGitRepoCheck: true,
|
|
382
|
-
...(options.model ?? this.#config.model !== undefined
|
|
389
|
+
...((options.model ?? this.#config.model !== undefined)
|
|
383
390
|
? { model: options.model ?? this.#config.model }
|
|
384
391
|
: {}),
|
|
385
392
|
...(options.reasoning?.mode === "effort"
|
|
@@ -392,11 +399,10 @@ class CodexInstance {
|
|
|
392
399
|
const thread = resumedId !== undefined
|
|
393
400
|
? codex.resumeThread(resumedId, threadOptions)
|
|
394
401
|
: codex.startThread(threadOptions);
|
|
395
|
-
return new CodexSession(thread, resumedId, options.reasoning);
|
|
402
|
+
return this.#sessions.manage(new CodexSession(thread, resumedId, options.reasoning));
|
|
396
403
|
}
|
|
397
404
|
async dispose() {
|
|
398
|
-
|
|
399
|
-
// home outlives the instance so resumable thread rollouts stay available.
|
|
405
|
+
await this.#resources.dispose();
|
|
400
406
|
}
|
|
401
407
|
}
|
|
402
408
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
|
|
2
2
|
import { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
|
|
3
3
|
import { codexLaunchConfigToml, launchCodex } from "./launch.js";
|
|
4
4
|
const driver = createCodexDriver();
|
|
@@ -23,7 +23,7 @@ export const codexTool = {
|
|
|
23
23
|
sandboxMode: "danger-full-access",
|
|
24
24
|
approvalPolicy: "never",
|
|
25
25
|
provider: {
|
|
26
|
-
baseUrl:
|
|
26
|
+
baseUrl: gatewayOpenAiBaseUrl(route.gatewayUrl),
|
|
27
27
|
...(route.authToken !== undefined ? { apiKey: route.authToken } : {})
|
|
28
28
|
}
|
|
29
29
|
})
|
package/dist/install.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
|
|
2
2
|
export type CodexInstallProfile = {
|
|
3
3
|
modelId: string;
|
|
4
|
-
/**
|
|
5
|
-
* Legacy selector for the one persistent profile. New callers should use
|
|
6
|
-
* `CodexInstallInput.profileId`; when both are absent it is `routekit`.
|
|
7
|
-
*/
|
|
8
|
-
profileId?: string;
|
|
9
4
|
description?: string;
|
|
10
5
|
reasoning?: ModelReasoningCapabilities;
|
|
11
6
|
};
|
|
@@ -19,12 +14,8 @@ export type CodexInstallOwner = {
|
|
|
19
14
|
};
|
|
20
15
|
export type CodexInstallInput = {
|
|
21
16
|
gatewayUrl: string;
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
* remains named `profiles` for API compatibility, although persistent
|
|
25
|
-
* installs now write one RouteKit profile rather than one file per model.
|
|
26
|
-
*/
|
|
27
|
-
profiles: readonly CodexInstallProfile[];
|
|
17
|
+
/** RouteKit models made available through Codex's model picker. */
|
|
18
|
+
models: readonly CodexInstallProfile[];
|
|
28
19
|
/** Model selected when the single RouteKit profile is first opened. */
|
|
29
20
|
defaultModel?: string;
|
|
30
21
|
/** Safe selector for the one persistent RouteKit profile. */
|
package/dist/install.js
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
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
|
-
import {
|
|
6
|
+
import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
|
|
7
7
|
import { codexPersistentModelCatalogJson, codexProfileFileToml, readCodexHomeModelsCache } from "./launch.js";
|
|
8
8
|
export function codexIntegrationConfigPath(codexHome) {
|
|
9
9
|
if (codexHome !== undefined)
|
|
@@ -36,13 +36,13 @@ function profileFileName(selector) {
|
|
|
36
36
|
return `${selector}.config.toml`;
|
|
37
37
|
}
|
|
38
38
|
function selectedProfileId(input) {
|
|
39
|
-
return input.profileId ??
|
|
39
|
+
return input.profileId ?? "routekit";
|
|
40
40
|
}
|
|
41
41
|
function selectedDefaultModel(input) {
|
|
42
|
-
const model = input.defaultModel ?? input.
|
|
42
|
+
const model = input.defaultModel ?? input.models[0]?.modelId;
|
|
43
43
|
if (model === undefined)
|
|
44
44
|
throw new Error("at least one Codex catalog model is required");
|
|
45
|
-
if (!input.
|
|
45
|
+
if (!input.models.some((profile) => profile.modelId === model)) {
|
|
46
46
|
throw new Error(`the Codex default model ${JSON.stringify(model)} is not in the RouteKit catalog`);
|
|
47
47
|
}
|
|
48
48
|
return model;
|
|
@@ -55,7 +55,7 @@ function orderedCatalogProfiles(profiles, defaultModel) {
|
|
|
55
55
|
}
|
|
56
56
|
/** Serialize one additive, owner-marked Codex provider block. */
|
|
57
57
|
export function codexIntegrationBlock(input) {
|
|
58
|
-
const base =
|
|
58
|
+
const base = gatewayOpenAiBaseUrl(input.gatewayUrl);
|
|
59
59
|
const begin = marker(input.owner.id, "begin");
|
|
60
60
|
const end = marker(input.owner.id, "end");
|
|
61
61
|
const filesComment = profileFilesComment(input.owner.id);
|
|
@@ -66,7 +66,7 @@ export function codexIntegrationBlock(input) {
|
|
|
66
66
|
model_providers: {
|
|
67
67
|
[input.owner.providerId]: {
|
|
68
68
|
name: `${input.owner.displayName} gateway`,
|
|
69
|
-
base_url:
|
|
69
|
+
base_url: base,
|
|
70
70
|
wire_api: "responses",
|
|
71
71
|
...(input.auth !== undefined
|
|
72
72
|
? {
|
|
@@ -119,7 +119,10 @@ function ownedCatalogFile(managed, codexHome, ownerId) {
|
|
|
119
119
|
if (line === undefined)
|
|
120
120
|
return undefined;
|
|
121
121
|
const file = line.slice(prefix.length).trim();
|
|
122
|
-
if (file.length === 0 ||
|
|
122
|
+
if (file.length === 0 ||
|
|
123
|
+
file.includes("/") ||
|
|
124
|
+
file.includes("\\") ||
|
|
125
|
+
file !== catalogFileName(ownerId)) {
|
|
123
126
|
return undefined;
|
|
124
127
|
}
|
|
125
128
|
return join(codexHome, file);
|
|
@@ -185,7 +188,7 @@ function assertProfileFileCanBeManaged(path, ownerId) {
|
|
|
185
188
|
`rename it, then rerun the RouteKit install`);
|
|
186
189
|
}
|
|
187
190
|
export function installCodexIntegration(input) {
|
|
188
|
-
if (input.
|
|
191
|
+
if (input.models.length === 0)
|
|
189
192
|
throw new Error("at least one Codex catalog model is required");
|
|
190
193
|
const defaultModel = selectedDefaultModel(input);
|
|
191
194
|
const profileId = selectedProfileId(input);
|
|
@@ -212,20 +215,12 @@ export function installCodexIntegration(input) {
|
|
|
212
215
|
const persistentProfilePath = join(codexHome, profileFileName(profileId));
|
|
213
216
|
assertProfileFileCanBeManaged(persistentProfilePath, input.owner.id);
|
|
214
217
|
mkdirSync(codexHome, { recursive: true });
|
|
215
|
-
writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.
|
|
218
|
+
writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.models, defaultModel).map((profile) => ({
|
|
216
219
|
id: profile.modelId,
|
|
217
220
|
...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {})
|
|
218
221
|
})), readCodexHomeModelsCache(codexHome)[0]), { mode: 0o600 });
|
|
219
222
|
writeFileSync(persistentProfilePath, `# Managed by ${input.owner.id}\n${codexProfileFileToml(defaultModel, input.owner.providerId, catalogPath)}`, { mode: 0o600 });
|
|
220
|
-
// Write the new profile before replacing the managed block, then clean up
|
|
221
|
-
// legacy per-model files last. An interrupted migration therefore leaves a
|
|
222
|
-
// working old or new profile, never a config that points at a missing file.
|
|
223
223
|
writeFileSync(configPath, next);
|
|
224
|
-
const nextFiles = new Set([persistentProfilePath]);
|
|
225
|
-
for (const stale of ownedProfileFiles(managed, codexHome, input.owner.id)) {
|
|
226
|
-
if (!nextFiles.has(stale))
|
|
227
|
-
removeOwnedProfileFile(stale, input.owner.id);
|
|
228
|
-
}
|
|
229
224
|
return {
|
|
230
225
|
configPath,
|
|
231
226
|
catalogPath,
|
package/dist/launch.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { isAbsolute, join } from "node:path";
|
|
5
5
|
import { codexCompatibility, isCodexPickerEligibleModel, reasoningEffortDescriptors } from "@velum-labs/routekit-contracts";
|
|
6
|
-
import {
|
|
6
|
+
import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
|
|
7
7
|
import { stringify as tomlStringify } from "smol-toml";
|
|
8
8
|
const PROVIDER_ID = "routekit";
|
|
9
9
|
const CATALOG_FILE = "model-catalog.json";
|
|
@@ -89,14 +89,13 @@ function codexModelId(modelId) {
|
|
|
89
89
|
}
|
|
90
90
|
function catalogModels(spec) {
|
|
91
91
|
return spec.models.filter((model) => {
|
|
92
|
-
const isDefault = model.id === spec.defaultModel ||
|
|
93
|
-
model.aliases?.includes(spec.defaultModel) === true;
|
|
92
|
+
const isDefault = model.id === spec.defaultModel || model.aliases?.includes(spec.defaultModel) === true;
|
|
94
93
|
if (spec.modelSelection === undefined) {
|
|
95
94
|
return isDefault || isCodexPickerEligibleModel(model);
|
|
96
95
|
}
|
|
97
96
|
if (isDefault && spec.modelSelection !== "implicit")
|
|
98
97
|
return true;
|
|
99
|
-
return codexCompatibility({
|
|
98
|
+
return (codexCompatibility({
|
|
100
99
|
id: model.id,
|
|
101
100
|
...(model.provider !== undefined ? { provider: model.provider } : {}),
|
|
102
101
|
...(model.architecture !== undefined ? { architecture: model.architecture } : {}),
|
|
@@ -106,14 +105,12 @@ function catalogModels(spec) {
|
|
|
106
105
|
...(model.features?.tools !== undefined
|
|
107
106
|
? {
|
|
108
107
|
capabilities: {
|
|
109
|
-
tools: model.features.tools === "full"
|
|
110
|
-
? "supported"
|
|
111
|
-
: model.features.tools
|
|
108
|
+
tools: model.features.tools === "full" ? "supported" : model.features.tools
|
|
112
109
|
}
|
|
113
110
|
}
|
|
114
111
|
: {}),
|
|
115
112
|
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
|
|
116
|
-
}).status === "compatible";
|
|
113
|
+
}).status === "compatible");
|
|
117
114
|
});
|
|
118
115
|
}
|
|
119
116
|
function catalogIds(spec) {
|
|
@@ -346,7 +343,7 @@ export function codexLaunchConfigToml(spec, modelCatalogPath, roles = []) {
|
|
|
346
343
|
if (modelCatalogPath !== undefined) {
|
|
347
344
|
lines.push(`model_catalog_json = ${JSON.stringify(modelCatalogPath)}`);
|
|
348
345
|
}
|
|
349
|
-
lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(
|
|
346
|
+
lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(gatewayOpenAiBaseUrl(spec.gatewayUrl))}`, `wire_api = "responses"`, `requires_openai_auth = false`, ...(spec.auth?.token !== undefined ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`] : []), "");
|
|
350
347
|
if (roles.length > 0) {
|
|
351
348
|
lines.push("[features]", "multi_agent = true", "", "[agents]", "max_depth = 1", "");
|
|
352
349
|
for (const role of roles) {
|
|
@@ -372,7 +369,7 @@ function codexLaunchOverrides(spec, catalogPath, roles = []) {
|
|
|
372
369
|
["model", codexModelId(spec.defaultModel)],
|
|
373
370
|
["model_provider", PROVIDER_ID],
|
|
374
371
|
[`model_providers.${PROVIDER_ID}.name`, "RouteKit gateway"],
|
|
375
|
-
[`model_providers.${PROVIDER_ID}.base_url`,
|
|
372
|
+
[`model_providers.${PROVIDER_ID}.base_url`, gatewayOpenAiBaseUrl(spec.gatewayUrl)],
|
|
376
373
|
[`model_providers.${PROVIDER_ID}.wire_api`, "responses"],
|
|
377
374
|
[`model_providers.${PROVIDER_ID}.requires_openai_auth`, false]
|
|
378
375
|
];
|
package/dist/test/driver.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
@@ -23,7 +23,7 @@ process.stdin.on("end", () => {
|
|
|
23
23
|
emit({ type: "thread.started", thread_id: threadId });
|
|
24
24
|
emit({ type: "turn.started" });
|
|
25
25
|
emit({ type: "item.started", item: { id: "i1", type: "agent_message", text: "" } });
|
|
26
|
-
emit({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "ARGS: " + args.join(" ") + "\\nOK: " + input.trim() } });
|
|
26
|
+
emit({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "ARGS: " + args.join(" ") + "\\nCODEX_HOME: " + (process.env.CODEX_HOME || "") + "\\nOK: " + input.trim() } });
|
|
27
27
|
emit({ type: "turn.completed", usage: { input_tokens: 3, cached_input_tokens: 0, output_tokens: 2, reasoning_output_tokens: 0 } });
|
|
28
28
|
process.exit(0);
|
|
29
29
|
});
|
|
@@ -99,6 +99,80 @@ test("codex driver forwards effort as the SDK CLI config", async () => {
|
|
|
99
99
|
effortRepo.cleanup();
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
|
+
test("codex driver releases the turn after reasoning validation fails", async () => {
|
|
103
|
+
const driver = createCodexDriver();
|
|
104
|
+
const validationRepo = fakeCodexRepo();
|
|
105
|
+
const instance = await driver.createInstance(driver.configSchema.parse({ command: validationRepo.command }));
|
|
106
|
+
try {
|
|
107
|
+
const session = await instance.startSession({
|
|
108
|
+
cwd: validationRepo.cwd,
|
|
109
|
+
reasoning: { mode: "effort", effort: "low" }
|
|
110
|
+
});
|
|
111
|
+
await assert.rejects(async () => {
|
|
112
|
+
for await (const _event of session.sendTurn({
|
|
113
|
+
prompt: "invalid override",
|
|
114
|
+
reasoning: { mode: "effort", effort: "high" }
|
|
115
|
+
})) {
|
|
116
|
+
// Drain.
|
|
117
|
+
}
|
|
118
|
+
}, /reasoning must be selected before the session starts/);
|
|
119
|
+
const events = [];
|
|
120
|
+
for await (const event of session.sendTurn({ prompt: "valid retry" })) {
|
|
121
|
+
events.push(event);
|
|
122
|
+
}
|
|
123
|
+
assert.equal(events.find((event) => event.type === "turn.completed")?.endReason, "completed");
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await instance.dispose();
|
|
127
|
+
validationRepo.cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
test("gateway-routed codex homes are owned by their harness instance", async () => {
|
|
131
|
+
const driver = createCodexDriver();
|
|
132
|
+
const routedRepo = fakeCodexRepo();
|
|
133
|
+
const userHome = mkdtempSync(join(tmpdir(), "codex-driver-user-"));
|
|
134
|
+
const context = {
|
|
135
|
+
env: { ...process.env, HOME: userHome, CODEX_HOME: undefined }
|
|
136
|
+
};
|
|
137
|
+
const config = driver.configSchema.parse({
|
|
138
|
+
command: routedRepo.command,
|
|
139
|
+
provider: { baseUrl: "http://127.0.0.1:8080/v1" }
|
|
140
|
+
});
|
|
141
|
+
const first = await driver.createInstance(config, context);
|
|
142
|
+
const second = await driver.createInstance(config, context);
|
|
143
|
+
let firstHome;
|
|
144
|
+
let secondHome;
|
|
145
|
+
try {
|
|
146
|
+
const readHome = async (instance) => {
|
|
147
|
+
const session = await instance.startSession({ cwd: routedRepo.cwd });
|
|
148
|
+
const events = [];
|
|
149
|
+
for await (const event of session.sendTurn({ prompt: "show home" })) {
|
|
150
|
+
events.push(event);
|
|
151
|
+
}
|
|
152
|
+
const text = events
|
|
153
|
+
.flatMap((event) => (event.type === "content.delta" ? [event.text] : []))
|
|
154
|
+
.join("");
|
|
155
|
+
const match = /^CODEX_HOME: (.+)$/m.exec(text);
|
|
156
|
+
assert.ok(match?.[1]);
|
|
157
|
+
return match[1];
|
|
158
|
+
};
|
|
159
|
+
firstHome = await readHome(first);
|
|
160
|
+
secondHome = await readHome(second);
|
|
161
|
+
assert.notEqual(firstHome, secondHome);
|
|
162
|
+
assert.ok(firstHome.startsWith(join(userHome, ".cache", "routekit", "codex")));
|
|
163
|
+
assert.ok(secondHome.startsWith(join(userHome, ".cache", "routekit", "codex")));
|
|
164
|
+
await first.dispose();
|
|
165
|
+
assert.equal(existsSync(firstHome), false);
|
|
166
|
+
assert.equal(existsSync(secondHome), true);
|
|
167
|
+
await second.dispose();
|
|
168
|
+
assert.equal(existsSync(secondHome), false);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await Promise.allSettled([first.dispose(), second.dispose()]);
|
|
172
|
+
routedRepo.cleanup();
|
|
173
|
+
rmSync(userHome, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
102
176
|
test("codex driver probe reports version and installed state", async () => {
|
|
103
177
|
const driver = createCodexDriver();
|
|
104
178
|
const repo2 = fakeCodexRepo();
|
|
@@ -20,7 +20,7 @@ test("Codex managed install adds one picker-backed profile and removes only owne
|
|
|
20
20
|
const installed = installCodexIntegration({
|
|
21
21
|
gatewayUrl: "http://127.0.0.1:9999/",
|
|
22
22
|
owner: OWNER,
|
|
23
|
-
|
|
23
|
+
models: [
|
|
24
24
|
{ modelId: "opaque-primary" },
|
|
25
25
|
{ modelId: "opaque-secondary", description: "Secondary route" }
|
|
26
26
|
],
|
|
@@ -42,7 +42,7 @@ test("Codex managed install adds one picker-backed profile and removes only owne
|
|
|
42
42
|
const updated = installCodexIntegration({
|
|
43
43
|
gatewayUrl: "http://127.0.0.1:8888",
|
|
44
44
|
owner: OWNER,
|
|
45
|
-
|
|
45
|
+
models: [{ modelId: "opaque-primary" }],
|
|
46
46
|
defaultModel: "opaque-primary",
|
|
47
47
|
codexHome: home
|
|
48
48
|
});
|
|
@@ -64,7 +64,7 @@ test("Codex managed install can use a command-backed bearer token", () => {
|
|
|
64
64
|
installCodexIntegration({
|
|
65
65
|
gatewayUrl: "http://127.0.0.1:9999",
|
|
66
66
|
owner: OWNER,
|
|
67
|
-
|
|
67
|
+
models: [{ modelId: "opaque-primary" }],
|
|
68
68
|
auth: {
|
|
69
69
|
command: "/opt/routekit/node",
|
|
70
70
|
args: ["/opt/routekit/index.js", "credential", "get", "--tool", "codex"]
|
|
@@ -87,7 +87,8 @@ test("Codex's single persistent profile can use a safe custom selector", () => {
|
|
|
87
87
|
const result = installCodexIntegration({
|
|
88
88
|
gatewayUrl: "http://127.0.0.1:9999",
|
|
89
89
|
owner: OWNER,
|
|
90
|
-
|
|
90
|
+
models: [{ modelId: "provider/model" }],
|
|
91
|
+
profileId: "route-1",
|
|
91
92
|
codexHome: home
|
|
92
93
|
});
|
|
93
94
|
assert.deepEqual(result.profiles, ["route-1"]);
|
|
@@ -98,50 +99,6 @@ test("Codex's single persistent profile can use a safe custom selector", () => {
|
|
|
98
99
|
rmSync(home, { recursive: true, force: true });
|
|
99
100
|
}
|
|
100
101
|
});
|
|
101
|
-
test("Codex reinstall migrates a legacy per-model installation to one RouteKit profile", () => {
|
|
102
|
-
const home = mkdtempSync(join(tmpdir(), "routekit-codex-migrate-"));
|
|
103
|
-
const configPath = join(home, "config.toml");
|
|
104
|
-
const legacyProfiles = ["routekit-model-1.config.toml", "routekit-model-2.config.toml"];
|
|
105
|
-
writeFileSync(configPath, [
|
|
106
|
-
'model = "user-default"',
|
|
107
|
-
"",
|
|
108
|
-
"# >>> example-host integration >>>",
|
|
109
|
-
"# example-host-profile-files: routekit-model-1.config.toml routekit-model-2.config.toml",
|
|
110
|
-
"# example-host-catalog-file: .example-host-model-catalog.json",
|
|
111
|
-
"",
|
|
112
|
-
"[model_providers.example_route]",
|
|
113
|
-
'name = "Example Host gateway"',
|
|
114
|
-
'base_url = "http://127.0.0.1:9999/v1"',
|
|
115
|
-
'wire_api = "responses"',
|
|
116
|
-
"requires_openai_auth = false",
|
|
117
|
-
'env_key = "ROUTEKIT_GATEWAY_TOKEN"',
|
|
118
|
-
"",
|
|
119
|
-
"# <<< example-host integration <<<",
|
|
120
|
-
""
|
|
121
|
-
].join("\n"));
|
|
122
|
-
for (const profile of legacyProfiles) {
|
|
123
|
-
writeFileSync(join(home, profile), "# Managed by example-host\nmodel = \"opaque-primary\"\n");
|
|
124
|
-
}
|
|
125
|
-
writeFileSync(join(home, ".example-host-model-catalog.json"), "{\"models\":[]}\n");
|
|
126
|
-
try {
|
|
127
|
-
const result = installCodexIntegration({
|
|
128
|
-
gatewayUrl: "http://127.0.0.1:8888",
|
|
129
|
-
owner: OWNER,
|
|
130
|
-
profiles: [{ modelId: "opaque-primary" }, { modelId: "opaque-secondary" }],
|
|
131
|
-
defaultModel: "opaque-secondary",
|
|
132
|
-
codexHome: home
|
|
133
|
-
});
|
|
134
|
-
assert.equal(result.action, "updated");
|
|
135
|
-
assert.deepEqual(result.profiles, ["routekit"]);
|
|
136
|
-
assert.equal(existsSync(join(home, "routekit.config.toml")), true);
|
|
137
|
-
for (const profile of legacyProfiles)
|
|
138
|
-
assert.equal(existsSync(join(home, profile)), false);
|
|
139
|
-
assert.match(readFileSync(configPath, "utf8"), /example-host-profile-files: routekit\.config\.toml/);
|
|
140
|
-
}
|
|
141
|
-
finally {
|
|
142
|
-
rmSync(home, { recursive: true, force: true });
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
102
|
test("Codex install refuses to overwrite a user-owned routekit profile", () => {
|
|
146
103
|
const home = mkdtempSync(join(tmpdir(), "routekit-codex-profile-conflict-"));
|
|
147
104
|
const configPath = join(home, "config.toml");
|
|
@@ -150,7 +107,7 @@ test("Codex install refuses to overwrite a user-owned routekit profile", () => {
|
|
|
150
107
|
assert.throws(() => installCodexIntegration({
|
|
151
108
|
gatewayUrl: "http://127.0.0.1:9999",
|
|
152
109
|
owner: OWNER,
|
|
153
|
-
|
|
110
|
+
models: [{ modelId: "opaque-primary" }],
|
|
154
111
|
codexHome: home
|
|
155
112
|
}), /refusing to overwrite an existing Codex profile/);
|
|
156
113
|
assert.equal(existsSync(configPath), false);
|
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": "1.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./driver": {
|
|
19
|
+
"types": "./dist/driver.d.ts",
|
|
20
|
+
"default": "./dist/driver.js"
|
|
21
|
+
},
|
|
22
|
+
"./install": {
|
|
23
|
+
"types": "./dist/install.d.ts",
|
|
24
|
+
"default": "./dist/install.js"
|
|
25
|
+
},
|
|
26
|
+
"./launch": {
|
|
27
|
+
"types": "./dist/launch.d.ts",
|
|
28
|
+
"default": "./dist/launch.js"
|
|
17
29
|
}
|
|
18
30
|
},
|
|
19
31
|
"files": [
|
|
@@ -29,11 +41,11 @@
|
|
|
29
41
|
"@openai/codex-sdk": "0.145.0",
|
|
30
42
|
"smol-toml": "1.7.0",
|
|
31
43
|
"zod": "4.4.3",
|
|
32
|
-
"@velum-labs/routekit-contracts": "0.
|
|
33
|
-
"@velum-labs/routekit-harness-core": "0.
|
|
34
|
-
"@velum-labs/routekit-
|
|
35
|
-
"@velum-labs/routekit-
|
|
36
|
-
"@velum-labs/routekit-tools": "0.
|
|
44
|
+
"@velum-labs/routekit-contracts": "1.0.0",
|
|
45
|
+
"@velum-labs/routekit-harness-core": "1.0.0",
|
|
46
|
+
"@velum-labs/routekit-runtime": "1.0.0",
|
|
47
|
+
"@velum-labs/routekit-registry": "1.0.0",
|
|
48
|
+
"@velum-labs/routekit-tools": "1.0.0"
|
|
37
49
|
},
|
|
38
50
|
"keywords": [
|
|
39
51
|
"llm",
|
|
@@ -43,7 +55,7 @@
|
|
|
43
55
|
"adapter"
|
|
44
56
|
],
|
|
45
57
|
"scripts": {
|
|
46
|
-
"build": "tsc -
|
|
58
|
+
"build": "tsc -p tsconfig.json",
|
|
47
59
|
"clean": "tsc -b --clean",
|
|
48
60
|
"test": "node --test \"dist/test/*.test.js\""
|
|
49
61
|
}
|