@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.
- package/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +42 -7
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/adapters/openai-chat.ts +55 -4
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/init.ts +129 -102
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +3 -3
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +21 -3
- package/src/lib/provider-outbound.ts +8 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/winsw.ts +6 -0
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +129 -9
- package/src/oauth/kiro.ts +15 -3
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/index.ts +3 -3
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +82 -8
- package/src/server/management/config-routes.ts +24 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +61 -14
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +18 -5
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/proxy-liveness.ts +9 -2
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +395 -31
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +86 -13
- package/src/types.ts +16 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
871
|
-
events.push({ type: "
|
|
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"],
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/claude/alias.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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).
|
package/src/claude/outbound.ts
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
461
|
-
|
|
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,
|
|
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
|
-
[...
|
|
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
|
|
80
|
-
// spawned process cannot disagree.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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(
|
|
110
|
+
...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
|
|
86
111
|
...(deps.authDetect ?? {}),
|
|
87
|
-
env: () =>
|
|
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;
|