@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
package/src/server/responses.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
OcxTool,
|
|
7
7
|
OcxToolChoice,
|
|
8
8
|
OcxUsage,
|
|
9
|
+
OcxProviderModel,
|
|
9
10
|
} from "../types";
|
|
10
11
|
import { runClineTurn, type ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
|
|
11
12
|
import { runGeminiTurn, type GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
|
|
@@ -48,6 +49,15 @@ export class UnsupportedMediaError extends RequestValidationError {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
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
|
+
|
|
51
61
|
function record(value: unknown): Record<string, unknown> | undefined {
|
|
52
62
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
53
63
|
? value as Record<string, unknown>
|
|
@@ -86,12 +96,42 @@ function assertNoMedia(value: unknown): void {
|
|
|
86
96
|
for (const part of value) {
|
|
87
97
|
const row = record(part);
|
|
88
98
|
if (!row) continue;
|
|
89
|
-
if (row.type === "
|
|
99
|
+
if (row.type === "input_audio" || row.type === "video_url") {
|
|
90
100
|
throw new UnsupportedMediaError();
|
|
91
101
|
}
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
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
|
+
|
|
95
135
|
function parseToolChoice(value: unknown): OcxToolChoice | undefined {
|
|
96
136
|
if (value === undefined || value === null) return undefined;
|
|
97
137
|
if (value === "auto" || value === "none" || value === "required") return { type: value };
|
|
@@ -172,7 +212,7 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
172
212
|
} else if (role === "assistant") {
|
|
173
213
|
messages.push({ role: "assistant", content: [{ type: "text", text: textContent(content) }], timestamp: Date.now() });
|
|
174
214
|
} else {
|
|
175
|
-
messages.push({ role: "user", content:
|
|
215
|
+
messages.push({ role: "user", content: userContent(content), timestamp: Date.now() });
|
|
176
216
|
}
|
|
177
217
|
return;
|
|
178
218
|
}
|
|
@@ -234,6 +274,29 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
234
274
|
...(typeof row.parallel_tool_calls === "boolean" ? { parallelToolCalls: row.parallel_tool_calls } : {}),
|
|
235
275
|
...(typeof record(row.reasoning)?.effort === "string" ? { reasoning: record(row.reasoning)!.effort as string } : {}),
|
|
236
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
|
+
}
|
|
237
300
|
return {
|
|
238
301
|
requestedModel: row.model,
|
|
239
302
|
declared,
|
|
@@ -246,6 +309,9 @@ export function parseResponsesRequest(body: unknown): ParsedResponsesRequest {
|
|
|
246
309
|
...(declaredTools.length > 0 ? { tools: declaredTools.map(entry => entry.tool) } : {}),
|
|
247
310
|
},
|
|
248
311
|
options,
|
|
312
|
+
...(previousResponseId ? { previousResponseId } : {}),
|
|
313
|
+
...(promptCacheKey ? { promptCacheKey } : {}),
|
|
314
|
+
...(ignoredOptions.length > 0 ? { ignoredOptions } : {}),
|
|
249
315
|
_rawBody: body,
|
|
250
316
|
},
|
|
251
317
|
};
|
|
@@ -275,7 +341,8 @@ export function resolveClineModel(requested: string, known: Iterable<string>): s
|
|
|
275
341
|
return decodeRoutedModelId(suffix, known);
|
|
276
342
|
}
|
|
277
343
|
|
|
278
|
-
export type
|
|
344
|
+
export type ProviderModelEntry = string | OcxProviderModel;
|
|
345
|
+
export type ProviderModels = Partial<Record<ProviderId, readonly ProviderModelEntry[]>>;
|
|
279
346
|
|
|
280
347
|
export interface RoutedModel {
|
|
281
348
|
providerId: ProviderId;
|
|
@@ -297,10 +364,16 @@ export function resolveRoutedModel(
|
|
|
297
364
|
const suffix = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
298
365
|
return {
|
|
299
366
|
providerId,
|
|
300
|
-
modelId: suffix === "default"
|
|
367
|
+
modelId: suffix === "default"
|
|
368
|
+
? "default"
|
|
369
|
+
: decodeRoutedModelId(suffix, (known[providerId] ?? []).map(modelIdOf)),
|
|
301
370
|
};
|
|
302
371
|
}
|
|
303
372
|
|
|
373
|
+
function modelIdOf(model: ProviderModelEntry): string {
|
|
374
|
+
return typeof model === "string" ? model : model.id;
|
|
375
|
+
}
|
|
376
|
+
|
|
304
377
|
const PROVIDER_LABELS: Record<ProviderId, string> = {
|
|
305
378
|
[CLINE_PROVIDER_ID]: "Cline CLI",
|
|
306
379
|
[GEMINI_PROVIDER_ID]: "Gemini CLI",
|
|
@@ -325,14 +398,38 @@ export function modelList(known: string[] | ProviderModels): Array<Record<string
|
|
|
325
398
|
display_name: "Configured default provider",
|
|
326
399
|
}];
|
|
327
400
|
for (const providerId of [CLINE_PROVIDER_ID, GEMINI_PROVIDER_ID] as const) {
|
|
328
|
-
const
|
|
329
|
-
|
|
401
|
+
const entries = ["default", ...(known[providerId] ?? []).filter(entry => modelIdOf(entry) !== "default")];
|
|
402
|
+
const seen = new Set<string>();
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
const id = modelIdOf(entry);
|
|
405
|
+
if (seen.has(id)) continue;
|
|
406
|
+
seen.add(id);
|
|
407
|
+
const info = typeof entry === "string" ? { id: entry } as OcxProviderModel : entry;
|
|
408
|
+
const reasoning = info.supportedReasoningLevels ?? [];
|
|
330
409
|
rows.push({
|
|
331
410
|
id: `${providerId}/${encodeRoutedModelId(id)}`,
|
|
332
411
|
object: "model",
|
|
333
412
|
created: 0,
|
|
334
413
|
owned_by: providerId,
|
|
335
|
-
display_name: id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id,
|
|
414
|
+
display_name: info.name ?? (id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id),
|
|
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 } : {}),
|
|
424
|
+
...(reasoning.length > 0
|
|
425
|
+
? {
|
|
426
|
+
supported_reasoning_levels: reasoning.map(level => ({
|
|
427
|
+
effort: level.effort,
|
|
428
|
+
description: level.description,
|
|
429
|
+
})),
|
|
430
|
+
default_reasoning_level: info.defaultReasoningLevel ?? reasoning[0]!.effort,
|
|
431
|
+
}
|
|
432
|
+
: { supported_reasoning_levels: [] }),
|
|
336
433
|
});
|
|
337
434
|
}
|
|
338
435
|
}
|
|
@@ -428,6 +525,7 @@ function outputMeta(declared: Map<string, DeclaredTool>, name: string): { name:
|
|
|
428
525
|
export async function collectResponsesResult(
|
|
429
526
|
parsed: ParsedResponsesRequest,
|
|
430
527
|
run: (emit: (event: AdapterEvent) => void) => Promise<void>,
|
|
528
|
+
responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
431
529
|
): Promise<ResponsesResult> {
|
|
432
530
|
const output: OutputItem[] = [];
|
|
433
531
|
let currentText: OutputTextItem | undefined;
|
|
@@ -515,7 +613,7 @@ export async function collectResponsesResult(
|
|
|
515
613
|
closeAll();
|
|
516
614
|
if (failure) return { failure };
|
|
517
615
|
const body = {
|
|
518
|
-
id:
|
|
616
|
+
id: responseId,
|
|
519
617
|
object: "response",
|
|
520
618
|
created_at: Math.floor(Date.now() / 1000),
|
|
521
619
|
status: terminal?.type === "incomplete" ? "incomplete" : "completed",
|
|
@@ -534,9 +632,9 @@ export function streamResponsesResult(
|
|
|
534
632
|
parsed: ParsedResponsesRequest,
|
|
535
633
|
run: (emit: (event: AdapterEvent) => void) => Promise<void>,
|
|
536
634
|
signal?: AbortSignal,
|
|
635
|
+
responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
537
636
|
): ReadableStream<Uint8Array> {
|
|
538
637
|
const encoder = new TextEncoder();
|
|
539
|
-
const responseId = `resp_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
540
638
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
541
639
|
return new ReadableStream<Uint8Array>({
|
|
542
640
|
async start(controller) {
|
|
@@ -703,15 +801,23 @@ export async function handleResponses(
|
|
|
703
801
|
defaultProvider: ProviderId = CLINE_PROVIDER_ID,
|
|
704
802
|
): Promise<Response> {
|
|
705
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
|
+
};
|
|
706
812
|
const { run } = routedTurn(parsed.parsed, parsed.requestedModel, incoming, knownModels, deps, defaultProvider);
|
|
707
813
|
if (parsed.parsed.stream) {
|
|
708
|
-
return new Response(streamResponsesResult(parsed, run, incoming.abortSignal), {
|
|
814
|
+
return finish(new Response(streamResponsesResult(parsed, run, incoming.abortSignal, responseId), {
|
|
709
815
|
headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" },
|
|
710
|
-
});
|
|
816
|
+
}));
|
|
711
817
|
}
|
|
712
|
-
const result = await collectResponsesResult(parsed, run);
|
|
818
|
+
const result = await collectResponsesResult(parsed, run, responseId);
|
|
713
819
|
if (result.failure) {
|
|
714
|
-
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 }));
|
|
715
821
|
}
|
|
716
|
-
return Response.json(result.body);
|
|
822
|
+
return finish(Response.json(result.body));
|
|
717
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
|
|
|
@@ -96,6 +102,24 @@ export interface OcxUsage {
|
|
|
96
102
|
cacheCreationInputTokens?: number;
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
export interface OcxReasoningLevel {
|
|
106
|
+
effort: string;
|
|
107
|
+
description: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface OcxProviderModel {
|
|
111
|
+
id: string;
|
|
112
|
+
name?: string;
|
|
113
|
+
description?: string;
|
|
114
|
+
supportedReasoningLevels?: readonly OcxReasoningLevel[];
|
|
115
|
+
defaultReasoningLevel?: string;
|
|
116
|
+
contextWindowTokens?: number;
|
|
117
|
+
maxContextWindowTokens?: number;
|
|
118
|
+
compressionThreshold?: number;
|
|
119
|
+
inputModalities?: readonly ("text" | "image" | "audio")[];
|
|
120
|
+
loadSession?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
export type AdapterEvent =
|
|
100
124
|
| { type: "heartbeat" }
|
|
101
125
|
| { type: "text_delta"; text: string }
|