@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.
@@ -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, "sonnet" is the cheap canonical one.
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: \"sonnet\" as a placeholder (or omit it); routing is unaffected either way.";
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.";
@@ -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)) : configured;
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
- : configured,
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
  }