@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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.
Files changed (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-cmds12BG.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Czw-jpTU.css">
19
+ <script type="module" crossorigin src="/assets/index-GC0Vlu1Z.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BHsKRFh9.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.43",
3
+ "version": "2.8.2-preview.20260731",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -250,6 +250,40 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean {
250
250
  }
251
251
  }
252
252
 
253
+ /** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */
254
+ export function anthropicMessagesUrl(baseUrl: string): string {
255
+ try {
256
+ new URL(baseUrl);
257
+ } catch {
258
+ throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`);
259
+ }
260
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
261
+ const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, "");
262
+ return `${root}/v1/messages`;
263
+ }
264
+
265
+ function synthesizeToolUseId(): string {
266
+ return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
267
+ }
268
+
269
+ function toolUseArguments(input: unknown): string {
270
+ if (typeof input === "string") {
271
+ const trimmed = input.trim();
272
+ if (!trimmed) return "{}";
273
+ try {
274
+ JSON.parse(trimmed);
275
+ return trimmed;
276
+ } catch {
277
+ // A tool call's arguments must be a JSON object. Re-encoding an unparseable string as a
278
+ // JSON *string* is the double-encoding #765 reports: the caller then receives
279
+ // `"get weather"` where an object was required and the tool call is unusable either way.
280
+ // An empty object at least fails in the tool's own argument validation.
281
+ return "{}";
282
+ }
283
+ }
284
+ return JSON.stringify(input ?? {});
285
+ }
286
+
253
287
  function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
254
288
  return provider.apiKeyTransport === "bearer";
255
289
  }
@@ -680,8 +714,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
680
714
  else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) };
681
715
  }
682
716
 
683
- const base = provider.baseUrl.replace(/\/v1\/?$/, "");
684
- const url = `${base}/v1/messages`;
717
+ const url = anthropicMessagesUrl(provider.baseUrl);
685
718
  const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
686
719
  if (unresolvedPlaceholder) {
687
720
  throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
@@ -773,7 +806,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
773
806
  if (!block) break;
774
807
  currentBlockType = block.type;
775
808
  if (block.type === "tool_use") {
776
- currentToolCallId = block.id ?? "";
809
+ currentToolCallId = block.id ?? synthesizeToolUseId();
777
810
  currentToolCallName = toolNames.fromWire(block.name ?? "");
778
811
  yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
779
812
  }
@@ -799,7 +832,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
799
832
  // Arrives once, just before the thinking block's content_block_stop; block-scoped
800
833
  // so a stray signature on a non-thinking block can never be captured.
801
834
  yield { type: "thinking_signature", signature: delta.signature };
802
- } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
835
+ } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
803
836
  yield { type: "tool_call_delta", arguments: delta.partial_json };
804
837
  }
805
838
  break;
@@ -831,12 +864,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
831
864
  }
832
865
  }
833
866
  if (!emittedDone) {
867
+ // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
834
868
  if (pendingStopReason !== undefined) {
835
869
  const stopReason = pendingStopReason === "max_tokens"
836
870
  ? "max_tokens"
837
871
  : pendingStopReason === "refusal" || pendingStopReason === "content_filter"
838
872
  ? "content_filter"
839
- : undefined;
873
+ : pendingStopReason;
840
874
  emittedDone = true;
841
875
  yield {
842
876
  type: "done",
@@ -867,8 +901,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
867
901
  } else if (block.type === "redacted_thinking" && typeof block.data === "string") {
868
902
  events.push({ type: "redacted_thinking", data: block.data });
869
903
  } else if (block.type === "tool_use") {
870
- events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") });
871
- events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) });
904
+ const id = block.id ?? synthesizeToolUseId();
905
+ events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
906
+ events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
872
907
  events.push({ type: "tool_call_end" });
873
908
  }
874
909
  }
@@ -167,7 +167,7 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
167
167
  // the request builder appends the per-model suffix (see effort-map.ts) and reasoning models
168
168
  // advertise effort so Codex exposes the tier picker. `supportsReasoningEffort` tracks whether the
169
169
  // model has *selectable effort tiers* (CURSOR_MODEL_EFFORT_TIERS), NOT merely whether it reasons:
170
- // gemini/grok/kimi/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker).
170
+ // gemini/grok/kimi-k2.7/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker).
171
171
  ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })),
172
172
 
173
173
  { id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
@@ -224,6 +224,9 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
224
224
  // take PR #73's supportsReasoningEffort for glm-5.2 (its effort-map tiers landed with the PR).
225
225
  { id: "glm-5.2", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
226
226
  { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K },
227
+ // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) —
228
+ // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed.
229
+ { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true },
227
230
 
228
231
  { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true },
229
232
  { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true },
@@ -27,6 +27,9 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
27
27
  "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
28
28
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
29
29
  "glm-5.2": ["high", "max"],
30
+ // GetUsableModels (2026-07-28) lists kimi-k3 only as effort-suffixed kimi-k3-{low,high,max};
31
+ // the bare id returns not_found. Tiers mirror the native Kimi provider's K3 ladder.
32
+ "kimi-k3": ["low", "high", "max"],
30
33
  // GetUsableModels (2026-07-09) lists grok-4.5-{medium,high,xhigh} and grok-4.5-fast-{medium,high,xhigh};
31
34
  // the bare "grok-4.5-fast" id was removed upstream and now returns not_found.
32
35
  "grok-4.5": ["medium", "high", "xhigh"],
@@ -498,7 +498,11 @@ export function buildKiroPayload(
498
498
  if (!priorCalls.has(toolUseId)) {
499
499
  throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
500
500
  }
501
- pushUser(KIRO_TOOL_RESULT_CARRIER_MESSAGE, images, [{
501
+ // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix.
502
+ // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code
503
+ // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the
504
+ // newest user intent behind boilerplate. Backfill below only when nothing else speaks.
505
+ pushUser("", images, [{
502
506
  content: [{ text: resultText }],
503
507
  status: tr.isError ? "error" : "success",
504
508
  toolUseId,
@@ -518,6 +522,16 @@ export function buildKiroPayload(
518
522
  });
519
523
  }
520
524
 
525
+ // Give tool-result turns a carrier sentence ONLY when they carry no other text. This runs
526
+ // before the pop below so the current turn is covered too: skipping it there would ship an
527
+ // empty current content, which validateKiroConversationState accepts (tool results count as
528
+ // payload) and would therefore fail silently.
529
+ for (const turn of turns) {
530
+ if (turn.kind === "user" && !turn.content.trim() && turn.toolResults.length > 0) {
531
+ turn.content = KIRO_TOOL_RESULT_CARRIER_MESSAGE;
532
+ }
533
+ }
534
+
521
535
  const currentTurn = turns.pop();
522
536
  if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
523
537
  const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
@@ -206,10 +206,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
206
206
  }));
207
207
  // "" instead of null: strict validators (xAI: "Each message must have at least one
208
208
  // content element", langchain#34140) reject content-less assistant history entries.
209
- if (!chatMsg.content) chatMsg.content = "";
209
+ if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
210
210
  }
211
211
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
212
- chatMsg.content = "";
212
+ chatMsg.content = emptyAssistantContent(provider);
213
213
  }
214
214
  out.push(chatMsg);
215
215
  pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
@@ -238,7 +238,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
238
238
  const name = safeToolName(msg.toolName);
239
239
  out.push({
240
240
  role: "assistant",
241
- content: "",
241
+ content: emptyAssistantContent(provider),
242
242
  tool_calls: [{
243
243
  id: toolCallId,
244
244
  type: "function",
@@ -372,6 +372,43 @@ function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
372
372
  }
373
373
  }
374
374
 
375
+ // Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
376
+ // REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
377
+ // `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
378
+ // "", and xAI actively requires it ("Each message must have at least one content element"), so
379
+ // the two contracts are in direct conflict and this cannot be a global change.
380
+ const VOLCENGINE_ARK_HOSTNAMES = new Set([
381
+ "ark.cn-beijing.volces.com",
382
+ "ark.ap-southeast.volces.com",
383
+ ]);
384
+
385
+ function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
386
+ try {
387
+ return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
395
+ *
396
+ * UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
397
+ * which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
398
+ * bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
399
+ * published examples only show array content for MULTIMODAL USER input, never for an assistant
400
+ * history entry, so this shape is inferred from the error message and not confirmed by the docs
401
+ * or by a live request. The empty inner text at least adds no tokens either way.
402
+ *
403
+ * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
404
+ *
405
+ * Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
406
+ * message must have at least one content element"), so this cannot be applied globally.
407
+ */
408
+ function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
409
+ return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
410
+ }
411
+
375
412
  /**
376
413
  * Kimi requires function.parameters.type to be exactly "object" at the root.
377
414
  * Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
@@ -800,12 +837,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
800
837
  if (buffer.length > 0) {
801
838
  if ((yield* handleDataLine(buffer)) === "terminate") return;
802
839
  }
803
- yield* flushToolCalls();
804
840
  // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
805
841
  // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
806
842
  // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
807
843
  // closed so the bridge emits a classified response.failed rather than a silent truncation.
844
+ //
845
+ // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
846
+ // taking it back: a half-assembled argument string would reach the client as a completed
847
+ // call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
848
+ // fragments live), so this adapter can still decide.
808
849
  const sawFinish = finishReason !== undefined;
850
+ if (!sawFinish && pendingToolCalls.length > 0) {
851
+ debugProviderDiagnostic("openai-chat", "stream-truncated", {
852
+ finishReason: null,
853
+ hadUsage: pendingUsage !== undefined,
854
+ pendingToolCalls: pendingToolCalls.length,
855
+ });
856
+ yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
857
+ return;
858
+ }
859
+ yield* flushToolCalls();
809
860
  if (!sawFinish && pendingUsage === undefined) {
810
861
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
811
862
  finishReason: finishReason ?? null,
@@ -7,9 +7,19 @@
7
7
  * deterministic, reversible, and STABLE across releases (picker selections
8
8
  * persist to Claude Code's settings.json `model` field).
9
9
  *
10
+ * Versioned prefixes:
11
+ * - `claude-ocx-` (v1) — legacy / plain model ids with no `/` or `~`. Decode
12
+ * is literal (no escape expansion), so a persisted model id that literally
13
+ * contained the two-char sequences `~s` / `~t` keeps resolving.
14
+ * - `claude-ocx2-` (v2) — used whenever the model id needs escape encoding
15
+ * (`/` → `~s`, `~` → `~t`). Decode expands those escapes. New slash/tilde
16
+ * models always mint v2 so they cannot collide with v1 literals.
17
+ *
10
18
  * Reversibility rules:
11
19
  * - providers containing `--` or `/` are not aliased (split boundary safety);
12
- * - model ids containing `/` are not aliased (would be ambiguous on resolve);
20
+ * - model ids MAY contain `/` or `~` minted under the v2 prefix with escapes
21
+ * (e.g. openrouter `anthropic/claude-opus-4-8` →
22
+ * `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`);
13
23
  * - model ids MAY contain `--` (resolve splits on the FIRST `--` only);
14
24
  * - native OpenAI slugs use the pseudo-provider `native` and resolve back to
15
25
  * the bare slug; a real provider named "native" is therefore never aliased.
@@ -17,20 +27,82 @@
17
27
 
18
28
  import { desktop3pAlias } from "./desktop-3p";
19
29
 
20
- export const CLAUDE_ALIAS_PREFIX = "claude-ocx-";
30
+ /** Legacy / plain readable prefix (literal model portion on decode). */
31
+ export const CLAUDE_ALIAS_PREFIX_V1 = "claude-ocx-";
32
+ /** Escape-encoded readable prefix (`~s`/`~t` expanded on decode). */
33
+ export const CLAUDE_ALIAS_PREFIX_V2 = "claude-ocx2-";
34
+ /**
35
+ * Current write prefix for plain (unescaped) model ids.
36
+ * Escape-needing models mint {@link CLAUDE_ALIAS_PREFIX_V2} instead.
37
+ */
38
+ export const CLAUDE_ALIAS_PREFIX = CLAUDE_ALIAS_PREFIX_V1;
39
+
40
+ /** Encoded `/` inside the model portion of a v2 Claude Code alias. */
41
+ const CLAUDE_ALIAS_SLASH_ENC = "~s";
42
+ /** Encoded literal `~` inside the model portion of a v2 Claude Code alias. */
43
+ const CLAUDE_ALIAS_TILDE_ENC = "~t";
21
44
  const NATIVE_PSEUDO_PROVIDER = "native";
22
45
 
46
+ function modelNeedsEscapeEncoding(modelId: string): boolean {
47
+ return modelId.includes("/") || modelId.includes("~");
48
+ }
49
+
50
+ function encodeModelId(modelId: string): string {
51
+ // Escape literal tildes first so slash encoding cannot create ambiguity.
52
+ return modelId
53
+ .replaceAll("~", CLAUDE_ALIAS_TILDE_ENC)
54
+ .replaceAll("/", CLAUDE_ALIAS_SLASH_ENC);
55
+ }
56
+
57
+ function decodeEscapedModelId(encoded: string): string {
58
+ let out = "";
59
+ for (let i = 0; i < encoded.length; i++) {
60
+ if (encoded[i] === "~" && i + 1 < encoded.length) {
61
+ const next = encoded[i + 1];
62
+ if (next === "s") {
63
+ out += "/";
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (next === "t") {
68
+ out += "~";
69
+ i += 1;
70
+ continue;
71
+ }
72
+ }
73
+ out += encoded[i];
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function splitAlias(id: string, prefix: string): { provider: string; model: string } | null {
79
+ const rest = id.slice(prefix.length);
80
+ const sep = rest.indexOf("--");
81
+ if (sep <= 0) return null;
82
+ const provider = rest.slice(0, sep);
83
+ const model = rest.slice(sep + 2);
84
+ if (!provider || !model) return null;
85
+ return { provider, model };
86
+ }
87
+
23
88
  /** Alias for a routed "<provider>/<model>" pair; null when not representable. */
24
89
  export function aliasForRoute(provider: string, modelId: string): string | null {
25
90
  if (!provider || provider.includes("--") || provider.includes("/") || provider === NATIVE_PSEUDO_PROVIDER) return null;
26
- if (!modelId || modelId.includes("/")) return null;
27
- return `${CLAUDE_ALIAS_PREFIX}${provider}--${modelId}`;
91
+ if (!modelId) return null;
92
+ if (modelNeedsEscapeEncoding(modelId)) {
93
+ return `${CLAUDE_ALIAS_PREFIX_V2}${provider}--${encodeModelId(modelId)}`;
94
+ }
95
+ return `${CLAUDE_ALIAS_PREFIX_V1}${provider}--${modelId}`;
28
96
  }
29
97
 
30
98
  /** Alias for a native OpenAI slug (bare model id, no provider namespace). */
31
99
  export function aliasForNative(slug: string): string | null {
100
+ // Reject "/" — native ids are bare slugs. Literal `~` is fine via v2 + ~t.
32
101
  if (!slug || slug.includes("/") || slug.includes("--")) return null;
33
- return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${slug}`;
102
+ if (modelNeedsEscapeEncoding(slug)) {
103
+ return `${CLAUDE_ALIAS_PREFIX_V2}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`;
104
+ }
105
+ return `${CLAUDE_ALIAS_PREFIX_V1}${NATIVE_PSEUDO_PROVIDER}--${slug}`;
34
106
  }
35
107
 
36
108
  /**
@@ -38,20 +110,28 @@ export function aliasForNative(slug: string): string | null {
38
110
  * routed -> "<provider>/<model>", native -> bare slug. Null when not an alias.
39
111
  */
40
112
  export function resolveAlias(id: string): string | null {
41
- if (!id.startsWith(CLAUDE_ALIAS_PREFIX)) return null;
42
- const rest = id.slice(CLAUDE_ALIAS_PREFIX.length);
43
- const sep = rest.indexOf("--");
44
- if (sep <= 0) return null;
45
- const provider = rest.slice(0, sep);
46
- const model = rest.slice(sep + 2);
47
- if (!model) return null;
48
- return provider === NATIVE_PSEUDO_PROVIDER ? model : `${provider}/${model}`;
113
+ // Check v2 before v1 for clarity (prefixes are disjoint: ocx2 vs ocx-).
114
+ if (id.startsWith(CLAUDE_ALIAS_PREFIX_V2)) {
115
+ const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V2);
116
+ if (!parts) return null;
117
+ const model = decodeEscapedModelId(parts.model);
118
+ if (!model) return null;
119
+ return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`;
120
+ }
121
+ if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1)) {
122
+ const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V1);
123
+ if (!parts) return null;
124
+ // Literal decode — preserves pre-escape aliases whose model id contained
125
+ // the two-char sequences ~s / ~t.
126
+ return parts.provider === NATIVE_PSEUDO_PROVIDER ? parts.model : `${parts.provider}/${parts.model}`;
127
+ }
128
+ return null;
49
129
  }
50
130
 
51
131
  /**
52
132
  * Claude Code (CLI) surface alias — devlog 050 + audit 051 #2.
53
133
  *
54
- * The readable `claude-ocx-*` form when representable; otherwise the desktop-3p
134
+ * The readable `claude-ocx*` form when representable; otherwise the desktop-3p
55
135
  * hash so the model still appears in discovery (collisions follow the same
56
136
  * first-wins policy as the desktop registry — audit 051 #1). Real Anthropic
57
137
  * models pass through unchanged (they must keep hitting the sk-ant passthrough).
@@ -453,12 +453,15 @@ export function responsesSseToAnthropicSse(
453
453
  if (line.startsWith("event: ")) eventName = line.slice(7).trim();
454
454
  else if (line.startsWith("data: ")) dataLine += line.slice(6);
455
455
  }
456
- if (!eventName || !dataLine) continue;
456
+ if (!dataLine) continue;
457
457
  let data: unknown;
458
458
  try { data = JSON.parse(dataLine); } catch { continue; }
459
459
  if (!isRec(data)) continue;
460
- if (terminated) continue;
461
- handleFrame(eventName, data);
460
+ // Responses-compatible gateways may omit the optional SSE event field
461
+ // while retaining the event name in the JSON payload's required type.
462
+ const resolvedEventName = eventName || (typeof data.type === "string" ? data.type : "");
463
+ if (!resolvedEventName || terminated) continue;
464
+ handleFrame(resolvedEventName, data);
462
465
  }
463
466
  }
464
467
  // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
@@ -0,0 +1,24 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ type GatherRoutedModels = (config: OcxConfig) => Promise<unknown>;
4
+
5
+ export type CatalogPrewarmDeps = {
6
+ loadConfig?: () => OcxConfig;
7
+ importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>;
8
+ };
9
+
10
+ /**
11
+ * After the listen port is bound, kick off live provider discovery so the first
12
+ * GUI /v1/models and syncModelsToCodex share one gather flight instead of racing
13
+ * duplicate upstream /models fetches.
14
+ */
15
+ export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void {
16
+ void Promise.resolve()
17
+ .then(async () => {
18
+ const load = deps.loadConfig ?? (await import("../config")).loadConfig;
19
+ const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog"));
20
+ return gatherRoutedModels(load());
21
+ })
22
+ .catch(() => {});
23
+ }
24
+
@@ -10,7 +10,7 @@ import {
10
10
  type DesktopProfile,
11
11
  } from "../claude/desktop-profile";
12
12
  import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p";
13
- import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
13
+ import { filterCatalogVisibleModels, desktopVisibleNativeSlugs } from "../codex/catalog";
14
14
  import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api";
15
15
  import { findLiveProxy } from "../server/proxy-liveness";
16
16
 
@@ -42,7 +42,7 @@ async function applyProfile(profile: DesktopProfile, mode: Desktop3pConfigMode):
42
42
  }));
