@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.
@@ -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: ["text"];
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: true;
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: 95;
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: CATALOG_FALLBACK_CONTEXT,
133
- max_context_window: CATALOG_FALLBACK_CONTEXT,
134
- auto_compact_token_limit: Math.floor(CATALOG_FALLBACK_CONTEXT * 0.9),
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: true,
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: 95,
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
  }
@@ -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
- server = Bun.serve({ hostname: config.host, port: config.port, idleTimeout: 255, fetch });
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,
@@ -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 === "input_image" || row.type === "image_url" || row.type === "input_audio" || row.type === "video_url") {
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: textContent(content), timestamp: Date.now() });
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: `resp_${crypto.randomUUID().replaceAll("-", "")}`,
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 =