@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.
@@ -0,0 +1,381 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getConfigDir, getConfigPath } from "../config";
4
+ import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
5
+ import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
6
+ import type { OcxProviderModel } from "../types";
7
+
8
+ export const ADAPTER_PROVIDER_IDS = ["gemini-cli", "cline-cli"] as const;
9
+ export type AdapterProviderId = typeof ADAPTER_PROVIDER_IDS[number];
10
+ export const ADAPTER_TOKEN_ENV = "OPENCODEX_CLI_ADAPTER_TOKEN";
11
+ export const ADAPTER_DEFAULT_PORT = 19_100;
12
+
13
+ export interface AdapterModelMetadata {
14
+ models: string[];
15
+ modelDisplayNames?: Record<string, string>;
16
+ modelContextWindows?: Record<string, number>;
17
+ modelInputModalities?: Record<string, string[]>;
18
+ modelReasoningEfforts?: Record<string, string[]>;
19
+ modelDefaultReasoningEfforts?: Record<string, string>;
20
+ }
21
+
22
+ export type AdapterProviderMetadata = Partial<Record<AdapterProviderId, AdapterModelMetadata>>;
23
+
24
+ export interface AdapterRuntimeState {
25
+ version: 1;
26
+ pid: number;
27
+ host: "127.0.0.1";
28
+ port: number;
29
+ tokenEnv: typeof ADAPTER_TOKEN_ENV;
30
+ providers: AdapterProviderId[];
31
+ startedAt: string;
32
+ }
33
+
34
+ interface AdapterConfigJournal {
35
+ version: 1;
36
+ priorRows: Record<AdapterProviderId, unknown | "absent">;
37
+ priorHostname?: string;
38
+ appliedHostname?: string;
39
+ priorDefaultProvider?: string;
40
+ appliedDefaultProvider?: string;
41
+ }
42
+
43
+ export class AdapterConfigError extends Error {
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "AdapterConfigError";
47
+ }
48
+ }
49
+
50
+ const CLINE_FALLBACK_CONTEXT = 128_000;
51
+
52
+ function unionModalities(models: readonly OcxProviderModel[]): string[] {
53
+ const values = models.flatMap(model => model.inputModalities ?? []);
54
+ return [...new Set(values.length > 0 ? values : ["text"])];
55
+ }
56
+
57
+ function modelMetadata(models: readonly OcxProviderModel[], cline: boolean): AdapterModelMetadata {
58
+ const metadata: AdapterModelMetadata = { models: models.map(model => model.id) };
59
+ const displayNames: Record<string, string> = {};
60
+ const contexts: Record<string, number> = {};
61
+ const modalities: Record<string, string[]> = {};
62
+ const efforts: Record<string, string[]> = {};
63
+ const defaults: Record<string, string> = {};
64
+ for (const model of models) {
65
+ if (model.name) displayNames[model.id] = model.name;
66
+ const context = model.contextWindowTokens ?? (cline ? CLINE_FALLBACK_CONTEXT : undefined);
67
+ if (context !== undefined) contexts[model.id] = context;
68
+ modalities[model.id] = [...(model.inputModalities ?? ["text"])];
69
+ const levels = (model.supportedReasoningLevels ?? []).map(level => level.effort);
70
+ if (levels.length > 0) {
71
+ efforts[model.id] = levels;
72
+ defaults[model.id] = model.defaultReasoningLevel ?? levels[0]!;
73
+ }
74
+ }
75
+ if (cline) {
76
+ contexts.default = CLINE_FALLBACK_CONTEXT;
77
+ modalities.default = ["text"];
78
+ } else if (models.length > 0) {
79
+ const discoveredContexts = models.flatMap(model => model.contextWindowTokens ?? []);
80
+ if (discoveredContexts.length > 0) contexts.default = Math.min(...discoveredContexts);
81
+ modalities.default = unionModalities(models);
82
+ }
83
+ if (Object.keys(displayNames).length > 0) metadata.modelDisplayNames = displayNames;
84
+ if (Object.keys(contexts).length > 0) metadata.modelContextWindows = contexts;
85
+ if (Object.keys(modalities).length > 0) metadata.modelInputModalities = modalities;
86
+ if (Object.keys(efforts).length > 0) metadata.modelReasoningEfforts = efforts;
87
+ if (Object.keys(defaults).length > 0) metadata.modelDefaultReasoningEfforts = defaults;
88
+ return metadata;
89
+ }
90
+
91
+ export async function discoverManagedProviderMetadata(
92
+ env: NodeJS.ProcessEnv = process.env,
93
+ ): Promise<AdapterProviderMetadata> {
94
+ const [cline, gemini] = await Promise.all([
95
+ discoverClineLiveModels({ env }),
96
+ discoverGeminiLiveModels({ env }),
97
+ ]);
98
+ return {
99
+ "cline-cli": modelMetadata(
100
+ cline.ok ? cline.models.map(id => ({ id })) : [],
101
+ true,
102
+ ),
103
+ "gemini-cli": modelMetadata(
104
+ gemini.ok ? gemini.modelInfo : [],
105
+ false,
106
+ ),
107
+ };
108
+ }
109
+
110
+ function record(value: unknown): Record<string, unknown> | undefined {
111
+ return value && typeof value === "object" && !Array.isArray(value)
112
+ ? value as Record<string, unknown>
113
+ : undefined;
114
+ }
115
+
116
+ function isLoopback(hostname: string): boolean {
117
+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
118
+ }
119
+
120
+ export function adapterStateDir(env: NodeJS.ProcessEnv = process.env): string {
121
+ return join(getConfigDir(env), "cli-adapters");
122
+ }
123
+
124
+ export function adapterRuntimePath(env: NodeJS.ProcessEnv = process.env): string {
125
+ return join(adapterStateDir(env), "runtime.json");
126
+ }
127
+
128
+ export function adapterJournalPath(env: NodeJS.ProcessEnv = process.env): string {
129
+ return join(adapterStateDir(env), "config-journal.json");
130
+ }
131
+
132
+ async function readJson(path: string): Promise<unknown | undefined> {
133
+ try {
134
+ return JSON.parse(await readFile(path, "utf8")) as unknown;
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
137
+ throw new AdapterConfigError(path + " is not valid JSON.");
138
+ }
139
+ }
140
+
141
+ async function writePrivateJson(path: string, value: unknown): Promise<void> {
142
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
143
+ const temporary = path + "." + process.pid + "." + crypto.randomUUID() + ".tmp";
144
+ try {
145
+ await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600, flag: "wx" });
146
+ await chmod(temporary, 0o600);
147
+ await rename(temporary, path);
148
+ await chmod(path, 0o600).catch(() => undefined);
149
+ } finally {
150
+ await rm(temporary, { force: true }).catch(() => undefined);
151
+ }
152
+ }
153
+
154
+ export async function readUpstreamConfig(env: NodeJS.ProcessEnv = process.env): Promise<Record<string, unknown>> {
155
+ const parsed = await readJson(getConfigPath(env));
156
+ if (parsed === undefined) return {};
157
+ const config = record(parsed);
158
+ if (!config) throw new AdapterConfigError("config.json must contain a JSON object.");
159
+ return config;
160
+ }
161
+
162
+ function providers(config: Record<string, unknown>): Record<string, unknown> {
163
+ const current = record(config.providers);
164
+ if (current) return current;
165
+ const next: Record<string, unknown> = {};
166
+ config.providers = next;
167
+ return next;
168
+ }
169
+
170
+ export function isManagedProviderRow(name: AdapterProviderId, value: unknown): boolean {
171
+ const row = record(value);
172
+ if (!row || row.adapter !== "openai-responses" || typeof row.baseUrl !== "string") return false;
173
+ if (row.apiKey !== undefined && row.apiKey !== "${" + ADAPTER_TOKEN_ENV + "}") return false;
174
+ try {
175
+ const url = new URL(row.baseUrl);
176
+ return url.protocol === "http:"
177
+ && isLoopback(url.hostname)
178
+ && url.pathname === "/" + name + "/v1"
179
+ && url.search === ""
180
+ && url.hash === "";
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ function isLegacyProviderRow(name: AdapterProviderId, value: unknown): boolean {
187
+ const row = record(value);
188
+ return row?.adapter === name;
189
+ }
190
+
191
+ function uniqueModels(metadata: AdapterModelMetadata | undefined): string[] {
192
+ return [...new Set(["default", ...(metadata?.models ?? [])].filter(Boolean))];
193
+ }
194
+
195
+ export function buildManagedProviderRow(
196
+ name: AdapterProviderId,
197
+ gatewayPort: number,
198
+ metadata: AdapterModelMetadata = { models: ["default"] },
199
+ ): Record<string, unknown> {
200
+ if (!Number.isInteger(gatewayPort) || gatewayPort < 1 || gatewayPort > 65_535) {
201
+ throw new AdapterConfigError("A loopback gateway port is required.");
202
+ }
203
+ return {
204
+ adapter: "openai-responses",
205
+ baseUrl: "http://127.0.0.1:" + gatewayPort + "/" + name + "/v1",
206
+ authMode: "key",
207
+ apiKey: "${" + ADAPTER_TOKEN_ENV + "}",
208
+ allowPrivateNetwork: true,
209
+ liveModels: true,
210
+ defaultModel: "default",
211
+ models: uniqueModels(metadata),
212
+ ...(metadata.modelDisplayNames ? { modelDisplayNames: metadata.modelDisplayNames } : {}),
213
+ ...(metadata.modelContextWindows ? { modelContextWindows: metadata.modelContextWindows } : {}),
214
+ ...(metadata.modelInputModalities ? { modelInputModalities: metadata.modelInputModalities } : {}),
215
+ ...(metadata.modelReasoningEfforts ? { modelReasoningEfforts: metadata.modelReasoningEfforts } : {}),
216
+ ...(metadata.modelDefaultReasoningEfforts
217
+ ? { modelDefaultReasoningEfforts: metadata.modelDefaultReasoningEfforts }
218
+ : {}),
219
+ };
220
+ }
221
+
222
+ async function readJournal(env: NodeJS.ProcessEnv): Promise<AdapterConfigJournal | undefined> {
223
+ const parsed = await readJson(adapterJournalPath(env));
224
+ if (parsed === undefined) return undefined;
225
+ const journal = record(parsed);
226
+ if (journal?.version !== 1 || !record(journal.priorRows)) {
227
+ throw new AdapterConfigError("The adapter configuration journal is malformed.");
228
+ }
229
+ return journal as unknown as AdapterConfigJournal;
230
+ }
231
+
232
+ export interface SyncManagedProviderRowsOptions {
233
+ env?: NodeJS.ProcessEnv;
234
+ gatewayPort: number;
235
+ metadata?: AdapterProviderMetadata;
236
+ defaultProvider?: AdapterProviderId;
237
+ }
238
+
239
+ /** Merge only the two managed provider rows and their reversible top-level markers. */
240
+ export async function syncManagedProviderRows(
241
+ options: SyncManagedProviderRowsOptions,
242
+ ): Promise<{ configPath: string; providerEnvToken?: string }> {
243
+ const env = options.env ?? process.env;
244
+ const config = await readUpstreamConfig(env);
245
+ const rows = providers(config);
246
+ const existingJournal = await readJournal(env);
247
+ const journal: AdapterConfigJournal = existingJournal ?? {
248
+ version: 1,
249
+ priorRows: {
250
+ "gemini-cli": Object.hasOwn(rows, "gemini-cli") ? structuredClone(rows["gemini-cli"]) : "absent",
251
+ "cline-cli": Object.hasOwn(rows, "cline-cli") ? structuredClone(rows["cline-cli"]) : "absent",
252
+ },
253
+ ...(typeof config.hostname === "string" ? { priorHostname: config.hostname } : {}),
254
+ ...(typeof config.defaultProvider === "string" ? { priorDefaultProvider: config.defaultProvider } : {}),
255
+ };
256
+
257
+ for (const name of ADAPTER_PROVIDER_IDS) {
258
+ const prior = journal.priorRows[name];
259
+ if (prior !== "absent" && !isManagedProviderRow(name, prior) && !isLegacyProviderRow(name, prior)) {
260
+ throw new AdapterConfigError(
261
+ "Provider " + name + " is user-owned and cannot be replaced by the CLI adapter.",
262
+ );
263
+ }
264
+ const current = rows[name];
265
+ if (existingJournal && current !== undefined && !isManagedProviderRow(name, current)) {
266
+ throw new AdapterConfigError(
267
+ "Provider " + name + " changed while the CLI adapter was attached; refusing to overwrite it.",
268
+ );
269
+ }
270
+ }
271
+
272
+ if (config.hostname === undefined && typeof config.host === "string" && config.host.trim()) {
273
+ journal.appliedHostname = config.host.trim();
274
+ config.hostname = config.host.trim();
275
+ }
276
+ if (options.defaultProvider) {
277
+ journal.appliedDefaultProvider = options.defaultProvider;
278
+ config.defaultProvider = options.defaultProvider;
279
+ }
280
+ for (const name of ADAPTER_PROVIDER_IDS) {
281
+ const nextRow = buildManagedProviderRow(
282
+ name,
283
+ options.gatewayPort,
284
+ options.metadata?.[name] ?? { models: ["default"] },
285
+ );
286
+ const current = record(rows[name]);
287
+ const preserved = current
288
+ ? Object.fromEntries(Object.entries(current).filter(([key]) => !Object.hasOwn(nextRow, key)))
289
+ : {};
290
+ rows[name] = { ...preserved, ...nextRow };
291
+ }
292
+ config.providers = rows;
293
+ await writePrivateJson(adapterJournalPath(env), journal);
294
+ await writePrivateJson(getConfigPath(env), config);
295
+ return {
296
+ configPath: getConfigPath(env),
297
+ ...(typeof config.apiAuthToken === "string" && config.apiAuthToken.trim()
298
+ ? { providerEnvToken: config.apiAuthToken.trim() }
299
+ : {}),
300
+ };
301
+ }
302
+
303
+ export async function restoreManagedProviderRows(
304
+ env: NodeJS.ProcessEnv = process.env,
305
+ ): Promise<{ restored: boolean; preservedUserRows: AdapterProviderId[] }> {
306
+ const journal = await readJournal(env);
307
+ if (!journal) return { restored: false, preservedUserRows: [] };
308
+ const config = await readUpstreamConfig(env);
309
+ const rows = providers(config);
310
+ const preservedUserRows: AdapterProviderId[] = [];
311
+ for (const name of ADAPTER_PROVIDER_IDS) {
312
+ const current = rows[name];
313
+ if (current !== undefined && !isManagedProviderRow(name, current)) {
314
+ preservedUserRows.push(name);
315
+ continue;
316
+ }
317
+ const prior = journal.priorRows[name];
318
+ if (prior === "absent") delete rows[name];
319
+ else rows[name] = structuredClone(prior);
320
+ }
321
+ if (journal.appliedHostname !== undefined && config.hostname === journal.appliedHostname) {
322
+ if (journal.priorHostname === undefined) delete config.hostname;
323
+ else config.hostname = journal.priorHostname;
324
+ }
325
+ if (journal.appliedDefaultProvider !== undefined && config.defaultProvider === journal.appliedDefaultProvider) {
326
+ if (journal.priorDefaultProvider === undefined) delete config.defaultProvider;
327
+ else config.defaultProvider = journal.priorDefaultProvider;
328
+ }
329
+ await writePrivateJson(getConfigPath(env), config);
330
+ await rm(adapterJournalPath(env), { force: true });
331
+ return { restored: true, preservedUserRows };
332
+ }
333
+
334
+ export async function setAdapterDefaultProvider(
335
+ provider: AdapterProviderId,
336
+ env: NodeJS.ProcessEnv = process.env,
337
+ ): Promise<void> {
338
+ const config = await readUpstreamConfig(env);
339
+ const journal = await readJournal(env);
340
+ if (journal) {
341
+ if (journal.appliedDefaultProvider === undefined) {
342
+ if (typeof config.defaultProvider === "string") journal.priorDefaultProvider = config.defaultProvider;
343
+ else delete journal.priorDefaultProvider;
344
+ }
345
+ journal.appliedDefaultProvider = provider;
346
+ await writePrivateJson(adapterJournalPath(env), journal);
347
+ }
348
+ config.defaultProvider = provider;
349
+ await writePrivateJson(getConfigPath(env), config);
350
+ }
351
+
352
+ export async function readAdapterRuntime(
353
+ env: NodeJS.ProcessEnv = process.env,
354
+ ): Promise<AdapterRuntimeState | undefined> {
355
+ const parsed = await readJson(adapterRuntimePath(env));
356
+ if (parsed === undefined) return undefined;
357
+ const state = record(parsed);
358
+ if (state?.version !== 1
359
+ || !Number.isInteger(state.pid)
360
+ || !Number.isInteger(state.port)
361
+ || state.host !== "127.0.0.1"
362
+ || state.tokenEnv !== ADAPTER_TOKEN_ENV) {
363
+ return undefined;
364
+ }
365
+ return state as unknown as AdapterRuntimeState;
366
+ }
367
+
368
+ export async function writeAdapterRuntime(
369
+ state: AdapterRuntimeState,
370
+ env: NodeJS.ProcessEnv = process.env,
371
+ ): Promise<void> {
372
+ await writePrivateJson(adapterRuntimePath(env), state);
373
+ }
374
+
375
+ export async function removeAdapterRuntimeIfPid(
376
+ pid: number,
377
+ env: NodeJS.ProcessEnv = process.env,
378
+ ): Promise<void> {
379
+ const state = await readAdapterRuntime(env);
380
+ if (state?.pid === pid) await rm(adapterRuntimePath(env), { force: true });
381
+ }
@@ -0,0 +1,121 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import type { RunningServer, ServerOptions } from "../server";
3
+ import {
4
+ startScopedProviderServer,
5
+ type ScopedServerOptions,
6
+ } from "./scoped-server";
7
+ import type { AdapterProviderId } from "./config";
8
+
9
+ const PROVIDER_PATHS: ReadonlyArray<[AdapterProviderId, string]> = [
10
+ ["gemini-cli", "/gemini-cli/v1"],
11
+ ["cline-cli", "/cline-cli/v1"],
12
+ ];
13
+
14
+ export interface AdapterGatewayOptions extends Omit<ScopedServerOptions, "config"> {
15
+ token: string;
16
+ startBackend?: typeof startScopedProviderServer;
17
+ }
18
+
19
+ export interface AdapterGateway {
20
+ readonly host: "127.0.0.1";
21
+ readonly port: number;
22
+ readonly unavailable: Partial<Record<AdapterProviderId, boolean>>;
23
+ stop(): Promise<void>;
24
+ }
25
+
26
+ function equalSecret(candidate: string | undefined, expected: string): boolean {
27
+ if (candidate === undefined) return false;
28
+ const left = Buffer.from(candidate);
29
+ const right = Buffer.from(expected);
30
+ return left.length === right.length && timingSafeEqual(left, right);
31
+ }
32
+
33
+ function requestToken(req: Request): string | undefined {
34
+ const authorization = req.headers.get("authorization");
35
+ if (authorization?.startsWith("Bearer ")) return authorization.slice(7);
36
+ return req.headers.get("x-api-key") ?? undefined;
37
+ }
38
+
39
+ function matchProvider(pathname: string): { provider: AdapterProviderId; path: string } | undefined {
40
+ for (const [provider, prefix] of PROVIDER_PATHS) {
41
+ if (pathname === prefix || pathname.startsWith(prefix + "/")) {
42
+ const suffix = pathname.slice(prefix.length);
43
+ return { provider, path: "/v1" + (suffix || "") };
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function startAdapterGateway(options: AdapterGatewayOptions): AdapterGateway {
50
+ const backends: Partial<Record<AdapterProviderId, RunningServer>> = {};
51
+ const unavailable: Partial<Record<AdapterProviderId, boolean>> = {};
52
+ const startBackend = options.startBackend ?? startScopedProviderServer;
53
+ for (const [provider] of PROVIDER_PATHS) {
54
+ try {
55
+ backends[provider] = startBackend(provider, options);
56
+ } catch {
57
+ unavailable[provider] = true;
58
+ }
59
+ }
60
+ let server: ReturnType<typeof Bun.serve>;
61
+ const handleRequest = async (req: Request): Promise<Response> => {
62
+ const url = new URL(req.url);
63
+ if (url.pathname === "/healthz") {
64
+ return Response.json({
65
+ ok: true,
66
+ service: "opencodex-cli-adapters",
67
+ pid: process.pid,
68
+ unavailable: Object.keys(unavailable),
69
+ });
70
+ }
71
+ const match = matchProvider(url.pathname);
72
+ if (!match) {
73
+ return Response.json({ error: { message: "Not found", type: "invalid_request_error" } }, { status: 404 });
74
+ }
75
+ if (!equalSecret(requestToken(req), options.token)) {
76
+ return Response.json({ error: { message: "Unauthorized", type: "authentication_error" } }, {
77
+ status: 401,
78
+ headers: { "www-authenticate": "Bearer" },
79
+ });
80
+ }
81
+ const backend = backends[match.provider];
82
+ if (!backend) {
83
+ return Response.json({
84
+ error: { message: "CLI adapter is unavailable.", type: "provider_unavailable" },
85
+ }, { status: 502 });
86
+ }
87
+ const headers = new Headers(req.headers);
88
+ headers.delete("authorization");
89
+ headers.delete("x-api-key");
90
+ headers.delete("host");
91
+ const target = "http://127.0.0.1:" + backend.port + match.path + url.search;
92
+ try {
93
+ const forwarded = new Request(target, {
94
+ method: req.method,
95
+ headers,
96
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
97
+ signal: req.signal,
98
+ redirect: "manual",
99
+ });
100
+ return await globalThis.fetch(forwarded);
101
+ } catch {
102
+ return Response.json({
103
+ error: { message: "CLI adapter request failed.", type: "provider_unavailable" },
104
+ }, { status: 502 });
105
+ }
106
+ };
107
+ server = Bun.serve({
108
+ hostname: "127.0.0.1",
109
+ port: 0,
110
+ fetch: handleRequest,
111
+ });
112
+ return {
113
+ host: "127.0.0.1",
114
+ port: server.port ?? 0,
115
+ unavailable,
116
+ async stop() {
117
+ await server.stop(true).catch(() => undefined);
118
+ await Promise.all(Object.values(backends).map(backend => backend?.stop(true).catch(() => undefined)));
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,20 @@
1
+ import { defaultConfig, type ProviderId, type RuntimeConfig } from "../config";
2
+ import { startServer, type RunningServer, type ServerOptions } from "../server";
3
+
4
+ export interface ScopedServerOptions extends Omit<ServerOptions, "providerScope"> {
5
+ config?: RuntimeConfig;
6
+ }
7
+
8
+ export function startScopedProviderServer(
9
+ provider: ProviderId,
10
+ options: ScopedServerOptions = {},
11
+ ): RunningServer {
12
+ const base = options.config ?? defaultConfig();
13
+ const config: RuntimeConfig = {
14
+ ...base,
15
+ port: 0,
16
+ host: "127.0.0.1",
17
+ defaultProvider: provider,
18
+ };
19
+ return startServer(config, { ...options, providerScope: provider });
20
+ }