43
43
  const result = writeDesktop3pConfig(
44
44
  live?.port ?? config.port ?? 10100,
45
- [...visibleNativeSlugs(config)],
45
+ [...desktopVisibleNativeSlugs(config)],
46
46
  routed,
47
47
  config.apiKeys?.[0]?.key,
48
48
  mode,
package/src/cli/claude.ts CHANGED
@@ -48,6 +48,27 @@ export function buildClaudeEnv(
48
48
  // leaving the child with no token at all (audit R2-1). It is opencodex state, never
49
49
  // user auth, so dropping it unconditionally is safe.
50
50
  if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
51
+ // Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
52
+ // project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
53
+ // moment either token slot is populated, so an ambient project file silently moved a
54
+ // subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
55
+ // runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
56
+ // before Bun started; anything populated now but absent then came from the working
57
+ // directory, not from the user. A genuine shell export is still honored, which keeps
58
+ // auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
59
+ // (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
60
+ // change nothing rather than guess — an EMPTY marker is different: the launcher ran
61
+ // and saw no pre-existing slots.
62
+ const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
63
+ if (preBunSlots !== undefined) {
64
+ const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
65
+ for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
66
+ const value = env[name];
67
+ if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
68
+ }
69
+ }
70
+ // Never forward the seam itself to Claude Code.
71
+ delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
51
72
  const setDefault = (name: string, value: string | undefined) => {
52
73
  if (value === undefined || value.length === 0) return;
53
74
  if (env[name] !== undefined && env[name] !== "") return; // user wins
@@ -76,15 +97,19 @@ export function buildClaudeEnv(
76
97
  if ((config.apiKeys?.length ?? 0) > 0) {
77
98
  setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
78
99
  }
79
- // Detection reads the SAME base env this launch will use, so the resolver and the
80
- // spawned process cannot disagree. Injected deps are spread FIRST and `env` bound
81
- // LAST, and the injection type excludes `env`, so a test fake cannot break that.
82
- // `ownTokens` is bound last for the same reason: it is config-derived, and a fake
83
- // that replaced it could make our own admission key look like user auth.
100
+ // Detection reads the SANITIZED launch env the exact object spawned below so the
101
+ // resolver and the spawned process cannot disagree. It deliberately does NOT read the
102
+ // raw base: the provenance strip above already removed dotenv-only credentials, and
103
+ // letting a value the child never receives decide the marker left an auto-mode user
104
+ // with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
105
+ // are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
106
+ // test fake cannot break that. `ownTokens` is bound last for the same reason: it is
107
+ // config-derived, and a fake that replaced it could make our own admission key look
108
+ // like user auth.
84
109
  const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
85
- ...defaultAuthDetectDeps(base as NodeJS.ProcessEnv),
110
+ ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
86
111
  ...(deps.authDetect ?? {}),
87
- env: () => base as NodeJS.ProcessEnv,
112
+ env: () => env as NodeJS.ProcessEnv,
88
113
  ownTokens: ownAdmissionTokens(config),
89
114
  }));
