@koreanhole/opencodex 2.96.0 → 2.98.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 +18 -4
- package/README.md +86 -7
- package/package.json +6 -1
- package/src/adapters/cli-agent/acp-client.ts +60 -3
- package/src/adapters/cli-agent/cline/acp.ts +1 -7
- package/src/adapters/cli-agent/cline/adapter.ts +54 -151
- package/src/adapters/cli-agent/cline/settings.ts +38 -260
- package/src/adapters/cli-agent/cline/stream.ts +2 -216
- package/src/adapters/cli-agent/gemini/acp.ts +168 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +211 -17
- package/src/adapters/cli-agent/gemini/live-models.ts +55 -6
- package/src/adapters/cli-agent/gemini/model-metadata.ts +387 -0
- package/src/adapters/cli-agent/gemini/runtime.ts +183 -0
- package/src/adapters/cli-agent/gemini/session-store.ts +202 -0
- package/src/adapters/cli-agent/protocol.ts +60 -9
- package/src/adapters/cli-agent/types.ts +1 -22
- package/src/cli/index.ts +43 -1
- package/src/lib/codex-catalog.ts +351 -0
- package/src/server/index.ts +49 -8
- package/src/server/responses.ts +120 -14
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +25 -1
- package/src/adapters/cli-agent/turn.ts +0 -307
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import type { OcxProviderModel } from "../types";
|
|
5
|
+
import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
|
|
6
|
+
import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
|
|
7
|
+
import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
8
|
+
import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
9
|
+
|
|
10
|
+
// Cline ACP descriptors expose no context source; this fallback is Cline-only.
|
|
11
|
+
const CATALOG_FALLBACK_CONTEXT = 128_000;
|
|
12
|
+
const BASE_INSTRUCTIONS = "You are a helpful coding assistant.";
|
|
13
|
+
const MANAGED_PREFIXES = ["cline-cli/", "gemini-cli/"] as const;
|
|
14
|
+
|
|
15
|
+
export interface CodexCatalogModel {
|
|
16
|
+
slug: string;
|
|
17
|
+
display_name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
default_reasoning_level: string;
|
|
20
|
+
supported_reasoning_levels: Array<{ effort: string; description: string }>;
|
|
21
|
+
shell_type: "unified_exec";
|
|
22
|
+
visibility: "list";
|
|
23
|
+
supported_in_api: true;
|
|
24
|
+
priority: number;
|
|
25
|
+
base_instructions: string;
|
|
26
|
+
web_search_tool_type: "text_and_image";
|
|
27
|
+
supports_search_tool: false;
|
|
28
|
+
tool_mode: "code_mode_only";
|
|
29
|
+
context_window: number;
|
|
30
|
+
max_context_window: number;
|
|
31
|
+
auto_compact_token_limit: number;
|
|
32
|
+
input_modalities: Array<"text" | "image" | "audio">;
|
|
33
|
+
node_repl_disabled: false;
|
|
34
|
+
node_repl_auto_review_required: false;
|
|
35
|
+
include_plugin_usage_instructions: false;
|
|
36
|
+
include_apps_usage_instructions: false;
|
|
37
|
+
include_skills_usage_instructions: false;
|
|
38
|
+
supports_reasoning_summaries: boolean;
|
|
39
|
+
default_reasoning_summary: string;
|
|
40
|
+
support_verbosity: boolean;
|
|
41
|
+
default_verbosity: "low";
|
|
42
|
+
apply_patch_tool_type: "freeform";
|
|
43
|
+
truncation_policy: { mode: "tokens"; limit: number };
|
|
44
|
+
supports_parallel_tool_calls: true;
|
|
45
|
+
supports_image_detail_original: false;
|
|
46
|
+
experimental_supported_tools: [];
|
|
47
|
+
effective_context_window_percent: number;
|
|
48
|
+
comp_hash: null;
|
|
49
|
+
multi_agent_version: "v1";
|
|
50
|
+
auto_review_model_override: null;
|
|
51
|
+
supports_reasoning_effort_updates: false;
|
|
52
|
+
use_responses_lite: false;
|
|
53
|
+
additional_speed_tiers: [];
|
|
54
|
+
service_tiers: [];
|
|
55
|
+
availability_nux: null;
|
|
56
|
+
upgrade: null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CodexCatalogDocument {
|
|
60
|
+
fetched_at: string;
|
|
61
|
+
etag: null;
|
|
62
|
+
client_version: string;
|
|
63
|
+
identity: string;
|
|
64
|
+
models: CodexCatalogModel[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CodexCatalogDiscoveryOptions {
|
|
68
|
+
env?: NodeJS.ProcessEnv;
|
|
69
|
+
clineDeps?: ClineTurnDeps;
|
|
70
|
+
geminiDeps?: GeminiTurnDeps;
|
|
71
|
+
geminiBinary?: string;
|
|
72
|
+
geminiPackageRoot?: string;
|
|
73
|
+
now?: Date;
|
|
74
|
+
forceRefresh?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CodexCatalogModels {
|
|
78
|
+
models: CodexCatalogModel[];
|
|
79
|
+
warnings: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type CodexCatalogSyncResult =
|
|
83
|
+
| {
|
|
84
|
+
ok: true;
|
|
85
|
+
path: string;
|
|
86
|
+
configured: boolean;
|
|
87
|
+
written: boolean;
|
|
88
|
+
modelCount: number;
|
|
89
|
+
geminiModelCount: number;
|
|
90
|
+
reasoningModelCount: number;
|
|
91
|
+
reloadRequired: true;
|
|
92
|
+
warnings: string[];
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
ok: false;
|
|
96
|
+
path?: string;
|
|
97
|
+
reason: "unsafe_path" | "malformed_catalog" | "write_failed" | "discovery_failed";
|
|
98
|
+
message: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
102
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
103
|
+
? value as Record<string, unknown>
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function encodeRoutedId(id: string): string {
|
|
108
|
+
return id.replaceAll("/", "-");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", model: OcxProviderModel, index: number): CodexCatalogModel {
|
|
112
|
+
const levels = (model.supportedReasoningLevels ?? []).map(level => ({
|
|
113
|
+
effort: level.effort,
|
|
114
|
+
description: level.description,
|
|
115
|
+
}));
|
|
116
|
+
const defaultLevel = model.defaultReasoningLevel ?? levels[0]?.effort ?? "low";
|
|
117
|
+
const isDefault = model.id === "default";
|
|
118
|
+
const providerLabel = providerId === "gemini-cli" ? "Gemini CLI" : "Cline CLI";
|
|
119
|
+
const contextWindow = model.contextWindowTokens ?? (providerId === "cline-cli" ? CATALOG_FALLBACK_CONTEXT : undefined);
|
|
120
|
+
if (contextWindow === undefined) {
|
|
121
|
+
throw new Error(`Gemini model "${model.id}" has no runtime context window.`);
|
|
122
|
+
}
|
|
123
|
+
const deterministicThreshold = providerId === "gemini-cli"
|
|
124
|
+
? model.compressionThreshold
|
|
125
|
+
: model.compressionThreshold ?? 0.9;
|
|
126
|
+
const compactLimit = deterministicThreshold !== undefined
|
|
127
|
+
? Math.max(1, Math.floor(contextWindow * deterministicThreshold))
|
|
128
|
+
: contextWindow;
|
|
129
|
+
const effectivePercent = deterministicThreshold !== undefined
|
|
130
|
+
? Math.max(1, Math.min(100, Math.round(deterministicThreshold * 100)))
|
|
131
|
+
: 100;
|
|
132
|
+
return {
|
|
133
|
+
slug: `${providerId}/${encodeRoutedId(model.id)}`,
|
|
134
|
+
display_name: model.name ?? (isDefault ? `${providerLabel} default` : model.id),
|
|
135
|
+
description: model.description ?? `Routed via opencodex -> ${providerLabel}.`,
|
|
136
|
+
default_reasoning_level: levels.length > 0 ? defaultLevel : "low",
|
|
137
|
+
supported_reasoning_levels: levels,
|
|
138
|
+
shell_type: "unified_exec",
|
|
139
|
+
visibility: "list",
|
|
140
|
+
supported_in_api: true,
|
|
141
|
+
priority: 10 + index,
|
|
142
|
+
base_instructions: BASE_INSTRUCTIONS,
|
|
143
|
+
web_search_tool_type: "text_and_image",
|
|
144
|
+
supports_search_tool: false,
|
|
145
|
+
tool_mode: "code_mode_only",
|
|
146
|
+
context_window: contextWindow,
|
|
147
|
+
max_context_window: model.maxContextWindowTokens ?? contextWindow,
|
|
148
|
+
auto_compact_token_limit: compactLimit,
|
|
149
|
+
input_modalities: [...(model.inputModalities ?? ["text"])],
|
|
150
|
+
node_repl_disabled: false,
|
|
151
|
+
node_repl_auto_review_required: false,
|
|
152
|
+
include_plugin_usage_instructions: false,
|
|
153
|
+
include_apps_usage_instructions: false,
|
|
154
|
+
include_skills_usage_instructions: false,
|
|
155
|
+
supports_reasoning_summaries: levels.length > 0,
|
|
156
|
+
default_reasoning_summary: levels.length > 0 ? "auto" : "none",
|
|
157
|
+
support_verbosity: false,
|
|
158
|
+
default_verbosity: "low",
|
|
159
|
+
apply_patch_tool_type: "freeform",
|
|
160
|
+
truncation_policy: { mode: "tokens", limit: 10_000 },
|
|
161
|
+
supports_parallel_tool_calls: true,
|
|
162
|
+
supports_image_detail_original: false,
|
|
163
|
+
experimental_supported_tools: [],
|
|
164
|
+
effective_context_window_percent: effectivePercent,
|
|
165
|
+
comp_hash: null,
|
|
166
|
+
multi_agent_version: "v1",
|
|
167
|
+
auto_review_model_override: null,
|
|
168
|
+
supports_reasoning_effort_updates: false,
|
|
169
|
+
use_responses_lite: false,
|
|
170
|
+
additional_speed_tiers: [],
|
|
171
|
+
service_tiers: [],
|
|
172
|
+
availability_nux: null,
|
|
173
|
+
upgrade: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function defaultModel(): OcxProviderModel {
|
|
178
|
+
return { id: "default" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizedModels(models: readonly OcxProviderModel[]): OcxProviderModel[] {
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
const out: OcxProviderModel[] = [];
|
|
184
|
+
for (const model of [defaultModel(), ...models]) {
|
|
185
|
+
if (!seen.has(model.id)) {
|
|
186
|
+
seen.add(model.id);
|
|
187
|
+
out.push(model);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function discoverCodexCatalogModels(options: CodexCatalogDiscoveryOptions = {}): Promise<CodexCatalogModels> {
|
|
194
|
+
const env = options.env ?? process.env;
|
|
195
|
+
const [cline, gemini] = await Promise.all([
|
|
196
|
+
discoverClineLiveModels({ env, ...(options.clineDeps ? { deps: options.clineDeps } : {}) }),
|
|
197
|
+
discoverGeminiLiveModels({
|
|
198
|
+
env,
|
|
199
|
+
...(options.geminiBinary ? { binary: options.geminiBinary } : {}),
|
|
200
|
+
...(options.geminiDeps ? { deps: options.geminiDeps } : {}),
|
|
201
|
+
...(options.geminiPackageRoot ? { packageRoot: options.geminiPackageRoot } : {}),
|
|
202
|
+
...(options.forceRefresh ? { forceRefresh: true } : {}),
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
const warnings: string[] = [];
|
|
206
|
+
if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") warnings.push(`Cline: ${cline.detail}`);
|
|
207
|
+
if (!gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") warnings.push(`Gemini: ${gemini.detail}`);
|
|
208
|
+
if (gemini.ok && gemini.metadataWarning) warnings.push(`Gemini metadata: ${gemini.metadataWarning}`);
|
|
209
|
+
const clineModels = normalizedModels(cline.ok ? cline.models.map(id => ({ id })) : []);
|
|
210
|
+
const geminiModels = normalizedModels(gemini.ok ? gemini.modelInfo : []);
|
|
211
|
+
const geminiDefault = geminiModels.find(model => model.id === "default");
|
|
212
|
+
const geminiContexts = geminiModels.flatMap(model => model.contextWindowTokens !== undefined ? [model.contextWindowTokens] : []);
|
|
213
|
+
if (geminiDefault && geminiContexts.length > 0) {
|
|
214
|
+
const conservative = Math.min(...geminiContexts);
|
|
215
|
+
geminiDefault.contextWindowTokens = conservative;
|
|
216
|
+
geminiDefault.maxContextWindowTokens = conservative;
|
|
217
|
+
geminiDefault.compressionThreshold = geminiModels.find(model => model.compressionThreshold !== undefined)?.compressionThreshold;
|
|
218
|
+
const modalities = geminiModels.flatMap(model => model.inputModalities ?? []);
|
|
219
|
+
geminiDefault.inputModalities = [...new Set(modalities.length > 0 ? modalities : ["text" as const])]
|
|
220
|
+
.filter((value): value is "text" | "image" | "audio" => value === "text" || value === "image" || value === "audio");
|
|
221
|
+
}
|
|
222
|
+
const models: CodexCatalogModel[] = [];
|
|
223
|
+
for (const model of clineModels) {
|
|
224
|
+
models.push(buildCodexCatalogModel("cline-cli", model, models.length));
|
|
225
|
+
}
|
|
226
|
+
for (const model of geminiModels) {
|
|
227
|
+
if (model.contextWindowTokens === undefined) {
|
|
228
|
+
warnings.push(`Gemini model "${model.id}" has no runtime context window; omitted from the Codex catalog.`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
models.push(buildCodexCatalogModel("gemini-cli", model, models.length));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
models,
|
|
235
|
+
warnings,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function buildCodexCatalogDocument(
|
|
240
|
+
models: CodexCatalogModel[],
|
|
241
|
+
now = new Date(),
|
|
242
|
+
preserved: CodexCatalogModel[] = [],
|
|
243
|
+
): CodexCatalogDocument {
|
|
244
|
+
return {
|
|
245
|
+
fetched_at: now.toISOString(),
|
|
246
|
+
etag: null,
|
|
247
|
+
client_version: "0.165.0",
|
|
248
|
+
identity: "opencodex",
|
|
249
|
+
models: [...preserved, ...models],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readRootTomlString(text: string, key: string): string | undefined {
|
|
254
|
+
const firstTable = text.search(/^\s*\[/m);
|
|
255
|
+
const root = firstTable < 0 ? text : text.slice(0, firstTable);
|
|
256
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(root);
|
|
257
|
+
return match?.[1]?.trim() || undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function writeCodexCatalog(
|
|
261
|
+
path: string,
|
|
262
|
+
document: CodexCatalogDocument,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const parent = dirname(path);
|
|
265
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
266
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
267
|
+
try {
|
|
268
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
269
|
+
await chmod(temporary, 0o600);
|
|
270
|
+
await rename(temporary, path);
|
|
271
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
272
|
+
} finally {
|
|
273
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function managedSlug(slug: unknown): boolean {
|
|
278
|
+
return typeof slug === "string" && MANAGED_PREFIXES.some(prefix => slug.startsWith(prefix));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function syncCodexCatalog(
|
|
282
|
+
options: CodexCatalogDiscoveryOptions & {
|
|
283
|
+
path?: string;
|
|
284
|
+
discover?: typeof discoverCodexCatalogModels;
|
|
285
|
+
} = {},
|
|
286
|
+
): Promise<CodexCatalogSyncResult> {
|
|
287
|
+
const env = options.env ?? process.env;
|
|
288
|
+
const codexHome = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
289
|
+
let configuredPath: string | undefined;
|
|
290
|
+
try {
|
|
291
|
+
configuredPath = readRootTomlString(await readFile(join(codexHome, "config.toml"), "utf8"), "model_catalog_json");
|
|
292
|
+
} catch {
|
|
293
|
+
configuredPath = undefined;
|
|
294
|
+
}
|
|
295
|
+
const configured = configuredPath !== undefined;
|
|
296
|
+
const selected = options.path
|
|
297
|
+
? (isAbsolute(options.path) ? options.path : resolve(options.path))
|
|
298
|
+
: configuredPath
|
|
299
|
+
? resolve(codexHome, configuredPath)
|
|
300
|
+
: join(codexHome, "opencodex-catalog.json");
|
|
301
|
+
if (configured && basename(selected) !== "opencodex-catalog.json") {
|
|
302
|
+
return { ok: false, path: selected, reason: "unsafe_path", message: "Configured model_catalog_json is not an opencodex catalog; refusing to overwrite it." };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let existing: Record<string, unknown> = {};
|
|
306
|
+
try {
|
|
307
|
+
const parsed = asRecord(JSON.parse(await readFile(selected, "utf8")));
|
|
308
|
+
if (!parsed) return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog is not a JSON object." };
|
|
309
|
+
existing = parsed;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
312
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog could not be parsed; it was preserved." };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let discovered: CodexCatalogModels;
|
|
317
|
+
try {
|
|
318
|
+
discovered = await (options.discover ?? discoverCodexCatalogModels)({ ...options, forceRefresh: true });
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return { ok: false, reason: "discovery_failed", message: error instanceof Error ? error.message : String(error) };
|
|
321
|
+
}
|
|
322
|
+
const existingModels = Array.isArray(existing.models) ? existing.models : [];
|
|
323
|
+
if (existing.models !== undefined && !Array.isArray(existing.models)) {
|
|
324
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog models field is not an array; it was preserved." };
|
|
325
|
+
}
|
|
326
|
+
if (existingModels.some(model => !asRecord(model))) {
|
|
327
|
+
return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog contains a non-object model row; it was preserved." };
|
|
328
|
+
}
|
|
329
|
+
const preserved = existingModels.filter(model => !managedSlug(asRecord(model)?.slug));
|
|
330
|
+
const document = buildCodexCatalogDocument(
|
|
331
|
+
discovered.models,
|
|
332
|
+
options.now ?? new Date(),
|
|
333
|
+
preserved as CodexCatalogModel[],
|
|
334
|
+
);
|
|
335
|
+
try {
|
|
336
|
+
await writeCodexCatalog(selected, document);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return { ok: false, path: selected, reason: "write_failed", message: error instanceof Error ? error.message : String(error) };
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
path: selected,
|
|
343
|
+
configured,
|
|
344
|
+
written: true,
|
|
345
|
+
modelCount: document.models.length,
|
|
346
|
+
geminiModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/")).length,
|
|
347
|
+
reasoningModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/") && (model.supported_reasoning_levels?.length ?? 0) > 0).length,
|
|
348
|
+
reloadRequired: true,
|
|
349
|
+
warnings: discovered.warnings,
|
|
350
|
+
};
|
|
351
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -12,8 +12,14 @@ import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
|
12
12
|
import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
13
13
|
import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
|
|
14
14
|
import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
|
|
15
|
-
import {
|
|
15
|
+
import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
|
|
16
|
+
import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, UnsupportedOptionError, type ProviderModels } from "./responses";
|
|
16
17
|
import { handleCountTokens, handleMessages } from "./messages";
|
|
18
|
+
import {
|
|
19
|
+
createWebSocketHandlers,
|
|
20
|
+
type WebSocketData,
|
|
21
|
+
type WebSocketRequestObservation,
|
|
22
|
+
} from "./websocket";
|
|
17
23
|
|
|
18
24
|
const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
19
25
|
const REQUEST_TIMEOUT_MS = 300_000;
|
|
@@ -22,6 +28,8 @@ export interface ServerOptions {
|
|
|
22
28
|
env?: NodeJS.ProcessEnv;
|
|
23
29
|
clineDeps?: ClineTurnDeps;
|
|
24
30
|
geminiDeps?: GeminiTurnDeps;
|
|
31
|
+
geminiPackageRoot?: string;
|
|
32
|
+
onWebSocketRequest?: (observation: WebSocketRequestObservation) => void;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export interface RunningServer {
|
|
@@ -64,6 +72,9 @@ async function boundedJson(req: Request): Promise<unknown> {
|
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
function errorResponse(error: unknown): Response {
|
|
75
|
+
if (error instanceof UnsupportedOptionError) {
|
|
76
|
+
return Response.json({ error: { message: error.message, type: error.type, code: error.code } }, { status: error.status });
|
|
77
|
+
}
|
|
67
78
|
if (error instanceof UnsupportedMediaError) {
|
|
68
79
|
return Response.json({ error: { message: error.message, type: error.type, code: error.code } }, { status: error.status });
|
|
69
80
|
}
|
|
@@ -80,10 +91,11 @@ async function discoverModels(
|
|
|
80
91
|
env: NodeJS.ProcessEnv,
|
|
81
92
|
clineDeps: ClineTurnDeps | undefined,
|
|
82
93
|
geminiDeps: GeminiTurnDeps | undefined,
|
|
94
|
+
geminiPackageRoot: string | undefined,
|
|
83
95
|
): Promise<ProviderModels> {
|
|
84
96
|
const [cline, gemini] = await Promise.all([
|
|
85
97
|
discoverClineLiveModels({ env, ...(clineDeps ? { deps: clineDeps } : {}) }),
|
|
86
|
-
discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}) }),
|
|
98
|
+
discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}), ...(geminiPackageRoot ? { packageRoot: geminiPackageRoot } : {}) }),
|
|
87
99
|
]);
|
|
88
100
|
if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
|
|
89
101
|
console.warn(`[opencodex] Cline model discovery degraded [${cline.reason}]: ${cline.detail}`);
|
|
@@ -93,7 +105,9 @@ async function discoverModels(
|
|
|
93
105
|
}
|
|
94
106
|
return {
|
|
95
107
|
"cline-cli": cline.ok ? ["default", ...cline.models.filter(id => id !== "default")] : ["default"],
|
|
96
|
-
"gemini-cli": gemini.ok
|
|
108
|
+
"gemini-cli": gemini.ok
|
|
109
|
+
? ["default", ...gemini.modelInfo.filter(model => model.id !== "default")]
|
|
110
|
+
: ["default"],
|
|
97
111
|
};
|
|
98
112
|
}
|
|
99
113
|
|
|
@@ -109,7 +123,7 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
109
123
|
const token = apiToken(config, env);
|
|
110
124
|
let server: ReturnType<typeof Bun.serve>;
|
|
111
125
|
|
|
112
|
-
const fetch = async (req: Request): Promise<Response> => {
|
|
126
|
+
const fetch = async (req: Request, bunServer: Bun.Server<WebSocketData>): Promise<Response | undefined> => {
|
|
113
127
|
const url = new URL(req.url);
|
|
114
128
|
if (url.pathname === "/healthz") {
|
|
115
129
|
return Response.json({ ok: true, service: "opencodex", version: packageInfo.version });
|
|
@@ -117,21 +131,35 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
117
131
|
if (!authorized(req, token)) {
|
|
118
132
|
return Response.json({ error: { message: "Unauthorized", type: "authentication_error", code: "unauthorized" } }, { status: 401 });
|
|
119
133
|
}
|
|
134
|
+
if (req.method === "GET" && url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
135
|
+
const data: WebSocketData = { headers: req.headers, queue: Promise.resolve(), closed: false };
|
|
136
|
+
if (bunServer.upgrade(req, { data })) return undefined;
|
|
137
|
+
return new Response("WebSocket upgrade failed.", { status: 400 });
|
|
138
|
+
}
|
|
120
139
|
try {
|
|
121
140
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
122
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
141
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
123
142
|
return Response.json({ object: "list", data: modelList(known) });
|
|
124
143
|
}
|
|
144
|
+
if (req.method === "GET" && url.pathname === "/v1/model-catalog") {
|
|
145
|
+
const discovered = await discoverCodexCatalogModels({
|
|
146
|
+
env,
|
|
147
|
+
...(options.clineDeps ? { clineDeps: options.clineDeps } : {}),
|
|
148
|
+
...(options.geminiDeps ? { geminiDeps: options.geminiDeps } : {}),
|
|
149
|
+
...(options.geminiPackageRoot ? { geminiPackageRoot: options.geminiPackageRoot } : {}),
|
|
150
|
+
});
|
|
151
|
+
return Response.json(buildCodexCatalogDocument(discovered.models));
|
|
152
|
+
}
|
|
125
153
|
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
126
154
|
const body = await boundedJson(req);
|
|
127
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
155
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
128
156
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
129
157
|
const incoming: IncomingMeta = { headers: req.headers, abortSignal: signal };
|
|
130
158
|
return await handleResponses(body, incoming, known, turnDeps, config.defaultProvider);
|
|
131
159
|
}
|
|
132
160
|
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
|
133
161
|
const body = await boundedJson(req);
|
|
134
|
-
const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
|
|
162
|
+
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
135
163
|
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
136
164
|
return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, config.defaultProvider);
|
|
137
165
|
}
|
|
@@ -144,7 +172,20 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
144
172
|
}
|
|
145
173
|
};
|
|
146
174
|
|
|
147
|
-
|
|
175
|
+
const websocketContext = {
|
|
176
|
+
knownModels: () => discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot),
|
|
177
|
+
deps: turnDeps,
|
|
178
|
+
defaultProvider: config.defaultProvider,
|
|
179
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
180
|
+
...(options.onWebSocketRequest ? { onRequest: options.onWebSocketRequest } : {}),
|
|
181
|
+
};
|
|
182
|
+
server = Bun.serve({
|
|
183
|
+
hostname: config.host,
|
|
184
|
+
port: config.port,
|
|
185
|
+
idleTimeout: 255,
|
|
186
|
+
fetch,
|
|
187
|
+
websocket: createWebSocketHandlers(websocketContext),
|
|
188
|
+
});
|
|
148
189
|
return {
|
|
149
190
|
port: server.port ?? config.port,
|
|
150
191
|
host: config.host,
|