@bitkyc08/opencodex 2.10.2 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/adapters/google.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type TranslatorBudget,
|
|
31
31
|
} from "../lib/translator-budget";
|
|
32
32
|
import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
|
|
33
|
-
import { mapReasoningEffort } from "../reasoning-effort";
|
|
33
|
+
import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
|
|
34
34
|
|
|
35
35
|
// Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
|
|
36
36
|
// tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally
|
|
@@ -340,12 +340,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
340
340
|
if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature;
|
|
341
341
|
if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP;
|
|
342
342
|
if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
343
|
+
// Effort → thinkingLevel follows the configured ladder: any model advertising reasoning
|
|
344
|
+
// efforts (registry preset or user config) sends the mapped level, so a picker-selected
|
|
345
|
+
// effort actually reaches the wire (gemini-3.1-pro-preview ships a ladder). The original
|
|
346
|
+
// gemini-3.5/3.6-flash direct-mode slice stays hardcoded so unladdered configs keep their
|
|
347
|
+
// current behavior; Vertex participates only through an explicitly configured ladder (the
|
|
348
|
+
// seed google-vertex entry ships none). Image models are excluded — thinkingConfig would
|
|
349
|
+
// suppress the responseModalities fallback below. CCA maps effort on its envelope path.
|
|
350
|
+
const thinkingEligible = provider.googleMode !== "cloud-code-assist"
|
|
351
|
+
&& !isImageCapableModel(parsed.modelId)
|
|
352
|
+
&& (configuredReasoningEfforts(provider, parsed.modelId) !== undefined
|
|
353
|
+
|| (provider.googleMode !== "vertex"
|
|
354
|
+
&& (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")));
|
|
355
|
+
const thinkingLevel = thinkingEligible
|
|
346
356
|
? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)
|
|
347
357
|
: undefined;
|
|
348
|
-
if (
|
|
358
|
+
if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel };
|
|
349
359
|
if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
|
|
350
360
|
generationConfig.responseModalities = ["TEXT", "IMAGE"];
|
|
351
361
|
}
|
|
@@ -3,6 +3,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx
|
|
|
3
3
|
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
|
|
4
4
|
import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
5
5
|
import { debugProviderDiagnostic } from "../lib/debug";
|
|
6
|
+
import { sseFieldValue } from "../lib/sse-decoder";
|
|
6
7
|
import { isDebugEnabled } from "../lib/debug-settings";
|
|
7
8
|
import { isCyberPolicyCode } from "../lib/errors";
|
|
8
9
|
import { redactSecretString } from "../lib/redact";
|
|
@@ -818,6 +819,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
818
819
|
if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
|
|
819
820
|
body.prompt_cache_key = parsed.options.promptCacheKey;
|
|
820
821
|
}
|
|
822
|
+
// Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
|
|
823
|
+
// re-nests the flattened Responses fields under `json_schema` — the exact inverse of
|
|
824
|
+
// responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
|
|
825
|
+
// response_format is a first-class Chat Completions field, it is only present when the
|
|
826
|
+
// caller explicitly asked for structured output, and a backend that rejects it should
|
|
827
|
+
// fail loud rather than silently return prose the caller will try to JSON.parse.
|
|
828
|
+
const textFormat = parsed.options.textFormat;
|
|
829
|
+
if (textFormat?.type === "json_object") {
|
|
830
|
+
body.response_format = { type: "json_object" };
|
|
831
|
+
} else if (textFormat?.type === "json_schema") {
|
|
832
|
+
body.response_format = {
|
|
833
|
+
type: "json_schema",
|
|
834
|
+
json_schema: {
|
|
835
|
+
name: textFormat.name ?? "response",
|
|
836
|
+
...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
|
|
837
|
+
...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
|
|
838
|
+
...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|
|
821
842
|
|
|
822
843
|
if (tools) {
|
|
823
844
|
// Default-ON for chat-completions providers (user decision 260709): the buffered
|
|
@@ -927,8 +948,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
927
948
|
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
|
|
928
949
|
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
|
|
929
950
|
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
930
|
-
|
|
931
|
-
|
|
951
|
+
const rawPayload = sseFieldValue(line, "data");
|
|
952
|
+
if (rawPayload === null) return "continue";
|
|
953
|
+
const payload = rawPayload.trim();
|
|
932
954
|
if (payload === "[DONE]") {
|
|
933
955
|
yield* flushToolCalls();
|
|
934
956
|
const stopReason = stopReasonFor(finishReason);
|
|
@@ -1048,7 +1048,8 @@ function stripInputImagesDeep(value: unknown): unknown {
|
|
|
1048
1048
|
*/
|
|
1049
1049
|
function buildRoutedCompactionBody(body: unknown): unknown {
|
|
1050
1050
|
if (!isPlainObject(body)) return body;
|
|
1051
|
-
|
|
1051
|
+
// `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON.
|
|
1052
|
+
const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body;
|
|
1052
1053
|
const input = Array.isArray(body.input) ? body.input : [];
|
|
1053
1054
|
const kept = input.filter(item => !isPlainObject(item)
|
|
1054
1055
|
// `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
|
package/src/bridge.ts
CHANGED
|
@@ -827,8 +827,10 @@ export function bridgeToResponsesSSE(
|
|
|
827
827
|
if (currentReasoning) closeCurrentReasoning();
|
|
828
828
|
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
829
829
|
flushHiddenRawReasoning();
|
|
830
|
-
// Reasoning consumed by a text turn, not a tool call: no cache target.
|
|
831
|
-
|
|
830
|
+
// Reasoning consumed by a REAL text turn, not a tool call: no cache target.
|
|
831
|
+
// Empty text deltas must not wipe reasoning that precedes a tool call
|
|
832
|
+
// (chat-completions providers emit empty content deltas mid-tool-turn).
|
|
833
|
+
if (event.text.length > 0) rawReasoningForNextToolCall = "";
|
|
832
834
|
if (currentToolCall) closeCurrentToolCall();
|
|
833
835
|
// Only flush on an explicit phase change. A later delta that omits `phase` must
|
|
834
836
|
// keep appending to the current message rather than wiping the earlier phase.
|
|
@@ -880,7 +882,7 @@ export function bridgeToResponsesSSE(
|
|
|
880
882
|
if (currentMsg) closeCurrentMessage("commentary");
|
|
881
883
|
if (currentRawReasoning) closeCurrentRawReasoning();
|
|
882
884
|
flushHiddenRawReasoning();
|
|
883
|
-
rawReasoningForNextToolCall = "";
|
|
885
|
+
if (event.thinking.length > 0) rawReasoningForNextToolCall = "";
|
|
884
886
|
if (currentToolCall) closeCurrentToolCall();
|
|
885
887
|
if (!currentReasoning) {
|
|
886
888
|
const itemId = `rs_${uuid()}`;
|
|
@@ -1561,7 +1563,9 @@ function buildResponseJSONWithBudget(
|
|
|
1561
1563
|
if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
|
|
1562
1564
|
if (currentSummaryReasoning) flushSummaryReasoning();
|
|
1563
1565
|
if (currentRawReasoning) flushRawReasoning();
|
|
1564
|
-
|
|
1566
|
+
// Empty text deltas (batch chat responses always carry content, often "") must
|
|
1567
|
+
// not wipe reasoning that precedes a tool call (#950 non-streaming path).
|
|
1568
|
+
if (e.text.length > 0) rawReasoningForNextToolCall = "";
|
|
1565
1569
|
if (currentToolCallId) flushToolCall();
|
|
1566
1570
|
// Compaction turns keep the summary out of normal message output (replay dedup — see
|
|
1567
1571
|
// bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
|
|
@@ -1580,7 +1584,7 @@ function buildResponseJSONWithBudget(
|
|
|
1580
1584
|
case "thinking_delta":
|
|
1581
1585
|
if (currentText) flushText("commentary");
|
|
1582
1586
|
if (currentRawReasoning) flushRawReasoning();
|
|
1583
|
-
rawReasoningForNextToolCall = "";
|
|
1587
|
+
if (e.thinking.length > 0) rawReasoningForNextToolCall = "";
|
|
1584
1588
|
if (currentToolCallId) flushToolCall();
|
|
1585
1589
|
{
|
|
1586
1590
|
({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
|
package/src/chat/outbound.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
type Rec = Record<string, unknown>;
|
|
9
9
|
|
|
10
|
-
import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
10
|
+
import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
|
|
11
11
|
import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
|
|
12
12
|
import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
|
|
13
13
|
|
|
@@ -671,8 +671,9 @@ export async function collectChatCompletion(
|
|
|
671
671
|
const rawFrame = buffer.slice(0, sep);
|
|
672
672
|
buffer = replaceRetained(buffer, buffer.slice(sep + 2), "live_transient");
|
|
673
673
|
for (const line of rawFrame.split("\n")) {
|
|
674
|
-
|
|
675
|
-
|
|
674
|
+
const rawData = sseFieldValue(line, "data");
|
|
675
|
+
if (rawData === null) continue;
|
|
676
|
+
const data = rawData.trim();
|
|
676
677
|
if (!data || data === "[DONE]") continue;
|
|
677
678
|
let parsed: unknown;
|
|
678
679
|
try { parsed = JSON.parse(data); } catch { continue; }
|
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { atomicWriteFile } from "../config";
|
|
@@ -103,6 +103,45 @@ interface Desktop3pMetadata {
|
|
|
103
103
|
[key: string]: unknown;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
export type Desktop3pLibraryKind =
|
|
107
|
+
| "not_installed"
|
|
108
|
+
| "standard"
|
|
109
|
+
| "gateway_ours"
|
|
110
|
+
| "gateway_drifted"
|
|
111
|
+
| "foreign"
|
|
112
|
+
| "no_owned_state"
|
|
113
|
+
| "broken"
|
|
114
|
+
| "unsafe";
|
|
115
|
+
|
|
116
|
+
export interface Desktop3pLibraryInspection {
|
|
117
|
+
kind: Desktop3pLibraryKind;
|
|
118
|
+
libraryPath: string;
|
|
119
|
+
selectedProfilePath: string | null;
|
|
120
|
+
appliedId: string | null;
|
|
121
|
+
/** Paths of opencodex-owned rows that are not selected by Desktop. */
|
|
122
|
+
residualPaths: string[];
|
|
123
|
+
/** Bounded reason code; never includes metadata or profile contents. */
|
|
124
|
+
reason?: "metadata_unreadable" | "unsafe_applied_id" | "invalid_owned_profile";
|
|
125
|
+
fingerprint?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Whether Desktop's applied selection is our owned entry, by ID match alone.
|
|
128
|
+
* `null` = undeterminable (no metadata, unreadable metadata, or no appliedId);
|
|
129
|
+
* a readable appliedId with no owned entry is a KNOWN false, not unknown.
|
|
130
|
+
* Deliberately independent of profile-file health: the status contract
|
|
131
|
+
* predates this inspector and callers render tri-state.
|
|
132
|
+
*/
|
|
133
|
+
ownedProfileActive: boolean | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Desktop3pRemovalResult {
|
|
137
|
+
ok: boolean;
|
|
138
|
+
changed: boolean;
|
|
139
|
+
kind: "removed" | "noop" | "cleanup_incomplete" | "unsafe" | "write_failed";
|
|
140
|
+
libraryPath: string;
|
|
141
|
+
residualPaths?: string[];
|
|
142
|
+
reason?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
let desktop3pRegistry = new Map<string, string>();
|
|
107
146
|
let desktop3pAliasesByRoute = new Map<string, string>();
|
|
108
147
|
|
|
@@ -327,6 +366,186 @@ function parseMetadata(path: string): Desktop3pMetadata {
|
|
|
327
366
|
return { ...parsed, entries: parsed.entries };
|
|
328
367
|
}
|
|
329
368
|
|
|
369
|
+
const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
370
|
+
|
|
371
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
372
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
|
|
376
|
+
return entry?.name === "opencodex" || entry?.name === "opencodex-standard";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A gateway row is removable; the selected standard row must always remain. */
|
|
380
|
+
function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
|
|
381
|
+
return entry?.name === "opencodex";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function profilePath(libraryPath: string, id: string): string {
|
|
385
|
+
return join(libraryPath, `${id}.json`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Read Desktop's selected config without changing its library.
|
|
390
|
+
*
|
|
391
|
+
* This is intentionally separate from the eager writer below: status probes must
|
|
392
|
+
* never manufacture a config-library directory on a machine without Desktop.
|
|
393
|
+
*/
|
|
394
|
+
export function inspectDesktop3pConfigLibrary(
|
|
395
|
+
options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {},
|
|
396
|
+
): Desktop3pLibraryInspection {
|
|
397
|
+
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
|
|
398
|
+
if (!existsSync(libraryPath)) {
|
|
399
|
+
return { kind: "not_installed", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const metadataPath = join(libraryPath, "_meta.json");
|
|
403
|
+
if (!existsSync(metadataPath)) {
|
|
404
|
+
return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let metadata: Desktop3pMetadata;
|
|
408
|
+
try {
|
|
409
|
+
metadata = parseMetadata(metadataPath);
|
|
410
|
+
} catch {
|
|
411
|
+
return {
|
|
412
|
+
kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", ownedProfileActive: null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const appliedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null;
|
|
416
|
+
if (appliedId === null) {
|
|
417
|
+
return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
|
|
418
|
+
}
|
|
419
|
+
const selected = metadata.entries.find(entry => entry?.id === appliedId);
|
|
420
|
+
// A readable appliedId with no owned entry is a KNOWN false, not unknown.
|
|
421
|
+
const ownedProfileActive = isOwnedDesktopEntry(selected);
|
|
422
|
+
if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) {
|
|
423
|
+
return {
|
|
424
|
+
kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", ownedProfileActive,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const selectedProfilePath = profilePath(libraryPath, appliedId);
|
|
429
|
+
const residualPaths = metadata.entries
|
|
430
|
+
.filter(entry => isOwnedDesktopGatewayEntry(entry) && entry.id !== appliedId && SAFE_DESKTOP_PROFILE_ID.test(entry.id))
|
|
431
|
+
.flatMap(entry => [profilePath(libraryPath, entry.id), `${profilePath(libraryPath, entry.id)}.bak`])
|
|
432
|
+
.filter(existsSync);
|
|
433
|
+
if (!existsSync(selectedProfilePath)) {
|
|
434
|
+
return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let profile: Record<string, unknown>;
|
|
438
|
+
let fingerprint: string;
|
|
439
|
+
try {
|
|
440
|
+
const source = readFileSync(selectedProfilePath, "utf8");
|
|
441
|
+
const parsed = JSON.parse(source) as unknown;
|
|
442
|
+
if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
|
|
443
|
+
profile = parsed;
|
|
444
|
+
fingerprint = createHash("sha256").update(source).digest("hex").slice(0, 16);
|
|
445
|
+
} catch {
|
|
446
|
+
return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
|
|
447
|
+
}
|
|
448
|
+
if (!isOwnedDesktopEntry(selected)) {
|
|
449
|
+
return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive };
|
|
450
|
+
}
|
|
451
|
+
if (profile.inferenceProvider === undefined) {
|
|
452
|
+
return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive };
|
|
453
|
+
}
|
|
454
|
+
const validGateway = profile.inferenceProvider === "gateway"
|
|
455
|
+
&& profile.inferenceCredentialKind === "static"
|
|
456
|
+
&& typeof profile.inferenceGatewayBaseUrl === "string"
|
|
457
|
+
&& typeof profile.inferenceGatewayApiKey === "string";
|
|
458
|
+
if (!validGateway) {
|
|
459
|
+
return {
|
|
460
|
+
kind: "unsafe", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, reason: "invalid_owned_profile", ownedProfileActive,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
kind: options.appliedFingerprint && options.appliedFingerprint === fingerprint ? "gateway_ours" : "gateway_drifted",
|
|
465
|
+
libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Select a credential-free standard profile before deleting an owned gateway.
|
|
471
|
+
* The old metadata row remains as a retry locator only until both its profile
|
|
472
|
+
* and backup are absent; successful cleanup removes it in the same operation.
|
|
473
|
+
*/
|
|
474
|
+
export function removeDesktop3pStandardPivot(
|
|
475
|
+
options: Desktop3pConfigLibraryOptions & {
|
|
476
|
+
appliedFingerprint?: string | null;
|
|
477
|
+
unlink?: (path: string) => void;
|
|
478
|
+
} = {},
|
|
479
|
+
): Desktop3pRemovalResult {
|
|
480
|
+
const inspected = inspectDesktop3pConfigLibrary(options);
|
|
481
|
+
if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") {
|
|
482
|
+
return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
|
|
483
|
+
}
|
|
484
|
+
if (inspected.kind === "broken" || inspected.kind === "unsafe" || inspected.kind === "gateway_drifted") {
|
|
485
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason };
|
|
486
|
+
}
|
|
487
|
+
if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) {
|
|
488
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: "unsafe_applied_id" };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const metadataPath = join(inspected.libraryPath, "_meta.json");
|
|
492
|
+
try {
|
|
493
|
+
const metadata = parseMetadata(metadataPath);
|
|
494
|
+
const selectedId = inspected.appliedId;
|
|
495
|
+
// When Desktop is actively using our gateway, pivot only that selected row
|
|
496
|
+
// first. Any second owned row is residue for a later standard-mode retry;
|
|
497
|
+
// this preserves the selected-row preference after an interrupted cleanup.
|
|
498
|
+
const targetIds = inspected.kind === "gateway_ours"
|
|
499
|
+
? [selectedId]
|
|
500
|
+
: metadata.entries
|
|
501
|
+
.filter(isOwnedDesktopGatewayEntry)
|
|
502
|
+
.map(entry => entry.id)
|
|
503
|
+
.filter(id => SAFE_DESKTOP_PROFILE_ID.test(id));
|
|
504
|
+
if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
|
|
505
|
+
|
|
506
|
+
let metadataAfterPivot = metadata;
|
|
507
|
+
if (inspected.kind === "gateway_ours") {
|
|
508
|
+
const standardId = randomUUID();
|
|
509
|
+
const standardPath = profilePath(inspected.libraryPath, standardId);
|
|
510
|
+
atomicWriteFile(standardPath, "{}\n");
|
|
511
|
+
const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
|
|
512
|
+
metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
|
|
513
|
+
atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const residualPaths: string[] = [];
|
|
517
|
+
for (const id of targetIds) {
|
|
518
|
+
for (const candidate of [profilePath(inspected.libraryPath, id), `${profilePath(inspected.libraryPath, id)}.bak`]) {
|
|
519
|
+
try {
|
|
520
|
+
if (existsSync(candidate)) (options.unlink ?? unlinkSync)(candidate);
|
|
521
|
+
} catch {
|
|
522
|
+
// Only the path is allowed to leave this credential-bearing cleanup boundary.
|
|
523
|
+
}
|
|
524
|
+
if (existsSync(candidate)) residualPaths.push(candidate);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const ownedResiduePaths = metadataAfterPivot.entries
|
|
528
|
+
.filter(entry => isOwnedDesktopGatewayEntry(entry) && !targetIds.includes(entry.id) && SAFE_DESKTOP_PROFILE_ID.test(entry.id))
|
|
529
|
+
.flatMap(entry => [profilePath(inspected.libraryPath, entry.id), `${profilePath(inspected.libraryPath, entry.id)}.bak`])
|
|
530
|
+
.filter(existsSync);
|
|
531
|
+
if (residualPaths.length > 0 || ownedResiduePaths.length > 0) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: inspected.libraryPath,
|
|
534
|
+
residualPaths: [...new Set([...residualPaths, ...ownedResiduePaths])],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Do not leave a metadata row pointing at a deleted profile. For a foreign
|
|
538
|
+
// selection this only removes proven opencodex residues; appliedId is kept.
|
|
539
|
+
atomicWriteFile(
|
|
540
|
+
metadataPath,
|
|
541
|
+
JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
|
|
542
|
+
);
|
|
543
|
+
return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
|
|
544
|
+
} catch {
|
|
545
|
+
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
330
549
|
/** Write and apply the opencodex config in Claude Desktop 3P's config library. */
|
|
331
550
|
export function writeDesktop3pConfig(
|
|
332
551
|
port: number,
|
|
@@ -343,7 +562,8 @@ export function writeDesktop3pConfig(
|
|
|
343
562
|
try {
|
|
344
563
|
mkdirSync(libraryPath, { recursive: true, mode: 0o700 });
|
|
345
564
|
const metadata = parseMetadata(metadataPath);
|
|
346
|
-
const
|
|
565
|
+
const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry));
|
|
566
|
+
const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string");
|
|
347
567
|
const id = existing?.id ?? randomUUID();
|
|
348
568
|
configPath = join(libraryPath, `${id}.json`);
|
|
349
569
|
const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" };
|
package/src/claude/outbound.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
TranslatorBudgetExceededError,
|
|
17
17
|
type TranslatorBudget,
|
|
18
18
|
} from "../lib/translator-budget";
|
|
19
|
+
import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder";
|
|
19
20
|
|
|
20
21
|
type Rec = Record<string, unknown>;
|
|
21
22
|
|
|
@@ -588,10 +589,16 @@ export function responsesSseToAnthropicSse(
|
|
|
588
589
|
while (lineStart <= rawFrame.length) {
|
|
589
590
|
const newline = rawFrame.indexOf("\n", lineStart);
|
|
590
591
|
const lineEnd = newline === -1 ? rawFrame.length : newline;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
592
|
+
// The space after the colon is optional in text/event-stream (#1170);
|
|
593
|
+
// compute the value offset the same way sseFieldValue does, without
|
|
594
|
+
// slicing the line first — the byte accounting below is keyed to
|
|
595
|
+
// offsets into rawFrame.
|
|
596
|
+
const eventOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "event");
|
|
597
|
+
const dataOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "data");
|
|
598
|
+
if (eventOffset !== -1) {
|
|
599
|
+
eventName = rawFrame.slice(eventOffset, lineEnd).trim();
|
|
600
|
+
} else if (dataOffset !== -1) {
|
|
601
|
+
const fragmentStart = dataOffset;
|
|
595
602
|
const fragmentBytes = utf8SliceBytes(rawFrame, fragmentStart, lineEnd);
|
|
596
603
|
const fragmentReservation = translatorBudget.reserveTransient(fragmentBytes, { kind: "live_transient" });
|
|
597
604
|
let fragmentCommitted = false;
|
|
@@ -861,8 +868,10 @@ export async function collectAnthropicMessage(
|
|
|
861
868
|
let eventName = "";
|
|
862
869
|
let dataLine = "";
|
|
863
870
|
for (const line of rawFrame.split("\n")) {
|
|
864
|
-
|
|
865
|
-
|
|
871
|
+
const eventValue = sseFieldValue(line, "event");
|
|
872
|
+
if (eventValue !== null) { eventName = eventValue.trim(); continue; }
|
|
873
|
+
const dataValue = sseFieldValue(line, "data");
|
|
874
|
+
if (dataValue !== null) dataLine += dataValue;
|
|
866
875
|
}
|
|
867
876
|
if (!eventName || !dataLine) continue;
|
|
868
877
|
let data: unknown;
|
package/src/cli/account-api.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface AccountRow {
|
|
|
21
21
|
masked?: string;
|
|
22
22
|
active: boolean;
|
|
23
23
|
needsReauth?: boolean;
|
|
24
|
+
/** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
|
|
25
|
+
priority?: number;
|
|
24
26
|
quota?: CodexQuotaDto | null;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -172,6 +174,7 @@ interface CodexAccountDto {
|
|
|
172
174
|
plan?: string;
|
|
173
175
|
isMain?: boolean;
|
|
174
176
|
needsReauth?: boolean;
|
|
177
|
+
priority?: number;
|
|
175
178
|
quota?: CodexQuotaDto | null;
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -219,6 +222,7 @@ export async function fetchCodexRows(
|
|
|
219
222
|
plan: a.plan,
|
|
220
223
|
active: a.id === activeId,
|
|
221
224
|
needsReauth: a.needsReauth,
|
|
225
|
+
priority: typeof a.priority === "number" ? a.priority : 0,
|
|
222
226
|
...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
|
|
223
227
|
}));
|
|
224
228
|
return { rows, activeId, autoSwitchThreshold, status: 200 };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { loadConfig } from "../config";
|
|
2
|
+
import {
|
|
3
|
+
MAX_ACCOUNT_PRIORITY,
|
|
4
|
+
MIN_ACCOUNT_PRIORITY,
|
|
5
|
+
normalizeAccountPriority,
|
|
6
|
+
parseAccountPriority,
|
|
7
|
+
} from "../codex/pool-rotation";
|
|
2
8
|
import {
|
|
3
9
|
apiError,
|
|
4
10
|
apiJson,
|
|
@@ -18,6 +24,7 @@ const EXTENDED_USAGE = `Usage:
|
|
|
18
24
|
ocx account refresh <provider> [--json]
|
|
19
25
|
ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
|
|
20
26
|
ocx account alias <provider> <id|main> <display-name|-> [--json]
|
|
27
|
+
ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
|
|
21
28
|
ocx account remove <provider> <id|main> --yes [--json]
|
|
22
29
|
ocx account clear-cooldown <provider> <id|main> [--json]
|
|
23
30
|
ocx account add-key <provider> [--label <label>] [--json]`;
|
|
@@ -316,6 +323,111 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
|
|
|
316
323
|
return 0;
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Named selection orders. The words convey sequence rather than rank because the
|
|
328
|
+
* pool moves down the list only when everything above it is drained — "high
|
|
329
|
+
* priority" would suggest the account gets more traffic, which is not what
|
|
330
|
+
* ordering does.
|
|
331
|
+
*/
|
|
332
|
+
const PRIORITY_PRESETS: Record<string, number> = {
|
|
333
|
+
first: 2,
|
|
334
|
+
earlier: 1,
|
|
335
|
+
normal: 0,
|
|
336
|
+
later: -1,
|
|
337
|
+
last: -2,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
function priorityPresetName(priority: number): string | null {
|
|
341
|
+
return Object.entries(PRIORITY_PRESETS).find(([, value]) => value === priority)?.[0] ?? null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function formatPriority(priority: number): string {
|
|
345
|
+
const preset = priorityPresetName(priority);
|
|
346
|
+
const signed = priority > 0 ? `+${priority}` : String(priority);
|
|
347
|
+
return preset ? `${signed} (${preset})` : signed;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** `null` = reset to the default; `undefined` = unparseable. */
|
|
351
|
+
function parsePriorityArgument(raw: string): number | null | undefined {
|
|
352
|
+
const word = raw.trim().toLowerCase();
|
|
353
|
+
if (word === "reset") return null;
|
|
354
|
+
// Own keys only: `in` also matches "constructor", "__proto__", and friends.
|
|
355
|
+
if (Object.hasOwn(PRIORITY_PRESETS, word)) return PRIORITY_PRESETS[word];
|
|
356
|
+
// The regex only rules out shapes Number() would coerce ("1e2", " 1 ", ""); the range
|
|
357
|
+
// itself comes from the core parser so the CLI cannot drift from what the API accepts.
|
|
358
|
+
if (!/^[+-]?\d+$/.test(word)) return undefined;
|
|
359
|
+
return parseAccountPriority(Number(word)) ?? undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function cmdPriority(args: string[], deps: AccountDeps): Promise<number> {
|
|
363
|
+
const wantsJson = flag(args, "--json");
|
|
364
|
+
const name = args.shift();
|
|
365
|
+
const requestedId = args.shift();
|
|
366
|
+
const requestedPriority = args.shift();
|
|
367
|
+
if (!name || !requestedId || args.length) return usage();
|
|
368
|
+
const classified = configAndType(deps, name);
|
|
369
|
+
if ("error" in classified) return usage(`Error: ${classified.error}`);
|
|
370
|
+
if (classified.type !== "codex") {
|
|
371
|
+
return usage("Error: selection order only applies to the openai Codex account pool");
|
|
372
|
+
}
|
|
373
|
+
const id = requestedId === "main" ? MAIN_ID : requestedId;
|
|
374
|
+
|
|
375
|
+
// Validate before touching the network so a typo never reaches the proxy.
|
|
376
|
+
let priority: number | null | undefined;
|
|
377
|
+
if (requestedPriority !== undefined) {
|
|
378
|
+
priority = parsePriorityArgument(requestedPriority);
|
|
379
|
+
if (priority === undefined) {
|
|
380
|
+
return usage(`Error: selection order must be an integer ${MIN_ACCOUNT_PRIORITY}..${MAX_ACCOUNT_PRIORITY}, one of ${Object.keys(PRIORITY_PRESETS).join("/")}, or reset`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const baseUrl = await resolveBaseUrl(deps);
|
|
385
|
+
if (!baseUrl) return proxyUnreachable();
|
|
386
|
+
|
|
387
|
+
// No value means "show" — a read must not rewrite what it is reporting.
|
|
388
|
+
if (priority === undefined) {
|
|
389
|
+
const result = await fetchCodexRows(deps, baseUrl);
|
|
390
|
+
const failed = familyFailure(result, `failed to read ${name} accounts`);
|
|
391
|
+
if (failed !== null) return failed;
|
|
392
|
+
const row = result.rows.find(candidate => candidate.id === id);
|
|
393
|
+
if (!row) return usage(`Error: no ${name} account ${requestedId}`);
|
|
394
|
+
const current = normalizeAccountPriority(row.priority);
|
|
395
|
+
if (wantsJson) {
|
|
396
|
+
console.log(JSON.stringify(
|
|
397
|
+
{ ok: true, provider: name, id, priority: current, preset: priorityPresetName(current) },
|
|
398
|
+
null,
|
|
399
|
+
2,
|
|
400
|
+
));
|
|
401
|
+
} else {
|
|
402
|
+
console.log(`${name}: ${requestedId} selection order is ${formatPriority(current)}`);
|
|
403
|
+
}
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/priority", { id, priority });
|
|
408
|
+
if (response.status === 0) return proxyUnreachable();
|
|
409
|
+
if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`);
|
|
410
|
+
const applied = typeof response.json.priority === "number" ? response.json.priority : (priority ?? 0);
|
|
411
|
+
if (wantsJson) {
|
|
412
|
+
console.log(JSON.stringify(
|
|
413
|
+
{ ok: true, provider: name, id, priority: applied, preset: priorityPresetName(applied) },
|
|
414
|
+
null,
|
|
415
|
+
2,
|
|
416
|
+
));
|
|
417
|
+
} else {
|
|
418
|
+
console.log(`${name}: ${requestedId} selection order is now ${formatPriority(applied)}`);
|
|
419
|
+
}
|
|
420
|
+
// Not cmdUse's note: re-ordering takes effect on the next unbound request rather than
|
|
421
|
+
// only on new sessions, because preemption moves those requests up immediately.
|
|
422
|
+
console.error("Takes effect from the next unbound request; running threads keep their current account until drained.");
|
|
423
|
+
// The release is not optional and not conditional on the value changing, so it has to be
|
|
424
|
+
// stated: this route is the only way to clear a pin without immediately setting another,
|
|
425
|
+
// which means a write storing the order an account already had still releases it. Without
|
|
426
|
+
// this line that is a silent side effect of a command that looks purely declarative.
|
|
427
|
+
console.error('Also releases any manual "use this account now" pin, on any account.');
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
319
431
|
export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
|
|
320
432
|
const wantsJson = flag(args, "--json");
|
|
321
433
|
const name = args.shift();
|