90
115
  if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
package/src/cli/doctor.ts CHANGED
@@ -307,13 +307,47 @@ export type ConfiguredProxyDiagnostic = {
307
307
  detail: string;
308
308
  };
309
309
 
310
- function envReferenceName(value: string): string | null {
310
+ export function envReferenceName(value: string): string | null {
311
311
  const braced = value.match(/^\$\{(\w+)\}$/);
312
312
  if (braced) return braced[1]!;
313
313
  const bare = value.match(/^\$(\w+)$/);
314
314
  return bare ? bare[1]! : null;
315
315
  }
316
316
 
317
+ export type ProviderApiKeyDiagnostic = {
318
+ provider: string;
319
+ envName: string;
320
+ detail: string;
321
+ };
322
+
323
+ /** Warn when a key-auth provider's apiKey env reference resolves empty in this process. */
324
+ export function collectProviderApiKeyDiagnostics(
325
+ providers: Record<string, { authMode?: string; apiKey?: string }> = readConfigDiagnostics().config.providers ?? {},
326
+ env: EnvMap = process.env,
327
+ ): ProviderApiKeyDiagnostic[] {
328
+ const resolveInEnv = (value: string): string | undefined => {
329
+ const name = envReferenceName(value);
330
+ if (!name) return value;
331
+ return env[name];
332
+ };
333
+ const rows: ProviderApiKeyDiagnostic[] = [];
334
+ for (const [provider, config] of Object.entries(providers)) {
335
+ if (config.authMode !== "key") continue;
336
+ const raw = typeof config.apiKey === "string" ? config.apiKey.trim() : "";
337
+ if (!raw) continue;
338
+ const envName = envReferenceName(raw);
339
+ if (!envName) continue;
340
+ const resolved = resolveInEnv(raw);
341
+ if (resolved?.trim()) continue;
342
+ rows.push({
343
+ provider,
344
+ envName,
345
+ detail: `provider ${provider}: env reference ${envName} is unset or empty in this process`,
346
+ });
347
+ }
348
+ return rows;
349
+ }
350
+
317
351
  export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
318
352
  const diagnostics = readConfigDiagnostics();
319
353
  const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
@@ -742,6 +776,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
742
776
  console.log("\nConfigured proxy (value hidden)");
743
777
  console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
744
778
 
779
+ const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers);
780
+ console.log("\nProvider API keys (value hidden)");
781
+ if (providerApiKeys.length === 0) {
782
+ console.log(" ok no empty env-referenced provider keys detected in this process");
783
+ } else {
784
+ for (const row of providerApiKeys) {
785
+ console.log(` !! ${row.detail}`);
786
+ }
787
+ }
788
+
745
789
  console.log("\nRunning proxy process proxy env (presence only)");
