@koreanhole/opencodex 2.97.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 +4 -2
- package/README.md +51 -4
- package/package.json +5 -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 +141 -9
- package/src/adapters/cli-agent/gemini/adapter.ts +179 -16
- package/src/adapters/cli-agent/gemini/live-models.ts +32 -3
- package/src/adapters/cli-agent/gemini/model-metadata.ts +100 -4
- 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/lib/codex-catalog.ts +46 -13
- package/src/server/index.ts +30 -3
- package/src/server/responses.ts +91 -9
- package/src/server/websocket.ts +261 -0
- package/src/types.ts +12 -1
- package/src/adapters/cli-agent/turn.ts +0 -307
package/src/lib/codex-catalog.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-mode
|
|
|
7
7
|
import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
8
8
|
import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
9
9
|
|
|
10
|
+
// Cline ACP descriptors expose no context source; this fallback is Cline-only.
|
|
10
11
|
const CATALOG_FALLBACK_CONTEXT = 128_000;
|
|
11
12
|
const BASE_INSTRUCTIONS = "You are a helpful coding assistant.";
|
|
12
13
|
const MANAGED_PREFIXES = ["cline-cli/", "gemini-cli/"] as const;
|
|
@@ -28,7 +29,7 @@ export interface CodexCatalogModel {
|
|
|
28
29
|
context_window: number;
|
|
29
30
|
max_context_window: number;
|
|
30
31
|
auto_compact_token_limit: number;
|
|
31
|
-
input_modalities:
|
|
32
|
+
input_modalities: Array<"text" | "image" | "audio">;
|
|
32
33
|
node_repl_disabled: false;
|
|
33
34
|
node_repl_auto_review_required: false;
|
|
34
35
|
include_plugin_usage_instructions: false;
|
|
@@ -36,14 +37,14 @@ export interface CodexCatalogModel {
|
|
|
36
37
|
include_skills_usage_instructions: false;
|
|
37
38
|
supports_reasoning_summaries: boolean;
|
|
38
39
|
default_reasoning_summary: string;
|
|
39
|
-
support_verbosity:
|
|
40
|
+
support_verbosity: boolean;
|
|
40
41
|
default_verbosity: "low";
|
|
41
42
|
apply_patch_tool_type: "freeform";
|
|
42
43
|
truncation_policy: { mode: "tokens"; limit: number };
|
|
43
44
|
supports_parallel_tool_calls: true;
|
|
44
45
|
supports_image_detail_original: false;
|
|
45
46
|
experimental_supported_tools: [];
|
|
46
|
-
effective_context_window_percent:
|
|
47
|
+
effective_context_window_percent: number;
|
|
47
48
|
comp_hash: null;
|
|
48
49
|
multi_agent_version: "v1";
|
|
49
50
|
auto_review_model_override: null;
|
|
@@ -115,6 +116,19 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
|
|
|
115
116
|
const defaultLevel = model.defaultReasoningLevel ?? levels[0]?.effort ?? "low";
|
|
116
117
|
const isDefault = model.id === "default";
|
|
117
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;
|
|
118
132
|
return {
|
|
119
133
|
slug: `${providerId}/${encodeRoutedId(model.id)}`,
|
|
120
134
|
display_name: model.name ?? (isDefault ? `${providerLabel} default` : model.id),
|
|
@@ -129,10 +143,10 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
|
|
|
129
143
|
web_search_tool_type: "text_and_image",
|
|
130
144
|
supports_search_tool: false,
|
|
131
145
|
tool_mode: "code_mode_only",
|
|
132
|
-
context_window:
|
|
133
|
-
max_context_window:
|
|
134
|
-
auto_compact_token_limit:
|
|
135
|
-
input_modalities: ["text"],
|
|
146
|
+
context_window: contextWindow,
|
|
147
|
+
max_context_window: model.maxContextWindowTokens ?? contextWindow,
|
|
148
|
+
auto_compact_token_limit: compactLimit,
|
|
149
|
+
input_modalities: [...(model.inputModalities ?? ["text"])],
|
|
136
150
|
node_repl_disabled: false,
|
|
137
151
|
node_repl_auto_review_required: false,
|
|
138
152
|
include_plugin_usage_instructions: false,
|
|
@@ -140,14 +154,14 @@ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", m
|
|
|
140
154
|
include_skills_usage_instructions: false,
|
|
141
155
|
supports_reasoning_summaries: levels.length > 0,
|
|
142
156
|
default_reasoning_summary: levels.length > 0 ? "auto" : "none",
|
|
143
|
-
support_verbosity:
|
|
157
|
+
support_verbosity: false,
|
|
144
158
|
default_verbosity: "low",
|
|
145
159
|
apply_patch_tool_type: "freeform",
|
|
146
160
|
truncation_policy: { mode: "tokens", limit: 10_000 },
|
|
147
161
|
supports_parallel_tool_calls: true,
|
|
148
162
|
supports_image_detail_original: false,
|
|
149
163
|
experimental_supported_tools: [],
|
|
150
|
-
effective_context_window_percent:
|
|
164
|
+
effective_context_window_percent: effectivePercent,
|
|
151
165
|
comp_hash: null,
|
|
152
166
|
multi_agent_version: "v1",
|
|
153
167
|
auto_review_model_override: null,
|
|
@@ -194,11 +208,30 @@ export async function discoverCodexCatalogModels(options: CodexCatalogDiscoveryO
|
|
|
194
208
|
if (gemini.ok && gemini.metadataWarning) warnings.push(`Gemini metadata: ${gemini.metadataWarning}`);
|
|
195
209
|
const clineModels = normalizedModels(cline.ok ? cline.models.map(id => ({ id })) : []);
|
|
196
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
|
+
}
|
|
197
233
|
return {
|
|
198
|
-
models
|
|
199
|
-
...clineModels.map((model, index) => buildCodexCatalogModel("cline-cli", model, index)),
|
|
200
|
-
...geminiModels.map((model, index) => buildCodexCatalogModel("gemini-cli", model, clineModels.length + index)),
|
|
201
|
-
],
|
|
234
|
+
models,
|
|
202
235
|
warnings,
|
|
203
236
|
};
|
|
204
237
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -13,8 +13,13 @@ 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
15
|
import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
|
|
16
|
-
import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, type ProviderModels } from "./responses";
|
|
16
|
+
import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, UnsupportedOptionError, type ProviderModels } from "./responses";
|
|
17
17
|
import { handleCountTokens, handleMessages } from "./messages";
|
|
18
|
+
import {
|
|
19
|
+
createWebSocketHandlers,
|
|
20
|
+
type WebSocketData,
|
|
21
|
+
type WebSocketRequestObservation,
|
|
22
|
+
} from "./websocket";
|
|
18
23
|
|
|
19
24
|
const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
20
25
|
const REQUEST_TIMEOUT_MS = 300_000;
|
|
@@ -24,6 +29,7 @@ export interface ServerOptions {
|
|
|
24
29
|
clineDeps?: ClineTurnDeps;
|
|
25
30
|
geminiDeps?: GeminiTurnDeps;
|
|
26
31
|
geminiPackageRoot?: string;
|
|
32
|
+
onWebSocketRequest?: (observation: WebSocketRequestObservation) => void;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface RunningServer {
|
|
@@ -66,6 +72,9 @@ async function boundedJson(req: Request): Promise<unknown> {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
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
|
+
}
|
|
69
78
|
if (error instanceof UnsupportedMediaError) {
|
|
70
79
|
return Response.json({ error: { message: error.message, type: error.type, code: error.code } }, { status: error.status });
|
|
71
80
|
}
|
|
@@ -114,7 +123,7 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
114
123
|
const token = apiToken(config, env);
|
|
115
124
|
let server: ReturnType<typeof Bun.serve>;
|
|
116
125
|
|
|
117
|
-
const fetch = async (req: Request): Promise<Response> => {
|
|
126
|
+
const fetch = async (req: Request, bunServer: Bun.Server<WebSocketData>): Promise<Response | undefined> => {
|
|
118
127
|
const url = new URL(req.url);
|
|
119
128
|
if (url.pathname === "/healthz") {
|
|
120
129
|
return Response.json({ ok: true, service: "opencodex", version: packageInfo.version });
|
|
@@ -122,6 +131,11 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
122
131
|
if (!authorized(req, token)) {
|
|
123
132
|
return Response.json({ error: { message: "Unauthorized", type: "authentication_error", code: "unauthorized" } }, { status: 401 });
|
|
124
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
|
+
}
|
|
125
139
|
try {
|
|
126
140
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
127
141
|
const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
|
|
@@ -158,7 +172,20 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
|
|
|
158
172
|
}
|
|
159
173
|
};
|
|
160
174
|
|
|
161
|
-
|
|
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
|
+
});
|
|
162
189
|
return {
|
|
163
190
|
port: server.port ?? config.port,
|
|
164
191
|
host: config.host,
|
package/src/server/responses.ts
CHANGED
|
@@ -49,6 +49,15 @@ export class UnsupportedMediaError extends RequestValidationError {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export class UnsupportedOptionError extends RequestValidationError {
|
|
53
|
+
readonly code = "unsupported_option";
|
|
54
|
+
|
|
55
|
+
constructor(message: string) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "UnsupportedOptionError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
function record(value: unknown): Record<string, unknown> | undefined {
|
|
53
62
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
54
63
|
? value as Record<string, unknown>
|
|
@@ -87,12 +96,42 @@ function assertNoMedia(value: unknown): void {
|
|
|
87
96
|
for (const part of value) {
|
|
88
97
|
const row = record(part);
|
|
89
98
|
if (!row) continue;
|
|
90
|
-
if (row.type === "
|
|
99
|
+
if (row.type === "input_audio" || row.type === "video_url") {
|
|
91
100
|
throw new UnsupportedMediaError();
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
function userContent(content: unknown): string | Array<{ type: "text"; text: string } | { type: "image"; imageUrl?: string; mimeType?: string }> {
|
|
106
|
+
if (typeof content === "string") return content;
|
|
107
|
+
if (!Array.isArray(content)) return "";
|
|
108
|
+
const parts: Array<{ type: "text"; text: string } | { type: "image"; imageUrl?: string; mimeType?: string }> = [];
|
|
109
|
+
for (const raw of content) {
|
|
110
|
+
const row = record(raw);
|
|
111
|
+
if (!row) continue;
|
|
112
|
+
if (row.type === "input_text" || row.type === "text" || row.type === "output_text") {
|
|
113
|
+
if (typeof row.text === "string") parts.push({ type: "text", text: row.text });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (row.type === "input_image" || row.type === "image_url") {
|
|
117
|
+
const imageUrl = typeof row.image_url === "string"
|
|
118
|
+
? row.image_url
|
|
119
|
+
: typeof row.image_url === "object" && row.image_url && typeof (row.image_url as Record<string, unknown>).url === "string"
|
|
120
|
+
? String((row.image_url as Record<string, unknown>).url)
|
|
121
|
+
: undefined;
|
|
122
|
+
if (!imageUrl) throw new UnsupportedMediaError();
|
|
123
|
+
parts.push({
|
|
124
|
+
type: "image",
|
|
125
|
+
imageUrl,
|
|
126
|
+
...(typeof row.mime_type === "string" ? { mimeType: row.mime_type } : {}),
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (row.type === "input_audio" || row.type === "video_url") throw new UnsupportedMediaError();
|
|
131
|
+
}
|
|
132
|
+
return parts;
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
function parseToolChoice(value: unknown): OcxToolChoice | undefined {
|
|
97
136
|
if (value === undefined || value === null) return undefined;
|
|
98
137
|
if (value === "auto" || value === "none" || value === "required") return { type: value };
|
|
@@ -173,7 +212,7 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
173
212
|
} else if (role === "assistant") {
|
|
174
213
|
messages.push({ role: "assistant", content: [{ type: "text", text: textContent(content) }], timestamp: Date.now() });
|
|
175
214
|
} else {
|
|
176
|
-
messages.push({ role: "user", content:
|
|
215
|
+
messages.push({ role: "user", content: userContent(content), timestamp: Date.now() });
|
|
177
216
|
}
|
|
178
217
|
return;
|
|
179
218
|
}
|
|
@@ -235,6 +274,29 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
235
274
|
...(typeof row.parallel_tool_calls === "boolean" ? { parallelToolCalls: row.parallel_tool_calls } : {}),
|
|
236
275
|
...(typeof record(row.reasoning)?.effort === "string" ? { reasoning: record(row.reasoning)!.effort as string } : {}),
|
|
237
276
|
};
|
|
277
|
+
const previousResponseId = typeof row.previous_response_id === "string" && row.previous_response_id.trim()
|
|
278
|
+
? row.previous_response_id.trim()
|
|
279
|
+
: undefined;
|
|
280
|
+
const promptCacheKey = typeof row.prompt_cache_key === "string" && row.prompt_cache_key.trim()
|
|
281
|
+
? row.prompt_cache_key.trim()
|
|
282
|
+
: undefined;
|
|
283
|
+
const ignoredOptions = [
|
|
284
|
+
...(typeof row.max_output_tokens === "number" ? ["max_output_tokens"] : []),
|
|
285
|
+
...(typeof row.temperature === "number" ? ["temperature"] : []),
|
|
286
|
+
...(typeof row.top_p === "number" ? ["top_p"] : []),
|
|
287
|
+
...(row.stop !== undefined ? ["stop"] : []),
|
|
288
|
+
...(typeof row.parallel_tool_calls === "boolean" ? ["parallel_tool_calls"] : []),
|
|
289
|
+
...(row.text !== undefined ? ["text"] : []),
|
|
290
|
+
...(row.store !== undefined ? ["store"] : []),
|
|
291
|
+
...(row.include !== undefined ? ["include"] : []),
|
|
292
|
+
...(row.service_tier !== undefined ? ["service_tier"] : []),
|
|
293
|
+
];
|
|
294
|
+
if (row.store === true) throw new UnsupportedOptionError("store=true is not supported by the CLI transport.");
|
|
295
|
+
const text = record(row.text);
|
|
296
|
+
if (text?.format !== undefined) throw new UnsupportedOptionError("text.format is not supported by the CLI transport.");
|
|
297
|
+
if (typeof row.service_tier === "string" && row.service_tier !== "auto" && row.service_tier !== "default") {
|
|
298
|
+
throw new UnsupportedOptionError("The requested service_tier is not supported by the CLI transport.");
|
|
299
|
+
}
|
|
238
300
|
return {
|
|
239
301
|
requestedModel: row.model,
|
|
240
302
|
declared,
|
|
@@ -247,6 +309,9 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
247
309
|
...(declaredTools.length > 0 ? { tools: declaredTools.map(entry => entry.tool) } : {}),
|
|
248
310
|
},
|
|
249
311
|
options,
|
|
312
|
+
...(previousResponseId ? { previousResponseId } : {}),
|
|
313
|
+
...(promptCacheKey ? { promptCacheKey } : {}),
|
|
314
|
+
...(ignoredOptions.length > 0 ? { ignoredOptions } : {}),
|
|
250
315
|
_rawBody: body,
|
|
251
316
|
},
|
|
252
317
|
};
|
|
@@ -348,6 +413,14 @@ export function modelList(known: string[] | ProviderModels): Array<Record<string
|
|
|
348
413
|
owned_by: providerId,
|
|
349
414
|
display_name: info.name ?? (id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id),
|
|
350
415
|
...(info.description ? { description: info.description } : {}),
|
|
416
|
+
...(info.contextWindowTokens !== undefined
|
|
417
|
+
? {
|
|
418
|
+
context_window: info.contextWindowTokens,
|
|
419
|
+
max_context_window: info.maxContextWindowTokens ?? info.contextWindowTokens,
|
|
420
|
+
}
|
|
421
|
+
: {}),
|
|
422
|
+
...(info.inputModalities ? { input_modalities: [...info.inputModalities] } : {}),
|
|
423
|
+
...(info.loadSession !== undefined ? { load_session: info.loadSession } : {}),
|
|
351
424
|
...(reasoning.length > 0
|
|
352
425
|
? {
|
|
353
426
|
supported_reasoning_levels: reasoning.map(level => ({
|
|
@@ -452,6 +525,7 @@ function outputMeta(declared: Map<string, DeclaredTool>, name: string): { name:
|
|
|
452
525
|
export async function collectResponsesResult(
|
|
453
526
|
parsed: ParsedResponsesRequest,
|
|
454
527
|
run: (emit: (event: AdapterEvent) => void) => Promise<void>,
|
|
528
|
+
responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
455
529
|
): Promise<ResponsesResult> {
|
|
456
530
|
const output: OutputItem[] = [];
|
|
457
531
|
let currentText: OutputTextItem | undefined;
|
|
@@ -539,7 +613,7 @@ export async function collectResponsesResult(
|
|
|
539
613
|
closeAll();
|
|
540
614
|
if (failure) return { failure };
|
|
541
615
|
const body = {
|
|
542
|
-
id:
|
|
616
|
+
id: responseId,
|
|
543
617
|
object: "response",
|
|
544
618
|
created_at: Math.floor(Date.now() / 1000),
|
|
545
619
|
status: terminal?.type === "incomplete" ? "incomplete" : "completed",
|
|
@@ -558,9 +632,9 @@ export function streamResponsesResult(
|
|
|
558
632
|
parsed: ParsedResponsesRequest,
|
|
559
633
|
run: (emit: (event: AdapterEvent) => void) => Promise<void>,
|
|
560
634
|
signal?: AbortSignal,
|
|
635
|
+
responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
561
636
|
): ReadableStream<Uint8Array> {
|
|
562
637
|
const encoder = new TextEncoder();
|
|
563
|
-
const responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
564
638
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
565
639
|
return new ReadableStream<Uint8Array>({
|
|
566
640
|
async start(controller) {
|
|
@@ -727,15 +801,23 @@ export async function handleResponses(
|
|
|
727
801
|
defaultProvider: ProviderId = CLINE_PROVIDER_ID,
|
|
728
802
|
): Promise<Response> {
|
|
729
803
|
const parsed = parseResponsesRequest(body);
|
|
804
|
+
const responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
805
|
+
parsed.parsed.responseId = responseId;
|
|
806
|
+
const finish = (response: Response): Response => {
|
|
807
|
+
if (parsed.parsed.ignoredOptions?.length) {
|
|
808
|
+
response.headers.set("x-opencodex-ignored-options", parsed.parsed.ignoredOptions.join(","));
|
|
809
|
+
}
|
|
810
|
+
return response;
|
|
811
|
+
};
|
|
730
812
|
const { run } = routedTurn(parsed.parsed, parsed.requestedModel, incoming, knownModels, deps, defaultProvider);
|
|
731
813
|
if (parsed.parsed.stream) {
|
|
732
|
-
return new Response(streamResponsesResult(parsed, run, incoming.abortSignal), {
|
|
814
|
+
return finish(new Response(streamResponsesResult(parsed, run, incoming.abortSignal, responseId), {
|
|
733
815
|
headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" },
|
|
734
|
-
});
|
|
816
|
+
}));
|
|
735
817
|
}
|
|
736
|
-
const result = await collectResponsesResult(parsed, run);
|
|
818
|
+
const result = await collectResponsesResult(parsed, run, responseId);
|
|
737
819
|
if (result.failure) {
|
|
738
|
-
return Response.json({ error: { message: result.failure.message, type: result.failure.type, code: result.failure.code ?? null } }, { status: result.failure.status });
|
|
820
|
+
return finish(Response.json({ error: { message: result.failure.message, type: result.failure.type, code: result.failure.code ?? null } }, { status: result.failure.status }));
|
|
739
821
|
}
|
|
740
|
-
return Response.json(result.body);
|
|
822
|
+
return finish(Response.json(result.body));
|
|
741
823
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import type { ProviderId } from "../config";
|
|
3
|
+
import type { IncomingMeta } from "../types";
|
|
4
|
+
import { hashMessages } from "../adapters/cli-agent/gemini/session-store";
|
|
5
|
+
import {
|
|
6
|
+
parseResponsesRequest,
|
|
7
|
+
RequestValidationError,
|
|
8
|
+
routedTurn,
|
|
9
|
+
streamResponsesResult,
|
|
10
|
+
type ParsedResponsesRequest,
|
|
11
|
+
type ProviderModels,
|
|
12
|
+
type ProviderTurnDeps,
|
|
13
|
+
} from "./responses";
|
|
14
|
+
|
|
15
|
+
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
16
|
+
const WARMUP_TTL_MS = 5 * 60_000;
|
|
17
|
+
const MAX_WARMUPS = 128;
|
|
18
|
+
|
|
19
|
+
export interface WebSocketRequestObservation {
|
|
20
|
+
type: string;
|
|
21
|
+
generate: boolean;
|
|
22
|
+
previousResponseIdPresent: boolean;
|
|
23
|
+
inputItems: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WebSocketData {
|
|
27
|
+
headers: Headers;
|
|
28
|
+
queue: Promise<void>;
|
|
29
|
+
active?: AbortController;
|
|
30
|
+
closed: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type OcxWebSocket = ServerWebSocket<WebSocketData>;
|
|
34
|
+
|
|
35
|
+
export interface WebSocketContext {
|
|
36
|
+
knownModels: () => Promise<ProviderModels>;
|
|
37
|
+
deps: ProviderTurnDeps;
|
|
38
|
+
defaultProvider: ProviderId;
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
onRequest?: (observation: WebSocketRequestObservation) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ContextRecord {
|
|
44
|
+
parsed: ParsedResponsesRequest;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
warmup: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const contexts = new Map<string, ContextRecord>();
|
|
50
|
+
|
|
51
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
52
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
53
|
+
? value as Record<string, unknown>
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function responseId(): string {
|
|
58
|
+
return `resp_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function storeContext(parsed: ParsedResponsesRequest, warmup: boolean): string {
|
|
62
|
+
const id = responseId();
|
|
63
|
+
rememberContext(id, parsed, warmup);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function rememberContext(id: string, parsed: ParsedResponsesRequest, warmup: boolean): void {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
for (const [key, value] of contexts) {
|
|
70
|
+
if (value.expiresAt <= now) contexts.delete(key);
|
|
71
|
+
}
|
|
72
|
+
while (contexts.size >= MAX_WARMUPS) {
|
|
73
|
+
const oldest = contexts.keys().next().value;
|
|
74
|
+
if (!oldest) break;
|
|
75
|
+
contexts.delete(oldest);
|
|
76
|
+
}
|
|
77
|
+
delete parsed.parsed._rawBody;
|
|
78
|
+
contexts.set(id, { parsed, expiresAt: now + WARMUP_TTL_MS, warmup });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function takeContext(id: string): ContextRecord | undefined {
|
|
82
|
+
const record = contexts.get(id);
|
|
83
|
+
contexts.delete(id);
|
|
84
|
+
if (!record || record.expiresAt <= Date.now()) return undefined;
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mergeContext(prior: ContextRecord, real: ParsedResponsesRequest): void {
|
|
89
|
+
const stored = prior.parsed.parsed;
|
|
90
|
+
const incoming = real.parsed;
|
|
91
|
+
const storedMessages = stored.context.messages;
|
|
92
|
+
const prefixMatches = incoming.context.messages.length >= storedMessages.length
|
|
93
|
+
&& hashMessages(incoming.context.messages.slice(0, storedMessages.length)) === hashMessages(storedMessages);
|
|
94
|
+
if (!prefixMatches) {
|
|
95
|
+
incoming.context.messages = [...storedMessages, ...incoming.context.messages];
|
|
96
|
+
}
|
|
97
|
+
if (!incoming.context.systemPrompt?.length) incoming.context.systemPrompt = stored.context.systemPrompt;
|
|
98
|
+
if (!incoming.context.tools?.length) incoming.context.tools = stored.context.tools;
|
|
99
|
+
if (prior.warmup) {
|
|
100
|
+
incoming.previousResponseId = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendJson(ws: OcxWebSocket, value: Record<string, unknown>): void {
|
|
105
|
+
if (ws.data.closed) return;
|
|
106
|
+
try {
|
|
107
|
+
ws.send(JSON.stringify(value));
|
|
108
|
+
} catch {
|
|
109
|
+
ws.data.closed = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendError(ws: OcxWebSocket, error: unknown): void {
|
|
114
|
+
const validation = error instanceof RequestValidationError ? error : undefined;
|
|
115
|
+
const code = validation
|
|
116
|
+
? (validation as RequestValidationError & { code?: string }).code ?? "invalid_request"
|
|
117
|
+
: "proxy_error";
|
|
118
|
+
sendJson(ws, {
|
|
119
|
+
type: "error",
|
|
120
|
+
error: {
|
|
121
|
+
message: error instanceof Error ? error.message : "Invalid WebSocket request.",
|
|
122
|
+
type: validation?.type ?? "invalid_request_error",
|
|
123
|
+
code,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sendWarmup(ws: OcxWebSocket, id: string, model: string): void {
|
|
129
|
+
const base = {
|
|
130
|
+
id,
|
|
131
|
+
object: "response",
|
|
132
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
133
|
+
model,
|
|
134
|
+
output: [],
|
|
135
|
+
usage: null,
|
|
136
|
+
};
|
|
137
|
+
sendJson(ws, { type: "response.created", response: { ...base, status: "in_progress" } });
|
|
138
|
+
sendJson(ws, { type: "response.completed", response: { ...base, status: "completed", end_turn: true } });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function forwardStream(
|
|
142
|
+
ws: OcxWebSocket,
|
|
143
|
+
stream: ReadableStream<Uint8Array>,
|
|
144
|
+
signal: AbortSignal,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const reader = stream.getReader();
|
|
147
|
+
const decoder = new TextDecoder();
|
|
148
|
+
let buffer = "";
|
|
149
|
+
try {
|
|
150
|
+
while (!ws.data.closed && !signal.aborted) {
|
|
151
|
+
const { done, value } = await reader.read();
|
|
152
|
+
if (done) break;
|
|
153
|
+
buffer += decoder.decode(value, { stream: true });
|
|
154
|
+
let boundary = buffer.indexOf("\n\n");
|
|
155
|
+
while (boundary >= 0) {
|
|
156
|
+
const frame = buffer.slice(0, boundary);
|
|
157
|
+
buffer = buffer.slice(boundary + 2);
|
|
158
|
+
const data = frame
|
|
159
|
+
.split("\n")
|
|
160
|
+
.filter(line => line.startsWith("data: "))
|
|
161
|
+
.map(line => line.slice(6))
|
|
162
|
+
.join("\n");
|
|
163
|
+
if (data && data !== "[DONE]") sendJson(ws, JSON.parse(data) as Record<string, unknown>);
|
|
164
|
+
boundary = buffer.indexOf("\n\n");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
try { await reader.cancel(); } catch { /* client gone */ }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleFrame(ws: OcxWebSocket, raw: string, context: WebSocketContext): Promise<void> {
|
|
173
|
+
if (ws.data.closed) return;
|
|
174
|
+
if (Buffer.byteLength(raw) > MAX_FRAME_BYTES) {
|
|
175
|
+
sendError(ws, new RequestValidationError("WebSocket frame exceeds the byte limit."));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let value: unknown;
|
|
179
|
+
try {
|
|
180
|
+
value = JSON.parse(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
sendError(ws, new RequestValidationError("WebSocket frame is not valid JSON."));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const frame = record(value);
|
|
186
|
+
if (!frame || frame.type !== "response.create") {
|
|
187
|
+
sendError(ws, new RequestValidationError("WebSocket frame must be response.create."));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const generate = frame.generate !== false;
|
|
191
|
+
const previousResponseId = typeof frame.previous_response_id === "string" && frame.previous_response_id.trim()
|
|
192
|
+
? frame.previous_response_id.trim()
|
|
193
|
+
: undefined;
|
|
194
|
+
const inputItems = Array.isArray(frame.input) ? frame.input.length : typeof frame.input === "string" ? 1 : 0;
|
|
195
|
+
context.onRequest?.({
|
|
196
|
+
type: String(frame.type),
|
|
197
|
+
generate,
|
|
198
|
+
previousResponseIdPresent: Boolean(previousResponseId),
|
|
199
|
+
inputItems,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let parsed: ParsedResponsesRequest;
|
|
203
|
+
try {
|
|
204
|
+
parsed = parseResponsesRequest(frame);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sendError(ws, error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!generate) {
|
|
211
|
+
const id = storeContext(parsed, true);
|
|
212
|
+
sendWarmup(ws, id, parsed.requestedModel);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const id = responseId();
|
|
217
|
+
parsed.parsed.responseId = id;
|
|
218
|
+
if (previousResponseId) {
|
|
219
|
+
const prior = takeContext(previousResponseId);
|
|
220
|
+
if (prior) mergeContext(prior, parsed);
|
|
221
|
+
else parsed.parsed.previousResponseId = previousResponseId;
|
|
222
|
+
}
|
|
223
|
+
rememberContext(id, parsed, false);
|
|
224
|
+
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
ws.data.active = controller;
|
|
227
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(context.timeoutMs)]);
|
|
228
|
+
const incoming: IncomingMeta = { headers: ws.data.headers, abortSignal: signal };
|
|
229
|
+
try {
|
|
230
|
+
const known = await context.knownModels();
|
|
231
|
+
const { run } = routedTurn(parsed.parsed, parsed.requestedModel, incoming, known, context.deps, context.defaultProvider);
|
|
232
|
+
const stream = streamResponsesResult(parsed, run, signal, id);
|
|
233
|
+
await forwardStream(ws, stream, signal);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
sendError(ws, error);
|
|
236
|
+
} finally {
|
|
237
|
+
if (ws.data.active === controller) ws.data.active = undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createWebSocketHandlers(context: WebSocketContext) {
|
|
242
|
+
return {
|
|
243
|
+
open(_ws: OcxWebSocket) {
|
|
244
|
+
// Connection state is initialized by the upgrade data.
|
|
245
|
+
},
|
|
246
|
+
message(ws: OcxWebSocket, message: string | Buffer) {
|
|
247
|
+
const raw = typeof message === "string" ? message : message.toString("utf8");
|
|
248
|
+
ws.data.queue = ws.data.queue
|
|
249
|
+
.then(() => handleFrame(ws, raw, context))
|
|
250
|
+
.catch(error => sendError(ws, error));
|
|
251
|
+
},
|
|
252
|
+
close(ws: OcxWebSocket) {
|
|
253
|
+
ws.data.closed = true;
|
|
254
|
+
ws.data.active?.abort();
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function resetWebSocketWarmupsForTests(): void {
|
|
260
|
+
contexts.clear();
|
|
261
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface OcxToolCall {
|
|
|
31
31
|
|
|
32
32
|
export interface OcxUserMessage {
|
|
33
33
|
role: "user";
|
|
34
|
-
content: string | Array<OcxTextContent | { type: "image" | "video" | "audio"; text?: string; imageUrl?: string }>;
|
|
34
|
+
content: string | Array<OcxTextContent | { type: "image" | "video" | "audio"; text?: string; imageUrl?: string; mimeType?: string }>;
|
|
35
35
|
timestamp: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -83,6 +83,12 @@ export interface OcxParsedRequest {
|
|
|
83
83
|
stream: boolean;
|
|
84
84
|
context: OcxContext;
|
|
85
85
|
options: OcxRequestOptions;
|
|
86
|
+
previousResponseId?: string;
|
|
87
|
+
promptCacheKey?: string;
|
|
88
|
+
responseId?: string;
|
|
89
|
+
conversationKey?: string;
|
|
90
|
+
sessionKeySource?: string;
|
|
91
|
+
ignoredOptions?: readonly string[];
|
|
86
92
|
_rawBody?: unknown;
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -107,6 +113,11 @@ export interface OcxProviderModel {
|
|
|
107
113
|
description?: string;
|
|
108
114
|
supportedReasoningLevels?: readonly OcxReasoningLevel[];
|
|
109
115
|
defaultReasoningLevel?: string;
|
|
116
|
+
contextWindowTokens?: number;
|
|
117
|
+
maxContextWindowTokens?: number;
|
|
118
|
+
compressionThreshold?: number;
|
|
119
|
+
inputModalities?: readonly ("text" | "image" | "audio")[];
|
|
120
|
+
loadSession?: boolean;
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
export type AdapterEvent =
|