@bitkyc08/opencodex 2.7.34 → 2.7.35
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/README.md +1 -0
- package/gui/dist/assets/index-BunUANVE.js +52 -0
- package/gui/dist/index.html +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic.ts +22 -37
- package/src/adapters/cursor/cursor-errors.ts +11 -0
- package/src/adapters/cursor/discovery.ts +25 -0
- package/src/adapters/cursor/live-transport.ts +5 -0
- package/src/adapters/cursor/protobuf-events.ts +14 -0
- package/src/adapters/cursor/protobuf-request.ts +11 -1
- package/src/adapters/cursor/request-builder.ts +20 -3
- package/src/adapters/cursor.ts +69 -22
- package/src/adapters/google-http.ts +23 -4
- package/src/adapters/google-tool-schema.ts +143 -68
- package/src/adapters/google-wire-compiler.ts +228 -0
- package/src/adapters/google.ts +27 -18
- package/src/adapters/kiro.ts +25 -1
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +565 -0
- package/src/claude/agents-inject.ts +4 -2
- package/src/codex/catalog.ts +31 -2
- package/src/codex/home.ts +1 -1
- package/src/codex/quota.ts +43 -13
- package/src/index.ts +1 -0
- package/src/lib/sse-decoder.ts +84 -0
- package/src/providers/antigravity-models.ts +9 -3
- package/src/server/chat-completions.ts +258 -0
- package/src/server/gui-static.ts +1 -0
- package/src/server/index.ts +24 -0
- package/src/server/management-api.ts +9 -0
- package/src/server/responses.ts +28 -2
- package/src/types.ts +6 -3
- package/gui/dist/assets/index-BkmJJgg6.js +0 -52
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Completions outbound: internal /v1/responses output -> OpenAI Chat Completions shapes.
|
|
3
|
+
*
|
|
4
|
+
* Wire contract for GitHub Copilot App / OpenAI-compatible clients:
|
|
5
|
+
* - Streaming: `data: {choices:[{delta:...}]}` frames ending with `data: [DONE]`
|
|
6
|
+
* - Non-streaming: `{ id, object:"chat.completion", choices:[{message}], usage }`
|
|
7
|
+
*/
|
|
8
|
+
type Rec = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
11
|
+
|
|
12
|
+
function isRec(v: unknown): v is Rec {
|
|
13
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function uuid(): string {
|
|
17
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function completionId(): string {
|
|
21
|
+
return `chatcmpl-${uuid().slice(0, 24)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Responses usage (inclusive input_tokens) -> Chat Completions usage. */
|
|
25
|
+
export function chatCompletionsUsage(usage: unknown): Rec {
|
|
26
|
+
const u = isRec(usage) ? usage : {};
|
|
27
|
+
const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
|
|
28
|
+
const prompt = typeof u.input_tokens === "number" ? u.input_tokens : 0;
|
|
29
|
+
const completion = typeof u.output_tokens === "number" ? u.output_tokens : 0;
|
|
30
|
+
const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : undefined;
|
|
31
|
+
const out: Rec = {
|
|
32
|
+
prompt_tokens: prompt,
|
|
33
|
+
completion_tokens: completion,
|
|
34
|
+
total_tokens: prompt + completion,
|
|
35
|
+
};
|
|
36
|
+
if (cached !== undefined) {
|
|
37
|
+
out.prompt_tokens_details = { cached_tokens: cached };
|
|
38
|
+
}
|
|
39
|
+
const outDetails = isRec(u.output_tokens_details) ? u.output_tokens_details : {};
|
|
40
|
+
if (typeof outDetails.reasoning_tokens === "number") {
|
|
41
|
+
out.completion_tokens_details = { reasoning_tokens: outDetails.reasoning_tokens };
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function chatCompletionsErrorBody(status: number, message: string, type = "invalid_request_error"): Rec {
|
|
47
|
+
return {
|
|
48
|
+
error: {
|
|
49
|
+
message,
|
|
50
|
+
type,
|
|
51
|
+
param: null,
|
|
52
|
+
code: status === 401 ? "invalid_api_key"
|
|
53
|
+
: status === 404 ? "model_not_found"
|
|
54
|
+
: status === 429 ? "rate_limit_exceeded"
|
|
55
|
+
: null,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function chatCompletionsErrorResponse(status: number, message: string, type?: string): Response {
|
|
61
|
+
return new Response(JSON.stringify(chatCompletionsErrorBody(status, message, type)), {
|
|
62
|
+
status,
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Thrown when a Chat Completions SSE stream ends in a typed failure/truncation. */
|
|
68
|
+
export class ChatCompletionsStreamError extends Error {
|
|
69
|
+
readonly status: number;
|
|
70
|
+
readonly type: string;
|
|
71
|
+
readonly code: string | null;
|
|
72
|
+
|
|
73
|
+
constructor(message: string, options: { status?: number; type?: string; code?: string | null } = {}) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "ChatCompletionsStreamError";
|
|
76
|
+
this.status = options.status ?? 502;
|
|
77
|
+
this.type = options.type ?? "server_error";
|
|
78
|
+
this.code = options.code ?? null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isChatCompletionsStreamError(err: unknown): err is ChatCompletionsStreamError {
|
|
83
|
+
return err instanceof ChatCompletionsStreamError;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function streamErrorStatus(message: string): number {
|
|
87
|
+
const lower = message.toLowerCase();
|
|
88
|
+
if (lower.includes("truncated")) return 502;
|
|
89
|
+
if (lower.includes("rate") || lower.includes("429")) return 429;
|
|
90
|
+
if (lower.includes("unauthor") || lower.includes("401") || lower.includes("api key")) return 401;
|
|
91
|
+
if (lower.includes("not found") || lower.includes("404")) return 404;
|
|
92
|
+
if (lower.includes("invalid") || lower.includes("400")) return 400;
|
|
93
|
+
return 502;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function streamErrorType(status: number): string {
|
|
97
|
+
if (status === 401) return "authentication_error";
|
|
98
|
+
if (status === 429) return "rate_limit_error";
|
|
99
|
+
if (status >= 500) return "server_error";
|
|
100
|
+
return "invalid_request_error";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dataFrame(payload: Rec | "[DONE]"): string {
|
|
104
|
+
if (payload === "[DONE]") return "data: [DONE]\n\n";
|
|
105
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function chunkBase(id: string, model: string, created: number): Rec {
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
object: "chat.completion.chunk",
|
|
112
|
+
created,
|
|
113
|
+
model,
|
|
114
|
+
choices: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Streaming: Responses SSE bytes -> Chat Completions SSE bytes.
|
|
120
|
+
*/
|
|
121
|
+
export function responsesSseToChatCompletionsSse(
|
|
122
|
+
upstream: ReadableStream<Uint8Array>,
|
|
123
|
+
model: string,
|
|
124
|
+
): ReadableStream<Uint8Array> {
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
let terminated = false;
|
|
127
|
+
let cancelled = false;
|
|
128
|
+
let started = false;
|
|
129
|
+
let sawToolUse = false;
|
|
130
|
+
const id = completionId();
|
|
131
|
+
const created = Math.floor(Date.now() / 1000);
|
|
132
|
+
// tool call_id -> streaming index (OpenAI requires stable indices per tool call)
|
|
133
|
+
const toolIndexByCallId = new Map<string, number>();
|
|
134
|
+
const toolIndexByItemId = new Map<string, number>();
|
|
135
|
+
const toolNameByIndex = new Map<number, string>();
|
|
136
|
+
let nextToolIndex = 0;
|
|
137
|
+
let sseIterator: AsyncGenerator<{ event?: string; data: string }> | undefined;
|
|
138
|
+
const upstreamAbort = new AbortController();
|
|
139
|
+
|
|
140
|
+
return new ReadableStream<Uint8Array>({
|
|
141
|
+
start(controller) {
|
|
142
|
+
let failed = false;
|
|
143
|
+
const emit = (payload: Rec | "[DONE]") => {
|
|
144
|
+
if (failed) return;
|
|
145
|
+
controller.enqueue(encoder.encode(dataFrame(payload)));
|
|
146
|
+
};
|
|
147
|
+
const ensureRole = () => {
|
|
148
|
+
if (started) return;
|
|
149
|
+
started = true;
|
|
150
|
+
const frame = chunkBase(id, model, created);
|
|
151
|
+
frame.choices = [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }];
|
|
152
|
+
emit(frame);
|
|
153
|
+
};
|
|
154
|
+
const emitContent = (text: string) => {
|
|
155
|
+
if (!text) return;
|
|
156
|
+
ensureRole();
|
|
157
|
+
const frame = chunkBase(id, model, created);
|
|
158
|
+
frame.choices = [{ index: 0, delta: { content: text }, finish_reason: null }];
|
|
159
|
+
emit(frame);
|
|
160
|
+
};
|
|
161
|
+
const emitReasoning = (text: string) => {
|
|
162
|
+
if (!text) return;
|
|
163
|
+
ensureRole();
|
|
164
|
+
// Many OpenAI-compatible clients accept reasoning_content; harmless if ignored.
|
|
165
|
+
const frame = chunkBase(id, model, created);
|
|
166
|
+
frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }];
|
|
167
|
+
emit(frame);
|
|
168
|
+
};
|
|
169
|
+
const finish = (finishReason: string, usage: unknown) => {
|
|
170
|
+
if (terminated) return;
|
|
171
|
+
terminated = true;
|
|
172
|
+
ensureRole();
|
|
173
|
+
const frame = chunkBase(id, model, created);
|
|
174
|
+
frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
|
|
175
|
+
if (usage) frame.usage = chatCompletionsUsage(usage);
|
|
176
|
+
emit(frame);
|
|
177
|
+
emit("[DONE]");
|
|
178
|
+
};
|
|
179
|
+
const fail = (message: string) => {
|
|
180
|
+
if (terminated) return;
|
|
181
|
+
terminated = true;
|
|
182
|
+
failed = true;
|
|
183
|
+
// OpenAI-compatible clients need a real error event, not a success completion
|
|
184
|
+
// that embeds `[error] ...` text followed by a clean [DONE].
|
|
185
|
+
// Deliver the error frame then close the stream abnormally (no [DONE]).
|
|
186
|
+
// Do not controller.error() — that can drop already-enqueued bytes from consumers
|
|
187
|
+
// like response.text().
|
|
188
|
+
const status = streamErrorStatus(message);
|
|
189
|
+
const type = streamErrorType(status);
|
|
190
|
+
try {
|
|
191
|
+
controller.enqueue(encoder.encode(dataFrame({
|
|
192
|
+
error: {
|
|
193
|
+
message,
|
|
194
|
+
type,
|
|
195
|
+
param: null,
|
|
196
|
+
code: status === 401 ? "invalid_api_key"
|
|
197
|
+
: status === 404 ? "model_not_found"
|
|
198
|
+
: status === 429 ? "rate_limit_exceeded"
|
|
199
|
+
: null,
|
|
200
|
+
},
|
|
201
|
+
})));
|
|
202
|
+
} catch {
|
|
203
|
+
/* controller may already be closed */
|
|
204
|
+
}
|
|
205
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleFrame = (eventName: string, data: Rec) => {
|
|
209
|
+
switch (eventName) {
|
|
210
|
+
case "response.created":
|
|
211
|
+
case "response.heartbeat":
|
|
212
|
+
ensureRole();
|
|
213
|
+
break;
|
|
214
|
+
case "response.output_text.delta": {
|
|
215
|
+
if (typeof data.delta === "string") emitContent(data.delta);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "response.reasoning_summary_text.delta":
|
|
219
|
+
case "response.reasoning_text.delta": {
|
|
220
|
+
if (typeof data.delta === "string") emitReasoning(data.delta);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "response.output_item.added": {
|
|
224
|
+
const item = isRec(data.item) ? data.item : null;
|
|
225
|
+
if (!item || item.type !== "function_call") break;
|
|
226
|
+
ensureRole();
|
|
227
|
+
sawToolUse = true;
|
|
228
|
+
const callId = typeof item.call_id === "string" ? item.call_id : `call_${uuid().slice(0, 16)}`;
|
|
229
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
230
|
+
let toolIndex = toolIndexByCallId.get(callId);
|
|
231
|
+
if (toolIndex === undefined) {
|
|
232
|
+
toolIndex = nextToolIndex++;
|
|
233
|
+
toolIndexByCallId.set(callId, toolIndex);
|
|
234
|
+
}
|
|
235
|
+
if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
|
|
236
|
+
if (name) toolNameByIndex.set(toolIndex, name);
|
|
237
|
+
const frame = chunkBase(id, model, created);
|
|
238
|
+
frame.choices = [{
|
|
239
|
+
index: 0,
|
|
240
|
+
delta: {
|
|
241
|
+
tool_calls: [{
|
|
242
|
+
index: toolIndex,
|
|
243
|
+
id: callId,
|
|
244
|
+
type: "function",
|
|
245
|
+
// Always include name so clients that replace (not merge) tool_calls
|
|
246
|
+
// deltas never end up with function.name-less history.
|
|
247
|
+
function: { name, arguments: "" },
|
|
248
|
+
}],
|
|
249
|
+
},
|
|
250
|
+
finish_reason: null,
|
|
251
|
+
}];
|
|
252
|
+
emit(frame);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case "response.function_call_arguments.delta": {
|
|
256
|
+
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
257
|
+
const itemId = typeof data.item_id === "string" ? data.item_id : undefined;
|
|
258
|
+
const toolIndex = (itemId ? toolIndexByItemId.get(itemId) : undefined)
|
|
259
|
+
?? (nextToolIndex > 0 ? nextToolIndex - 1 : 0);
|
|
260
|
+
ensureRole();
|
|
261
|
+
const knownName = toolNameByIndex.get(toolIndex) ?? "";
|
|
262
|
+
const fn: Rec = { arguments: data.delta };
|
|
263
|
+
// Re-emit name on every args delta: GitHub Copilot App / some ChatGPT clients
|
|
264
|
+
// replace the whole function object instead of merging, which otherwise drops name.
|
|
265
|
+
if (knownName) fn.name = knownName;
|
|
266
|
+
const frame = chunkBase(id, model, created);
|
|
267
|
+
frame.choices = [{
|
|
268
|
+
index: 0,
|
|
269
|
+
delta: {
|
|
270
|
+
tool_calls: [{
|
|
271
|
+
index: toolIndex,
|
|
272
|
+
function: fn,
|
|
273
|
+
}],
|
|
274
|
+
},
|
|
275
|
+
finish_reason: null,
|
|
276
|
+
}];
|
|
277
|
+
emit(frame);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "response.output_item.done": {
|
|
281
|
+
const item = isRec(data.item) ? data.item : null;
|
|
282
|
+
if (!item) break;
|
|
283
|
+
if (item.type === "function_call") {
|
|
284
|
+
sawToolUse = true;
|
|
285
|
+
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
286
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
287
|
+
const args = typeof item.arguments === "string" ? item.arguments : "";
|
|
288
|
+
if (!callId) break;
|
|
289
|
+
const existingIndex = toolIndexByCallId.get(callId);
|
|
290
|
+
const isNew = existingIndex === undefined;
|
|
291
|
+
const toolIndex = existingIndex ?? nextToolIndex++;
|
|
292
|
+
if (isNew) toolIndexByCallId.set(callId, toolIndex);
|
|
293
|
+
if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
|
|
294
|
+
if (name) toolNameByIndex.set(toolIndex, name);
|
|
295
|
+
const finalName = name || toolNameByIndex.get(toolIndex) || "";
|
|
296
|
+
// Last-write-wins: the done-frame snapshot is authoritative final arguments.
|
|
297
|
+
// Always re-emit full identity (id/type/name) so replace-style clients keep function.name.
|
|
298
|
+
if (args.length > 0 || isNew || finalName) {
|
|
299
|
+
ensureRole();
|
|
300
|
+
const frame = chunkBase(id, model, created);
|
|
301
|
+
frame.choices = [{
|
|
302
|
+
index: 0,
|
|
303
|
+
delta: {
|
|
304
|
+
tool_calls: [{
|
|
305
|
+
index: toolIndex,
|
|
306
|
+
id: callId,
|
|
307
|
+
type: "function",
|
|
308
|
+
function: { name: finalName, arguments: args },
|
|
309
|
+
}],
|
|
310
|
+
},
|
|
311
|
+
finish_reason: null,
|
|
312
|
+
}];
|
|
313
|
+
emit(frame);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case "response.completed": {
|
|
319
|
+
const response = isRec(data.response) ? data.response : {};
|
|
320
|
+
finish(sawToolUse ? "tool_calls" : "stop", response.usage);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "response.incomplete": {
|
|
324
|
+
const response = isRec(data.response) ? data.response : {};
|
|
325
|
+
const details = isRec(response.incomplete_details) ? response.incomplete_details : {};
|
|
326
|
+
const reason = details.reason === "max_output_tokens" ? "length"
|
|
327
|
+
: details.reason === "content_filter" ? "content_filter"
|
|
328
|
+
: sawToolUse ? "tool_calls" : "stop";
|
|
329
|
+
finish(reason, response.usage);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "response.failed": {
|
|
333
|
+
const response = isRec(data.response) ? data.response : {};
|
|
334
|
+
const error = isRec(response.error) ? response.error : {};
|
|
335
|
+
const message = typeof error.message === "string" ? error.message : "upstream request failed";
|
|
336
|
+
fail(message);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
default:
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Shared spec-shaped SSE decoder: handles CRLF framing, arbitrary chunk boundaries,
|
|
345
|
+
// multi-line data, and a terminal event without a trailing blank line (Sol audit
|
|
346
|
+
// blocker 3 — the hand-rolled "\n\n" splitter misreported those as truncation).
|
|
347
|
+
sseIterator = decodeServerSentEvents(upstream, { signal: upstreamAbort.signal });
|
|
348
|
+
void (async () => {
|
|
349
|
+
try {
|
|
350
|
+
for await (const record of sseIterator!) {
|
|
351
|
+
const eventName = record.event ?? "";
|
|
352
|
+
const dataLine = record.data.trim();
|
|
353
|
+
if (!eventName || !dataLine) continue;
|
|
354
|
+
let data: unknown;
|
|
355
|
+
try { data = JSON.parse(dataLine); } catch { continue; }
|
|
356
|
+
if (!isRec(data)) continue;
|
|
357
|
+
if (terminated) continue;
|
|
358
|
+
handleFrame(eventName, data);
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
362
|
+
} finally {
|
|
363
|
+
if (!cancelled && !terminated) {
|
|
364
|
+
fail("upstream stream ended before a terminal frame (truncated response)");
|
|
365
|
+
}
|
|
366
|
+
// Success path: close after [DONE]. Failure path closes inside fail().
|
|
367
|
+
if (!cancelled && terminated && !failed) {
|
|
368
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
},
|
|
373
|
+
cancel(reason) {
|
|
374
|
+
cancelled = true;
|
|
375
|
+
// Abort first: it cancels the decoder's underlying reader, settling any in-flight
|
|
376
|
+
// read() so the generator's return() below resolves promptly instead of hanging
|
|
377
|
+
// behind an idle upstream (Sol re-verification blocker).
|
|
378
|
+
upstreamAbort.abort(reason);
|
|
379
|
+
return sseIterator?.return(undefined).then(() => undefined, () => undefined) ?? Promise.resolve(undefined);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Non-streaming: /v1/responses JSON -> Chat Completions message JSON. */
|
|
385
|
+
export function responsesJsonToChatCompletion(json: unknown, model: string): Rec {
|
|
386
|
+
const body = isRec(json) ? json : {};
|
|
387
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
388
|
+
let content = "";
|
|
389
|
+
let reasoning = "";
|
|
390
|
+
const toolCalls: Rec[] = [];
|
|
391
|
+
|
|
392
|
+
for (const raw of output) {
|
|
393
|
+
if (!isRec(raw)) continue;
|
|
394
|
+
if (raw.type === "message" && Array.isArray(raw.content)) {
|
|
395
|
+
for (const part of raw.content) {
|
|
396
|
+
if (isRec(part) && part.type === "output_text" && typeof part.text === "string") {
|
|
397
|
+
content += part.text;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else if (raw.type === "reasoning") {
|
|
401
|
+
if (Array.isArray(raw.summary)) {
|
|
402
|
+
for (const part of raw.summary) {
|
|
403
|
+
if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
|
|
404
|
+
reasoning += part.text;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (Array.isArray(raw.content)) {
|
|
409
|
+
for (const part of raw.content) {
|
|
410
|
+
if (isRec(part) && part.type === "reasoning_text" && typeof part.text === "string") {
|
|
411
|
+
reasoning += part.text;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} else if (raw.type === "function_call") {
|
|
416
|
+
toolCalls.push({
|
|
417
|
+
id: typeof raw.call_id === "string" ? raw.call_id : `call_${uuid().slice(0, 16)}`,
|
|
418
|
+
type: "function",
|
|
419
|
+
function: {
|
|
420
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
421
|
+
arguments: typeof raw.arguments === "string" ? raw.arguments : "{}",
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const finishReason = toolCalls.length > 0 ? "tool_calls"
|
|
428
|
+
: body.status === "incomplete" ? "length"
|
|
429
|
+
: "stop";
|
|
430
|
+
|
|
431
|
+
const message: Rec = {
|
|
432
|
+
role: "assistant",
|
|
433
|
+
content: content || null,
|
|
434
|
+
};
|
|
435
|
+
if (reasoning) message.reasoning_content = reasoning;
|
|
436
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
id: completionId(),
|
|
440
|
+
object: "chat.completion",
|
|
441
|
+
created: Math.floor(Date.now() / 1000),
|
|
442
|
+
model,
|
|
443
|
+
choices: [{
|
|
444
|
+
index: 0,
|
|
445
|
+
message,
|
|
446
|
+
finish_reason: finishReason,
|
|
447
|
+
logprobs: null,
|
|
448
|
+
}],
|
|
449
|
+
usage: chatCompletionsUsage(body.usage),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Fold a Chat Completions SSE stream into a final completion JSON. */
|
|
454
|
+
export async function collectChatCompletion(
|
|
455
|
+
stream: ReadableStream<Uint8Array>,
|
|
456
|
+
model: string,
|
|
457
|
+
): Promise<Rec> {
|
|
458
|
+
const decoder = new TextDecoder();
|
|
459
|
+
let buffer = "";
|
|
460
|
+
let content = "";
|
|
461
|
+
let reasoning = "";
|
|
462
|
+
const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
|
|
463
|
+
let finishReason = "stop";
|
|
464
|
+
let usage: unknown;
|
|
465
|
+
let streamError: ChatCompletionsStreamError | null = null;
|
|
466
|
+
const reader = stream.getReader();
|
|
467
|
+
try {
|
|
468
|
+
for (;;) {
|
|
469
|
+
let done = false;
|
|
470
|
+
let value: Uint8Array | undefined;
|
|
471
|
+
try {
|
|
472
|
+
({ done, value } = await reader.read());
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (isChatCompletionsStreamError(err)) throw err;
|
|
475
|
+
throw new ChatCompletionsStreamError(err instanceof Error ? err.message : String(err));
|
|
476
|
+
}
|
|
477
|
+
if (done) break;
|
|
478
|
+
if (!value) continue;
|
|
479
|
+
buffer += decoder.decode(value, { stream: true });
|
|
480
|
+
let sep: number;
|
|
481
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
482
|
+
const rawFrame = buffer.slice(0, sep);
|
|
483
|
+
buffer = buffer.slice(sep + 2);
|
|
484
|
+
for (const line of rawFrame.split("\n")) {
|
|
485
|
+
if (!line.startsWith("data: ")) continue;
|
|
486
|
+
const data = line.slice(6).trim();
|
|
487
|
+
if (!data || data === "[DONE]") continue;
|
|
488
|
+
let parsed: unknown;
|
|
489
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
490
|
+
if (!isRec(parsed)) continue;
|
|
491
|
+
if (isRec(parsed.error)) {
|
|
492
|
+
const message = typeof parsed.error.message === "string"
|
|
493
|
+
? parsed.error.message
|
|
494
|
+
: "upstream request failed";
|
|
495
|
+
const type = typeof parsed.error.type === "string" ? parsed.error.type : "server_error";
|
|
496
|
+
const status = streamErrorStatus(message);
|
|
497
|
+
streamError = new ChatCompletionsStreamError(message, { status, type });
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (parsed.usage) usage = parsed.usage;
|
|
501
|
+
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
|
502
|
+
const choice = isRec(choices[0]) ? choices[0] : null;
|
|
503
|
+
if (!choice) continue;
|
|
504
|
+
if (typeof choice.finish_reason === "string") finishReason = choice.finish_reason;
|
|
505
|
+
const delta = isRec(choice.delta) ? choice.delta : null;
|
|
506
|
+
if (!delta) continue;
|
|
507
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
508
|
+
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
509
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
510
|
+
for (const tc of delta.tool_calls) {
|
|
511
|
+
if (!isRec(tc)) continue;
|
|
512
|
+
const index = typeof tc.index === "number" ? tc.index : 0;
|
|
513
|
+
const current = toolCalls.get(index) ?? { id: "", name: "", arguments: "" };
|
|
514
|
+
if (typeof tc.id === "string") current.id = tc.id;
|
|
515
|
+
const fn = isRec(tc.function) ? tc.function : {};
|
|
516
|
+
// Done-frame final arguments are authoritative last-write-wins snapshots.
|
|
517
|
+
if (typeof fn.name === "string" && fn.name.length > 0) current.name = fn.name;
|
|
518
|
+
if (typeof fn.arguments === "string") {
|
|
519
|
+
if (fn.arguments.startsWith("{") || fn.arguments.startsWith("[") || current.arguments.length === 0) {
|
|
520
|
+
current.arguments = fn.arguments;
|
|
521
|
+
} else {
|
|
522
|
+
current.arguments += fn.arguments;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
toolCalls.set(index, current);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
} finally {
|
|
532
|
+
reader.releaseLock();
|
|
533
|
+
}
|
|
534
|
+
if (streamError) throw streamError;
|
|
535
|
+
|
|
536
|
+
const message: Rec = {
|
|
537
|
+
role: "assistant",
|
|
538
|
+
content: content || null,
|
|
539
|
+
};
|
|
540
|
+
if (reasoning) message.reasoning_content = reasoning;
|
|
541
|
+
if (toolCalls.size > 0) {
|
|
542
|
+
message.tool_calls = [...toolCalls.entries()]
|
|
543
|
+
.sort((a, b) => a[0] - b[0])
|
|
544
|
+
.map(([, tc]) => ({
|
|
545
|
+
id: tc.id || `call_${uuid().slice(0, 16)}`,
|
|
546
|
+
type: "function",
|
|
547
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
548
|
+
}));
|
|
549
|
+
if (finishReason === "stop") finishReason = "tool_calls";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
id: completionId(),
|
|
554
|
+
object: "chat.completion",
|
|
555
|
+
created: Math.floor(Date.now() / 1000),
|
|
556
|
+
model,
|
|
557
|
+
choices: [{
|
|
558
|
+
index: 0,
|
|
559
|
+
message,
|
|
560
|
+
finish_reason: finishReason,
|
|
561
|
+
logprobs: null,
|
|
562
|
+
}],
|
|
563
|
+
usage: usage && isRec(usage) ? usage : chatCompletionsUsage(undefined),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
@@ -231,6 +231,8 @@ export function injectClaudeAgentDefs(config: OcxConfig, windows: Record<string,
|
|
|
231
231
|
* directive makes the Agent tool's `model` argument INERT (the proxy overrides
|
|
232
232
|
* the request model before routing — live-proven), so instead of asking the
|
|
233
233
|
* dispatcher to omit it (which caused schema-anxiety loops), we hand it a fixed
|
|
234
|
-
* placeholder: any value works
|
|
234
|
+
* placeholder: any value works; "haiku" is canonical because a haiku-labeled call
|
|
235
|
+
* is visibly a placeholder in the Claude Code UI, while "sonnet" was
|
|
236
|
+
* indistinguishable from a genuine Sonnet call (issue #252).
|
|
235
237
|
*/
|
|
236
|
-
const NO_MODEL_ARG = "NOTE: this agent's real model is pinned by the opencodex proxy — the `model` argument is ignored. Pass model: \"
|
|
238
|
+
const NO_MODEL_ARG = "NOTE: this agent's real model is pinned by the opencodex proxy — the `model` argument is ignored. Pass model: \"haiku\" as a placeholder (or omit it); routing is unaffected either way.";
|
package/src/codex/catalog.ts
CHANGED
|
@@ -466,6 +466,14 @@ export interface CatalogModel {
|
|
|
466
466
|
provider: string;
|
|
467
467
|
/** Public Codex-facing slug override (used by combo aliases). */
|
|
468
468
|
alias?: string;
|
|
469
|
+
/**
|
|
470
|
+
* Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
|
|
471
|
+
* affects the routing slug, alias-collision order, native marketing-name precedence, or provider
|
|
472
|
+
* behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior).
|
|
473
|
+
* Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
|
|
474
|
+
* which carries no CatalogModel, so a configured displayName can never override a native name.
|
|
475
|
+
*/
|
|
476
|
+
displayName?: string;
|
|
469
477
|
owned_by?: string;
|
|
470
478
|
reasoningEfforts?: string[];
|
|
471
479
|
defaultReasoningEffort?: string;
|
|
@@ -898,6 +906,14 @@ function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void
|
|
|
898
906
|
// This marker survives strict catalog normalization and lets sync distinguish a stale
|
|
899
907
|
// bare combo alias from a genuine native model row.
|
|
900
908
|
if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
|
|
909
|
+
// displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing
|
|
910
|
+
// slug, alias, or provider. deriveEntry already stamped the slug as display_name; a
|
|
911
|
+
// configured displayName overrides just the label. The `/` separator is rejected at every
|
|
912
|
+
// input boundary (CLI `ocx models add`, management API), so the catalog trusts its source.
|
|
913
|
+
// Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine
|
|
914
|
+
// upstream marketing names and combo alias labels are preserved untouched.
|
|
915
|
+
const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
|
|
916
|
+
if (displayName) entry.display_name = displayName;
|
|
901
917
|
if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
|
|
902
918
|
entry.context_window = model.contextWindow;
|
|
903
919
|
entry.max_context_window = model.contextWindow;
|
|
@@ -1494,6 +1510,17 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1494
1510
|
provider: name,
|
|
1495
1511
|
...catalogHintsFromProviderConfig(name, prov, id, contextCap),
|
|
1496
1512
|
}));
|
|
1513
|
+
// A configured default is a real callable selector and must remain discoverable when a
|
|
1514
|
+
// compatible provider's live /models request fails (issue #308). Keep this separate from the
|
|
1515
|
+
// explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
|
|
1516
|
+
// rows, while a failed live discovery may degrade to the default selector.
|
|
1517
|
+
const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
|
|
1518
|
+
? configured
|
|
1519
|
+
: [{
|
|
1520
|
+
id: prov.defaultModel,
|
|
1521
|
+
provider: name,
|
|
1522
|
+
...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap),
|
|
1523
|
+
}];
|
|
1497
1524
|
const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
|
|
1498
1525
|
const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
|
|
1499
1526
|
vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
|
|
@@ -1536,7 +1563,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1536
1563
|
// A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
|
|
1537
1564
|
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
|
|
1538
1565
|
const stale = getStaleCached(name);
|
|
1539
|
-
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) :
|
|
1566
|
+
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
|
|
1540
1567
|
}
|
|
1541
1568
|
const { url, headers } = buildModelsRequest(prov, apiKey, name);
|
|
1542
1569
|
const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
|
|
@@ -1548,7 +1575,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1548
1575
|
return {
|
|
1549
1576
|
models: stale
|
|
1550
1577
|
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
|
|
1551
|
-
:
|
|
1578
|
+
: failedDiscoveryConfigured,
|
|
1552
1579
|
fallback: stale ? "stale" : "configured",
|
|
1553
1580
|
};
|
|
1554
1581
|
};
|
|
@@ -1779,6 +1806,8 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
|
|
|
1779
1806
|
const customModels = (config.customModels ?? []).map(cm => ({
|
|
1780
1807
|
id: cm.modelId,
|
|
1781
1808
|
provider: cm.provider,
|
|
1809
|
+
// Display-only label: never feeds routing (customModels are keyed by routedSlug below).
|
|
1810
|
+
...(cm.displayName ? { displayName: cm.displayName } : {}),
|
|
1782
1811
|
...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
|
|
1783
1812
|
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
1784
1813
|
}));
|
package/src/codex/home.ts
CHANGED
|
@@ -79,9 +79,9 @@ function readProcVersion(): string | null {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function isWslRuntime(deps: CodexHomeDeps = {}): boolean {
|
|
82
|
+
if ((deps.platform ?? process.platform) !== "linux") return false;
|
|
82
83
|
const env = deps.env ?? process.env;
|
|
83
84
|
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
84
|
-
if ((deps.platform ?? process.platform) !== "linux") return false;
|
|
85
85
|
const version = `${deps.release ?? ""}\n${deps.procVersion ?? readProcVersion() ?? ""}`;
|
|
86
86
|
return /microsoft|wsl/i.test(version);
|
|
87
87
|
}
|