746
790
  if (runningProxyEnv.status === "not_running") {
747
791
  console.log(" -- no running ocx proxy process found");
@@ -825,6 +869,9 @@ export async function runDoctor(args: string[] = []): Promise<void> {
825
869
  serviceViable: startup.serviceViable,
826
870
  });
827
871
  if (proxyDown) hints.push(proxyDown);
872
+ for (const row of providerApiKeys) {
873
+ hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
874
+ }
828
875
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
829
876
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
830
877
  if (!startup.rebootSafe) {
package/src/cli/index.ts CHANGED
@@ -38,6 +38,7 @@ import { startTokenGuardian } from "../oauth/token-guardian";
38
38
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
39
39
  import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
40
40
  import { maybeShowStarPrompt } from "./star-prompt";
41
+ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
41
42
  import { maybeShowUpdatePrompt } from "../update/notify";
42
43
  import { syncModelsToCodex } from "../codex/sync";
43
44
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
@@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) {
190
191
  for (let attempt = 0; ; attempt++) {
191
192
  try {
192
193
  server = startServer(port);
194
+ // Prewarm the live provider model cache as soon as the port is bound so the
195
+ // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
196
+ // instead of racing duplicate upstream /models fetches.
197
+ scheduleCatalogPrewarm();
193
198
  break;
194
199
  } catch (err) {
195
200
  if (!isAddrInUse(err) || attempt >= 2) throw err;