@koreanhole/opencodex 2.98.0 → 3.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/AGENTS_INSTALL.md +23 -7
- package/README.md +90 -181
- package/bin/ocx.mjs +1 -1
- package/bin/package-main.mjs +11 -1
- package/package.json +9 -6
- package/src/adapter-runtime/config.ts +381 -0
- package/src/adapter-runtime/gateway.ts +121 -0
- package/src/adapter-runtime/scoped-server.ts +20 -0
- package/src/cli/entry.ts +222 -0
- package/src/cli/index.ts +90 -20
- package/src/lib/codex-catalog.ts +61 -0
- package/src/lib/codex-inject.ts +467 -0
- package/src/server/index.ts +28 -17
- package/src/server/responses.ts +42 -1
- package/src/upstream/cli.ts +12 -0
- package/src/upstream/package.ts +75 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getConfigDir, isLoopbackHost, type RuntimeConfig } from "../config";
|
|
5
|
+
import {
|
|
6
|
+
codexHome,
|
|
7
|
+
removeManagedCodexCatalogRows,
|
|
8
|
+
resolveCodexCatalogPath,
|
|
9
|
+
} from "./codex-catalog";
|
|
10
|
+
|
|
11
|
+
const JOURNAL_VERSION = 1;
|
|
12
|
+
const MANAGED_MARKER = "# Managed by opencodex: provider routing";
|
|
13
|
+
const LOCK_STALE_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
export class CodexInjectionError extends Error {
|
|
16
|
+
constructor(message: string, readonly code: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CodexInjectionError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CodexInjectionEndpoint {
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CodexInjectionResult {
|
|
28
|
+
success: boolean;
|
|
29
|
+
action: "inject" | "restore" | "restore-back";
|
|
30
|
+
changed: boolean;
|
|
31
|
+
config: { state: "ok" | "skipped" | "failed"; changed: boolean; message: string };
|
|
32
|
+
catalog: { state: "ok" | "skipped" | "failed"; changed: boolean; removed: number; message: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CodexJournal {
|
|
36
|
+
version: 1;
|
|
37
|
+
codexHome: string;
|
|
38
|
+
configPath: string;
|
|
39
|
+
originalConfigBase64: string | null;
|
|
40
|
+
injectedConfigHash: string;
|
|
41
|
+
catalogPath: string | null;
|
|
42
|
+
originalCatalogBase64: string | null;
|
|
43
|
+
managedCatalogHash: string | null;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface MaybeFile {
|
|
48
|
+
exists: boolean;
|
|
49
|
+
bytes: Buffer | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function configPath(env: NodeJS.ProcessEnv): string {
|
|
53
|
+
return join(codexHome(env), "config.toml");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function journalPath(env: NodeJS.ProcessEnv): string {
|
|
57
|
+
return join(getConfigDir(env), "codex", "inject-journal.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function lockPath(env: NodeJS.ProcessEnv): string {
|
|
61
|
+
return join(getConfigDir(env), "codex", "inject.lock");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hash(bytes: string | Buffer): string {
|
|
65
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function b64(bytes: Buffer | null): string | null {
|
|
69
|
+
return bytes ? bytes.toString("base64") : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unb64(value: string | null): Buffer | null {
|
|
73
|
+
return value ? Buffer.from(value, "base64") : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readMaybe(path: string): Promise<MaybeFile> {
|
|
77
|
+
try {
|
|
78
|
+
return { exists: true, bytes: await readFile(path) };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, bytes: null };
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function writeAtomic(path: string, bytes: string | Buffer, mode = 0o600): Promise<void> {
|
|
86
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
87
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(temporary, bytes, { mode, flag: "wx" });
|
|
90
|
+
await chmod(temporary, mode);
|
|
91
|
+
await rename(temporary, path);
|
|
92
|
+
await chmod(path, mode).catch(() => undefined);
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function alive(pid: number): boolean {
|
|
99
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function withLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>): Promise<T> {
|
|
103
|
+
const path = lockPath(env);
|
|
104
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
105
|
+
const acquire = async (): Promise<boolean> => {
|
|
106
|
+
try {
|
|
107
|
+
await writeFile(path, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600, flag: "wx" });
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
111
|
+
let stale = false;
|
|
112
|
+
try {
|
|
113
|
+
const info = await stat(path);
|
|
114
|
+
let pid = 0;
|
|
115
|
+
try { pid = (JSON.parse(await readFile(path, "utf8")) as { pid?: number }).pid ?? 0; } catch { /* malformed */ }
|
|
116
|
+
stale = Date.now() - info.mtimeMs > LOCK_STALE_MS || (pid > 0 && !alive(pid));
|
|
117
|
+
} catch {
|
|
118
|
+
stale = true;
|
|
119
|
+
}
|
|
120
|
+
if (stale) {
|
|
121
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
122
|
+
return await acquire();
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
if (!await acquire()) throw new CodexInjectionError("Another Codex configuration change is in progress; retry after it finishes.", "codex_config_busy");
|
|
128
|
+
try {
|
|
129
|
+
return await fn();
|
|
130
|
+
} finally {
|
|
131
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isProviderHeader(line: string): boolean {
|
|
136
|
+
return /^\s*\[\s*model_providers\.opencodex(?:\s*\.|\s*\])/.test(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isTableHeader(line: string): boolean {
|
|
140
|
+
return /^\s*\[/.test(line);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function rootEnd(lines: readonly string[]): number {
|
|
144
|
+
const index = lines.findIndex(isTableHeader);
|
|
145
|
+
return index < 0 ? lines.length : index;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rootValue(text: string, key: string): string | undefined {
|
|
149
|
+
const lines = text.split(/\r?\n/);
|
|
150
|
+
const end = rootEnd(lines);
|
|
151
|
+
const re = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
|
|
152
|
+
for (let index = 0; index < end; index++) {
|
|
153
|
+
const match = re.exec(lines[index]!);
|
|
154
|
+
if (match) return match[1] ?? "";
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setRootValue(text: string, key: string, value: string | undefined): string {
|
|
160
|
+
const lines = text.split(/\r?\n/);
|
|
161
|
+
let end = rootEnd(lines);
|
|
162
|
+
let found = false;
|
|
163
|
+
const re = new RegExp(`^\\s*${key}\\s*=`);
|
|
164
|
+
for (let index = 0; index < end; index++) {
|
|
165
|
+
if (!re.test(lines[index]!)) continue;
|
|
166
|
+
found = true;
|
|
167
|
+
if (value === undefined) {
|
|
168
|
+
lines.splice(index, 1);
|
|
169
|
+
end -= 1;
|
|
170
|
+
} else {
|
|
171
|
+
lines[index] = `${key} = ${JSON.stringify(value)}`;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (!found && value !== undefined) {
|
|
176
|
+
lines.splice(end, 0, `${key} = ${JSON.stringify(value)}`);
|
|
177
|
+
end += 1;
|
|
178
|
+
}
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasUnmarkedProviderTable(text: string): boolean {
|
|
183
|
+
const lines = text.split(/\r?\n/);
|
|
184
|
+
for (let index = 0; index < lines.length; index++) {
|
|
185
|
+
if (!isProviderHeader(lines[index]!)) continue;
|
|
186
|
+
let previous = index - 1;
|
|
187
|
+
while (previous >= 0 && lines[previous]!.trim() === "") previous--;
|
|
188
|
+
if (lines[previous]?.trim() !== MANAGED_MARKER) return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stripProviderTables(text: string): string {
|
|
194
|
+
const lines = text.split(/\r?\n/);
|
|
195
|
+
const out: string[] = [];
|
|
196
|
+
let skipping = false;
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
if (isTableHeader(line)) {
|
|
199
|
+
if (isProviderHeader(line)) {
|
|
200
|
+
skipping = true;
|
|
201
|
+
while (out.length > 0 && out[out.length - 1]!.trim() === MANAGED_MARKER) out.pop();
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
skipping = false;
|
|
205
|
+
}
|
|
206
|
+
if (skipping) continue;
|
|
207
|
+
if (line.trim() === MANAGED_MARKER) continue;
|
|
208
|
+
out.push(line);
|
|
209
|
+
}
|
|
210
|
+
return out.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function providerBlock(endpoint: CodexInjectionEndpoint): string {
|
|
214
|
+
const base = `http://${endpoint.host}:${endpoint.port}/v1`;
|
|
215
|
+
return [
|
|
216
|
+
MANAGED_MARKER,
|
|
217
|
+
"[model_providers.opencodex]",
|
|
218
|
+
'name = "opencodex (Cline or Gemini CLI)"',
|
|
219
|
+
`base_url = ${JSON.stringify(base)}`,
|
|
220
|
+
'wire_api = "responses"',
|
|
221
|
+
"requires_openai_auth = false",
|
|
222
|
+
`model_catalog_url = ${JSON.stringify(`${base}/model-catalog`)}`,
|
|
223
|
+
].join("\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function routedModel(original: string, config: RuntimeConfig): string {
|
|
227
|
+
const current = rootValue(original, "model");
|
|
228
|
+
if (current?.startsWith("cline-cli/") || current?.startsWith("gemini-cli/")) return current;
|
|
229
|
+
return `${config.defaultProvider}/${config.providers[config.defaultProvider].defaultModel}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function injectedConfig(text: string, endpoint: CodexInjectionEndpoint, config: RuntimeConfig): string {
|
|
233
|
+
if (hasUnmarkedProviderTable(text)) {
|
|
234
|
+
throw new CodexInjectionError(
|
|
235
|
+
"Codex config already defines a different [model_providers.opencodex] table; refusing to overwrite it.",
|
|
236
|
+
"codex_provider_conflict",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
let next = stripProviderTables(text);
|
|
240
|
+
next = setRootValue(next, "model_provider", "opencodex");
|
|
241
|
+
next = setRootValue(next, "model", routedModel(text, config));
|
|
242
|
+
const body = next.trimEnd();
|
|
243
|
+
return `${body}${body ? "\n\n" : ""}${providerBlock(endpoint)}\n`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function restoreRoots(current: string, original: string | null): string {
|
|
247
|
+
let next = stripProviderTables(current);
|
|
248
|
+
for (const key of ["model_provider", "model"] as const) {
|
|
249
|
+
next = setRootValue(next, key, original === null ? undefined : rootValue(original, key));
|
|
250
|
+
}
|
|
251
|
+
return next;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function readJournal(env: NodeJS.ProcessEnv): Promise<CodexJournal | null> {
|
|
255
|
+
let raw: string;
|
|
256
|
+
try {
|
|
257
|
+
raw = await readFile(journalPath(env), "utf8");
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
let parsed: Partial<CodexJournal>;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(raw) as Partial<CodexJournal>;
|
|
265
|
+
} catch {
|
|
266
|
+
throw new CodexInjectionError("The Codex restore journal is malformed; it was preserved.", "codex_journal_malformed");
|
|
267
|
+
}
|
|
268
|
+
if (parsed.version !== JOURNAL_VERSION || typeof parsed.configPath !== "string" || typeof parsed.injectedConfigHash !== "string") {
|
|
269
|
+
throw new CodexInjectionError("The Codex restore journal is invalid; it was preserved.", "codex_journal_invalid");
|
|
270
|
+
}
|
|
271
|
+
return parsed as CodexJournal;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function writeJournal(env: NodeJS.ProcessEnv, journal: CodexJournal): Promise<void> {
|
|
275
|
+
await writeAtomic(journalPath(env), `${JSON.stringify(journal, null, 2)}\n`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function endpointError(endpoint: CodexInjectionEndpoint): void {
|
|
279
|
+
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) {
|
|
280
|
+
throw new CodexInjectionError("The Codex proxy endpoint port is invalid.", "codex_endpoint_invalid");
|
|
281
|
+
}
|
|
282
|
+
if (!isLoopbackHost(endpoint.host)) {
|
|
283
|
+
throw new CodexInjectionError("Codex injection requires a loopback proxy endpoint.", "codex_endpoint_non_loopback");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validateToml(text: string): void {
|
|
288
|
+
if (!text.trim()) return;
|
|
289
|
+
try {
|
|
290
|
+
Bun.TOML.parse(text);
|
|
291
|
+
} catch {
|
|
292
|
+
throw new CodexInjectionError("Codex config is not valid TOML; it was preserved.", "codex_config_malformed");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Inject routing and persist the pre-image before touching Codex config. */
|
|
297
|
+
export async function injectCodexConfig(options: {
|
|
298
|
+
env: NodeJS.ProcessEnv;
|
|
299
|
+
endpoint: CodexInjectionEndpoint;
|
|
300
|
+
config: RuntimeConfig;
|
|
301
|
+
}): Promise<CodexInjectionResult> {
|
|
302
|
+
endpointError(options.endpoint);
|
|
303
|
+
return await withLock(options.env, async () => {
|
|
304
|
+
const path = configPath(options.env);
|
|
305
|
+
const current = await readMaybe(path);
|
|
306
|
+
if (current.exists) validateToml(current.bytes!.toString("utf8"));
|
|
307
|
+
const existing = await readJournal(options.env);
|
|
308
|
+
const originalConfig = existing ? unb64(existing.originalConfigBase64) : current.bytes;
|
|
309
|
+
const originalCatalog = existing ? unb64(existing.originalCatalogBase64) : null;
|
|
310
|
+
const catalogPath = existing?.catalogPath ?? await resolveCodexCatalogPath(options.env) ?? null;
|
|
311
|
+
const catalog = catalogPath ? await readMaybe(catalogPath) : { exists: false, bytes: null };
|
|
312
|
+
const before = current.bytes?.toString("utf8") ?? "";
|
|
313
|
+
if (existing && !hasManagedProvider(before)) {
|
|
314
|
+
throw new CodexInjectionError(
|
|
315
|
+
"A Codex restore journal exists but Codex routing is already native; run `ocx restore` before starting again.",
|
|
316
|
+
"codex_journal_state_conflict",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const next = injectedConfig(before, options.endpoint, options.config);
|
|
320
|
+
const journal: CodexJournal = {
|
|
321
|
+
version: JOURNAL_VERSION,
|
|
322
|
+
codexHome: codexHome(options.env),
|
|
323
|
+
configPath: path,
|
|
324
|
+
originalConfigBase64: b64(originalConfig),
|
|
325
|
+
injectedConfigHash: hash(next),
|
|
326
|
+
catalogPath: existing?.catalogPath ?? catalogPath,
|
|
327
|
+
originalCatalogBase64: existing ? existing.originalCatalogBase64 : b64(catalog.bytes),
|
|
328
|
+
managedCatalogHash: existing?.managedCatalogHash ?? null,
|
|
329
|
+
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
330
|
+
};
|
|
331
|
+
await writeJournal(options.env, journal);
|
|
332
|
+
try {
|
|
333
|
+
await writeAtomic(path, next);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (!existing) await rm(journalPath(options.env), { force: true }).catch(() => undefined);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
success: true,
|
|
340
|
+
action: "inject",
|
|
341
|
+
changed: true,
|
|
342
|
+
config: { state: "ok", changed: true, message: "Codex routing is pointed at the local proxy." },
|
|
343
|
+
catalog: { state: "ok", changed: false, removed: 0, message: originalCatalog ? "Original catalog is journaled." : "Catalog state is journaled." },
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function hasManagedProvider(text: string): boolean {
|
|
349
|
+
return text.includes(MANAGED_MARKER);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function restoreCatalog(journal: CodexJournal): Promise<CodexInjectionResult["catalog"]> {
|
|
353
|
+
const path = journal.catalogPath;
|
|
354
|
+
if (!path) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog path was journaled." };
|
|
355
|
+
const current = await readMaybe(path);
|
|
356
|
+
if (!current.exists) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog file was present." };
|
|
357
|
+
const currentHash = hash(current.bytes!);
|
|
358
|
+
const original = unb64(journal.originalCatalogBase64);
|
|
359
|
+
if (journal.managedCatalogHash && currentHash === journal.managedCatalogHash) {
|
|
360
|
+
if (original === null) {
|
|
361
|
+
await rm(path, { force: true });
|
|
362
|
+
return { state: "ok", changed: true, removed: 0, message: "Removed the opencodex-owned catalog file." };
|
|
363
|
+
}
|
|
364
|
+
await writeAtomic(path, original);
|
|
365
|
+
return { state: "ok", changed: true, removed: 0, message: "Restored the original Codex catalog." };
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const removed = await removeManagedCodexCatalogRows(path);
|
|
369
|
+
return { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
|
|
370
|
+
} catch (error) {
|
|
371
|
+
return { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Restore native Codex config/catalog; no-op when nothing was managed. */
|
|
376
|
+
export async function restoreCodexConfig(options: {
|
|
377
|
+
env: NodeJS.ProcessEnv;
|
|
378
|
+
removeProviderTable?: boolean;
|
|
379
|
+
}): Promise<CodexInjectionResult> {
|
|
380
|
+
return await withLock(options.env, async () => {
|
|
381
|
+
const journal = await readJournal(options.env);
|
|
382
|
+
const path = configPath(options.env);
|
|
383
|
+
const current = await readMaybe(path);
|
|
384
|
+
if (current.exists) validateToml(current.bytes!.toString("utf8"));
|
|
385
|
+
if (!journal) {
|
|
386
|
+
let catalog: CodexInjectionResult["catalog"] = {
|
|
387
|
+
state: "skipped", changed: false, removed: 0, message: "No journal was present.",
|
|
388
|
+
};
|
|
389
|
+
const catalogPath = await resolveCodexCatalogPath(options.env);
|
|
390
|
+
if (catalogPath) {
|
|
391
|
+
try {
|
|
392
|
+
const removed = await removeManagedCodexCatalogRows(catalogPath);
|
|
393
|
+
catalog = { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
|
|
394
|
+
} catch (error) {
|
|
395
|
+
catalog = { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!current.exists) {
|
|
399
|
+
return {
|
|
400
|
+
success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
|
|
401
|
+
config: { state: "skipped", changed: false, message: "Codex config was not present." },
|
|
402
|
+
catalog,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const text = current.bytes!.toString("utf8");
|
|
406
|
+
if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
|
|
407
|
+
throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
|
|
408
|
+
}
|
|
409
|
+
if (!hasManagedProvider(text) && !options.removeProviderTable) {
|
|
410
|
+
return {
|
|
411
|
+
success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
|
|
412
|
+
config: { state: "skipped", changed: false, message: "Codex is already using its native configuration." },
|
|
413
|
+
catalog,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const restored = restoreRoots(text, null);
|
|
417
|
+
await writeAtomic(path, restored);
|
|
418
|
+
return {
|
|
419
|
+
success: catalog.state !== "failed", action: "restore", changed: true || catalog.changed,
|
|
420
|
+
config: { state: "ok", changed: true, message: "Removed managed Codex routing; no pre-image journal was available." },
|
|
421
|
+
catalog,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const original = unb64(journal.originalConfigBase64);
|
|
425
|
+
const text = current.bytes?.toString("utf8") ?? "";
|
|
426
|
+
let changed = false;
|
|
427
|
+
let message = "Codex config was already native.";
|
|
428
|
+
if (current.exists && hash(current.bytes!) === journal.injectedConfigHash) {
|
|
429
|
+
if (original === null) await rm(path, { force: true });
|
|
430
|
+
else await writeAtomic(path, original);
|
|
431
|
+
changed = true;
|
|
432
|
+
message = original === null ? "Removed the managed Codex config." : "Restored the original Codex config.";
|
|
433
|
+
} else if (hasManagedProvider(text)) {
|
|
434
|
+
if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
|
|
435
|
+
throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
|
|
436
|
+
}
|
|
437
|
+
await writeAtomic(path, restoreRoots(text, original?.toString("utf8") ?? null));
|
|
438
|
+
changed = true;
|
|
439
|
+
message = "Removed managed Codex routing while preserving unrelated edits.";
|
|
440
|
+
}
|
|
441
|
+
const catalog = await restoreCatalog(journal);
|
|
442
|
+
if (catalog.state === "failed") {
|
|
443
|
+
return {
|
|
444
|
+
success: false, action: "restore", changed,
|
|
445
|
+
config: { state: "ok", changed, message },
|
|
446
|
+
catalog,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
await rm(journalPath(options.env), { force: true });
|
|
450
|
+
return {
|
|
451
|
+
success: true, action: "restore", changed: changed || catalog.changed,
|
|
452
|
+
config: { state: changed ? "ok" : "skipped", changed, message },
|
|
453
|
+
catalog,
|
|
454
|
+
};
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Record the post-sync catalog hash so a later exact restore can remove it. */
|
|
459
|
+
export async function recordCodexCatalogState(env: NodeJS.ProcessEnv): Promise<void> {
|
|
460
|
+
await withLock(env, async () => {
|
|
461
|
+
const journal = await readJournal(env);
|
|
462
|
+
if (!journal?.catalogPath) return;
|
|
463
|
+
const current = await readMaybe(journal.catalogPath);
|
|
464
|
+
journal.managedCatalogHash = current.exists ? hash(current.bytes!) : null;
|
|
465
|
+
await writeJournal(env, journal);
|
|
466
|
+
});
|
|
467
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
assertSafeListener,
|
|
6
6
|
defaultConfig,
|
|
7
7
|
normalizeConfig,
|
|
8
|
+
type ProviderId,
|
|
8
9
|
type RuntimeConfig,
|
|
9
10
|
} from "../config";
|
|
10
11
|
import type { IncomingMeta } from "../types";
|
|
@@ -29,6 +30,7 @@ export interface ServerOptions {
|
|
|
29
30
|
clineDeps?: ClineTurnDeps;
|
|
30
31
|
geminiDeps?: GeminiTurnDeps;
|
|
31
32
|
geminiPackageRoot?: string;
|
|
33
|
+
providerScope?: ProviderId;
|
|
32
34
|
onWebSocketRequest?: (observation: WebSocketRequestObservation) => void;
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -92,23 +94,32 @@ async function discoverModels(
|
|
|
92
94
|
clineDeps: ClineTurnDeps | undefined,
|
|
93
95
|
geminiDeps: GeminiTurnDeps | undefined,
|
|
94
96
|
geminiPackageRoot: string | undefined,
|
|
97
|
+
providerScope?: ProviderId,
|
|
95
98
|
): Promise<ProviderModels> {
|
|
96
99
|
const [cline, gemini] = await Promise.all([
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
!providerScope || providerScope === "cline-cli"
|
|
101
|
+
? discoverClineLiveModels({ env, ...(clineDeps ? { deps: clineDeps } : {}) })
|
|
102
|
+
: undefined,
|
|
103
|
+
!providerScope || providerScope === "gemini-cli"
|
|
104
|
+
? discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}), ...(geminiPackageRoot ? { packageRoot: geminiPackageRoot } : {}) })
|
|
105
|
+
: undefined,
|
|
99
106
|
]);
|
|
100
|
-
if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
|
|
107
|
+
if (cline && !cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
|
|
101
108
|
console.warn(`[opencodex] Cline model discovery degraded [${cline.reason}]: ${cline.detail}`);
|
|
102
109
|
}
|
|
103
|
-
if (!gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") {
|
|
110
|
+
if (gemini && !gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") {
|
|
104
111
|
console.warn(`[opencodex] Gemini model discovery degraded [${gemini.reason}]: ${gemini.detail}`);
|
|
105
112
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"
|
|
113
|
+
const models: ProviderModels = {};
|
|
114
|
+
if (cline) {
|
|
115
|
+
models["cline-cli"] = cline.ok ? ["default", ...cline.models.filter(id => id !== "default")] : ["default"];
|
|
116
|
+
}
|
|
117
|
+
if (gemini) {
|
|
118
|
+
models["gemini-cli"] = gemini.ok
|
|
109
119
|
? ["default", ...gemini.modelInfo.filter(model => model.id !== "default")]
|
|
110
|
-
: ["default"]
|
|
111
|
-
}
|
|
120
|
+
: ["default"];
|
|
121
|
+
}
|
|
122
|
+
return models;
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
export function startServer(config: RuntimeConfig | number, options: ServerOptions = {}): RunningServer {
|
|
@@ -138,8 +149,8 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
138
149
|
}
|
|
139
150
|
try {
|
|
140
151
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
141
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
142
|
-
return Response.json({ object: "list", data: modelList(known) });
|
|
152
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot, options.providerScope);
|
|
153
|
+
return Response.json({ object: "list", data: modelList(known, options.providerScope) });
|
|
143
154
|
}
|
|
144
155
|
if (req.method === "GET" && url.pathname === "/v1/model-catalog") {
|
|
145
156
|
const discovered = await discoverCodexCatalogModels({
|
|
@@ -152,16 +163,16 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
152
163
|
}
|
|
153
164
|
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
154
165
|
const body = await boundedJson(req);
|
|
155
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
166
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot, options.providerScope);
|
|
156
167
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
157
168
|
const incoming: IncomingMeta = { headers: req.headers, abortSignal: signal };
|
|
158
|
-
return await handleResponses(body, incoming, known, turnDeps, config.defaultProvider);
|
|
169
|
+
return await handleResponses(body, incoming, known, turnDeps, options.providerScope ?? config.defaultProvider);
|
|
159
170
|
}
|
|
160
171
|
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
161
172
|
const body = await boundedJson(req);
|
|
162
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
173
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot, options.providerScope);
|
|
163
174
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
164
|
-
return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, config.defaultProvider);
|
|
175
|
+
return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, options.providerScope ?? config.defaultProvider);
|
|
165
176
|
}
|
|
166
177
|
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
167
178
|
return handleCountTokens(await boundedJson(req));
|
|
@@ -173,9 +184,9 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
173
184
|
};
|
|
174
185
|
|
|
175
186
|
const websocketContext = {
|
|
176
|
-
knownModels: () => discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot),
|
|
187
|
+
knownModels: () => discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot, options.providerScope),
|
|
177
188
|
deps: turnDeps,
|
|
178
|
-
defaultProvider: config.defaultProvider,
|
|
189
|
+
defaultProvider: options.providerScope ?? config.defaultProvider,
|
|
179
190
|
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
180
191
|
...(options.onWebSocketRequest ? { onRequest: options.onWebSocketRequest } : {}),
|
|
181
192
|
};
|
package/src/server/responses.ts
CHANGED
|
@@ -379,7 +379,10 @@ const PROVIDER_LABELS: Record<ProviderId, string> = {
|
|
|
379
379
|
[GEMINI_PROVIDER_ID]: "Gemini CLI",
|
|
380
380
|
};
|
|
381
381
|
|
|
382
|
-
export function modelList(
|
|
382
|
+
export function modelList(
|
|
383
|
+
known: string[] | ProviderModels,
|
|
384
|
+
scope?: ProviderId,
|
|
385
|
+
): Array<Record<string, unknown>> {
|
|
383
386
|
if (Array.isArray(known)) {
|
|
384
387
|
const ids = ["default", ...known.filter(id => id !== "default")];
|
|
385
388
|
return [...new Set(ids)].map(id => ({
|
|
@@ -390,6 +393,44 @@ export function modelList(known: string[] | ProviderModels): Array<Record<string
|
|
|
390
393
|
display_name: id === "default" ? "Cline default" : id,
|
|
391
394
|
}));
|
|
392
395
|
}
|
|
396
|
+
if (scope) {
|
|
397
|
+
const entries = ["default", ...(known[scope] ?? []).filter(entry => modelIdOf(entry) !== "default")];
|
|
398
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
399
|
+
const seen = new Set<string>();
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
const id = modelIdOf(entry);
|
|
402
|
+
if (seen.has(id)) continue;
|
|
403
|
+
seen.add(id);
|
|
404
|
+
const info = typeof entry === "string" ? { id: entry } as OcxProviderModel : entry;
|
|
405
|
+
const reasoning = info.supportedReasoningLevels ?? [];
|
|
406
|
+
rows.push({
|
|
407
|
+
id: encodeRoutedModelId(id),
|
|
408
|
+
object: "model",
|
|
409
|
+
created: 0,
|
|
410
|
+
owned_by: scope,
|
|
411
|
+
display_name: info.name ?? (id === "default" ? PROVIDER_LABELS[scope] + " default" : id),
|
|
412
|
+
...(info.description ? { description: info.description } : {}),
|
|
413
|
+
...(info.contextWindowTokens !== undefined
|
|
414
|
+
? {
|
|
415
|
+
context_window: info.contextWindowTokens,
|
|
416
|
+
max_context_window: info.maxContextWindowTokens ?? info.contextWindowTokens,
|
|
417
|
+
}
|
|
418
|
+
: {}),
|
|
419
|
+
...(info.inputModalities ? { input_modalities: [...info.inputModalities] } : {}),
|
|
420
|
+
...(info.loadSession !== undefined ? { load_session: info.loadSession } : {}),
|
|
421
|
+
...(reasoning.length > 0
|
|
422
|
+
? {
|
|
423
|
+
supported_reasoning_levels: reasoning.map(level => ({
|
|
424
|
+
effort: level.effort,
|
|
425
|
+
description: level.description,
|
|
426
|
+
})),
|
|
427
|
+
default_reasoning_level: info.defaultReasoningLevel ?? reasoning[0]!.effort,
|
|
428
|
+
}
|
|
429
|
+
: { supported_reasoning_levels: [] }),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return rows;
|
|
433
|
+
}
|
|
393
434
|
const rows: Array<Record<string, unknown>> = [{
|
|
394
435
|
id: "default",
|
|
395
436
|
object: "model",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { resolveUpstreamPackage, type UpstreamPackage } from "./package";
|
|
3
|
+
|
|
4
|
+
export type UpstreamModuleImporter = (url: string) => Promise<unknown>;
|
|
5
|
+
|
|
6
|
+
/** Load the immutable upstream CLI into this process so service ownership stays ours. */
|
|
7
|
+
export async function loadUpstreamCli(
|
|
8
|
+
pkg: UpstreamPackage = resolveUpstreamPackage(),
|
|
9
|
+
importer: UpstreamModuleImporter = url => import(url),
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
await importer(pathToFileURL(pkg.cliEntry).href);
|
|
12
|
+
}
|