@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
|
@@ -107,15 +107,9 @@ export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY";
|
|
|
107
107
|
/** Env reference shared by apiKey and the dedicated proxy admission header. */
|
|
108
108
|
export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`;
|
|
109
109
|
|
|
110
|
-
/** Env var Pi interpolates. Pi takes bare `$NAME`, not opencode's `{env:NAME}`. */
|
|
111
|
-
export const PI_API_KEY_ENV = "OPENCODEX_API_KEY";
|
|
112
|
-
|
|
113
|
-
/** Pi's reference form for the admission key. Never the value. */
|
|
114
|
-
export const PI_API_KEY_ENV_REF = `$${PI_API_KEY_ENV}`;
|
|
115
|
-
|
|
116
110
|
/**
|
|
117
111
|
* Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays
|
|
118
|
-
* in the environment exactly as it does for OpenCode
|
|
112
|
+
* in the environment exactly as it does for OpenCode.
|
|
119
113
|
*/
|
|
120
114
|
export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY";
|
|
121
115
|
export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`;
|
|
@@ -125,12 +119,12 @@ export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY";
|
|
|
125
119
|
export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
|
|
126
120
|
|
|
127
121
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
122
|
+
* Placeholder credential for loopback-only clients (Kimi, Pi). A loopback
|
|
123
|
+
* bind needs no real admission key, so we emit the same placeholder the Grok
|
|
124
|
+
* managed block uses rather than a user secret. Pi resolves `apiKey` before
|
|
125
|
+
* building its model list and hides the provider when an env reference is unset.
|
|
132
126
|
*/
|
|
133
|
-
export const
|
|
127
|
+
export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback";
|
|
134
128
|
|
|
135
129
|
/**
|
|
136
130
|
* Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
|
|
@@ -728,7 +722,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
|
|
|
728
722
|
[OPENCODE_PROVIDER_ID]: {
|
|
729
723
|
baseUrl: ctx.baseUrl,
|
|
730
724
|
api: PI_API_DIALECT,
|
|
731
|
-
apiKey:
|
|
725
|
+
apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
|
|
732
726
|
models,
|
|
733
727
|
},
|
|
734
728
|
},
|
|
@@ -808,7 +802,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
|
|
|
808
802
|
[OPENCODE_PROVIDER_ID]: {
|
|
809
803
|
type: "openai",
|
|
810
804
|
base_url: ctx.baseUrl,
|
|
811
|
-
api_key:
|
|
805
|
+
api_key: LOOPBACK_API_KEY_PLACEHOLDER,
|
|
812
806
|
},
|
|
813
807
|
},
|
|
814
808
|
models,
|
|
@@ -949,15 +943,14 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
|
949
943
|
id: "pi",
|
|
950
944
|
filename: "pi-models.json",
|
|
951
945
|
destination: () => join(homedir(), ".pi", "agent", "models.json"),
|
|
952
|
-
apiKeyEnv:
|
|
953
|
-
exportHint:
|
|
946
|
+
apiKeyEnv: "",
|
|
947
|
+
exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.",
|
|
954
948
|
build: buildPiClientConfig,
|
|
955
949
|
format: "json",
|
|
956
950
|
summarize: summarizePi,
|
|
957
951
|
buildContribution: buildPiContribution,
|
|
958
|
-
// No header field in Pi's provider block
|
|
959
|
-
//
|
|
960
|
-
// admission header a remote bind requires.
|
|
952
|
+
// No header field in Pi's provider block, so there is nowhere to put the
|
|
953
|
+
// dedicated admission header a remote bind requires.
|
|
961
954
|
loopbackOnly: true,
|
|
962
955
|
},
|
|
963
956
|
hermes: {
|
|
@@ -7,6 +7,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f
|
|
|
7
7
|
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
|
|
8
8
|
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache";
|
|
9
9
|
import { forgetCodexAccountPause } from "./account-pause";
|
|
10
|
+
import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
|
|
10
11
|
import type { OcxConfig } from "../types";
|
|
11
12
|
|
|
12
13
|
let observedMainChatgptAccountId: string | undefined;
|
|
@@ -74,6 +75,8 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
|
|
|
74
75
|
runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
|
|
75
76
|
.filter(account => account.isMain || account.id !== accountId);
|
|
76
77
|
forgetCodexAccountPause(runtimeConfig, accountId);
|
|
78
|
+
forgetCodexAccountPriority(runtimeConfig, accountId);
|
|
79
|
+
clearCodexAccountPin(runtimeConfig, accountId);
|
|
77
80
|
if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
|
|
78
81
|
purgeCodexAccountRuntimeState(accountId);
|
|
79
82
|
invalidateCodexWebSocketsForAccount(accountId);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { CodexAccount, OcxConfig } from "../types";
|
|
2
2
|
import { COMBO_NAMESPACE } from "../combos/types";
|
|
3
3
|
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
4
|
+
import {
|
|
5
|
+
POLICY_NAMESPACE,
|
|
6
|
+
routingProfileAliasNamespacePrefixes,
|
|
7
|
+
} from "../routing/profile-namespace";
|
|
4
8
|
import {
|
|
5
9
|
CODEX_ACCOUNT_LOG_LABEL_RE,
|
|
6
10
|
createCodexAccountLogLabel,
|
|
@@ -21,6 +25,7 @@ const RESERVED_NAMESPACE_KEYS = new Set([
|
|
|
21
25
|
"constructor",
|
|
22
26
|
COMBO_NAMESPACE,
|
|
23
27
|
OPENAI_CODEX_PROVIDER_ID,
|
|
28
|
+
POLICY_NAMESPACE,
|
|
24
29
|
].map(codexProviderNamespaceKey));
|
|
25
30
|
const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16;
|
|
26
31
|
|
|
@@ -68,17 +73,21 @@ function claimNamespace(requested: string, used: Set<string>): string {
|
|
|
68
73
|
return namespace;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
/** Collect every public namespace that a generated account selector must not claim. */
|
|
77
|
+
function occupiedNamespaces(
|
|
78
|
+
config: Pick<OcxConfig, "combos" | "providers" | "routingProfiles">,
|
|
79
|
+
): Set<string> {
|
|
72
80
|
return new Set([
|
|
73
81
|
...Object.keys(config.providers).map(codexProviderNamespaceKey),
|
|
74
82
|
...comboAliasNamespaces(config),
|
|
83
|
+
...routingProfileAliasNamespacePrefixes(config),
|
|
75
84
|
...RESERVED_NAMESPACE_KEYS,
|
|
76
85
|
]);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/** Build an initial account-selector map without deriving public selectors from aliases or ids. */
|
|
80
89
|
export function defaultCodexAccountNamespaces(
|
|
81
|
-
config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers">,
|
|
90
|
+
config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers" | "routingProfiles">,
|
|
82
91
|
): Record<string, string> {
|
|
83
92
|
const namespaces: Record<string, string> = {};
|
|
84
93
|
const used = occupiedNamespaces(config);
|
|
@@ -99,13 +108,39 @@ export function defaultCodexAccountNamespaces(
|
|
|
99
108
|
return namespaces;
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Initialize generated selectors only for an explicit opt-in with no existing bindings.
|
|
113
|
+
* A true result means the map was replaced and the caller must persist the updated config.
|
|
114
|
+
*/
|
|
115
|
+
export function initializeDefaultCodexAccountNamespaces(
|
|
116
|
+
config: Pick<
|
|
117
|
+
OcxConfig,
|
|
118
|
+
| "codexAccountPickerEnabled"
|
|
119
|
+
| "codexAccountNamespaces"
|
|
120
|
+
| "codexAccounts"
|
|
121
|
+
| "combos"
|
|
122
|
+
| "providers"
|
|
123
|
+
| "routingProfiles"
|
|
124
|
+
>,
|
|
125
|
+
): boolean {
|
|
126
|
+
if (config.codexAccountPickerEnabled !== true
|
|
127
|
+
|| Object.keys(config.codexAccountNamespaces ?? {}).length > 0) return false;
|
|
128
|
+
|
|
129
|
+
const namespaces = defaultCodexAccountNamespaces(config);
|
|
130
|
+
config.codexAccountNamespaces = namespaces;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
102
134
|
/**
|
|
103
135
|
* Add one account to a generated map without renaming or replacing explicit existing entries.
|
|
104
136
|
* The account-creation layer must reject a new id that already equals an existing selector key.
|
|
105
137
|
* A true result means the map was mutated in place; callers must persist the updated config.
|
|
106
138
|
*/
|
|
107
139
|
export function appendDefaultCodexAccountNamespace(
|
|
108
|
-
config: Pick<
|
|
140
|
+
config: Pick<
|
|
141
|
+
OcxConfig,
|
|
142
|
+
"codexAccountNamespaces" | "codexAccounts" | "combos" | "providers" | "routingProfiles"
|
|
143
|
+
>,
|
|
109
144
|
account: Pick<CodexAccount, "id" | "isMain" | "logLabel">,
|
|
110
145
|
): boolean {
|
|
111
146
|
const namespaces = config.codexAccountNamespaces;
|
|
@@ -147,3 +182,14 @@ export function codexAccountNamespaceEntries(
|
|
|
147
182
|
return Object.entries(config.codexAccountNamespaces ?? {})
|
|
148
183
|
.map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]);
|
|
149
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether generated account-qualified rows are enabled for catalog discovery.
|
|
188
|
+
* A non-empty hand-written map predating the explicit override remains enabled.
|
|
189
|
+
*/
|
|
190
|
+
export function codexAccountPickerEnabled(
|
|
191
|
+
config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccountPickerEnabled">,
|
|
192
|
+
): boolean {
|
|
193
|
+
return (config.codexAccountPickerEnabled === undefined || config.codexAccountPickerEnabled === true)
|
|
194
|
+
&& Object.keys(config.codexAccountNamespaces ?? {}).length > 0;
|
|
195
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
2
|
+
import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
|
|
3
|
+
import type { OcxConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which ids may carry a selection order: any pool account, plus the synthetic
|
|
7
|
+
* `__main__` Desktop login. Owned here rather than by the config schema because
|
|
8
|
+
* every writer needs it — the schema rejects a bad key on load, but the management
|
|
9
|
+
* API has to reject one before `setCodexAccountPriority` persists it as an own
|
|
10
|
+
* property that the schema would then reject, degrading the whole map.
|
|
11
|
+
*/
|
|
12
|
+
export function isCodexAccountPriorityKey(key: unknown): key is string {
|
|
13
|
+
return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Persisted selection order for one account: higher numbers are used earlier.
|
|
18
|
+
* Stored as a config sidecar rather than a `codexAccounts` row field because the
|
|
19
|
+
* Codex Desktop login (`__main__`) has no row and must be orderable too.
|
|
20
|
+
*/
|
|
21
|
+
export function getCodexAccountPriority(config: OcxConfig, accountId: string): number {
|
|
22
|
+
return codexAccountPriorityLookup(config)(accountId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write one account's selection order. The default is stored as absence, so a
|
|
27
|
+
* pool that was never ordered keeps no map at all and routing takes its fast path.
|
|
28
|
+
* Entries are rebuilt through `Object.fromEntries` so a reserved key such as
|
|
29
|
+
* `__proto__` becomes an own data property instead of invoking a prototype setter.
|
|
30
|
+
*/
|
|
31
|
+
export function setCodexAccountPriority(config: OcxConfig, accountId: string, priority: number): void {
|
|
32
|
+
const entries = new Map(Object.entries(config.codexAccountPriorities ?? {}));
|
|
33
|
+
if (priority === DEFAULT_ACCOUNT_PRIORITY) entries.delete(accountId);
|
|
34
|
+
else entries.set(accountId, priority);
|
|
35
|
+
|
|
36
|
+
if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries);
|
|
37
|
+
else delete config.codexAccountPriorities;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void {
|
|
41
|
+
setCodexAccountPriority(config, accountId, DEFAULT_ACCOUNT_PRIORITY);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stable lookup for one selection pass. Routing calls this once per pick and
|
|
46
|
+
* hands the closure to `selectPriorityTier`, so a pool without stored order pays
|
|
47
|
+
* a single map read rather than one per candidate.
|
|
48
|
+
*/
|
|
49
|
+
export function codexAccountPriorityLookup(config: OcxConfig): (accountId: string) => number {
|
|
50
|
+
const priorities = config.codexAccountPriorities;
|
|
51
|
+
if (!priorities) return () => DEFAULT_ACCOUNT_PRIORITY;
|
|
52
|
+
return accountId => (
|
|
53
|
+
Object.hasOwn(priorities, accountId)
|
|
54
|
+
? normalizeAccountPriority(priorities[accountId])
|
|
55
|
+
: DEFAULT_ACCOUNT_PRIORITY
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The account an operator most recently selected by hand. While pinned, priority
|
|
61
|
+
* never preempts upward: the pin is a tier *ceiling* for round-robin/fill-first --
|
|
62
|
+
* `selectPriorityTier` skips every tier above the pinned account's, so higher-ordered
|
|
63
|
+
* accounts are suppressed rather than the pinned one being guaranteed -- and a veto in
|
|
64
|
+
* the quota path, until the account crosses the auto-switch threshold.
|
|
65
|
+
*/
|
|
66
|
+
export function pinnedCodexAccountId(config: OcxConfig): string | undefined {
|
|
67
|
+
return config.activeCodexAccountPinned;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isCodexAccountPinned(config: OcxConfig, accountId: string): boolean {
|
|
71
|
+
return config.activeCodexAccountPinned === accountId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setCodexAccountPin(config: OcxConfig, accountId: string): void {
|
|
75
|
+
config.activeCodexAccountPinned = accountId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Release the pin. With `accountId` given, only when it is the pinned account. */
|
|
79
|
+
export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void {
|
|
80
|
+
if (accountId === undefined || config.activeCodexAccountPinned === accountId) {
|
|
81
|
+
delete config.activeCodexAccountPinned;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -15,20 +15,33 @@ import {
|
|
|
15
15
|
} from "./account-store";
|
|
16
16
|
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
17
17
|
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
18
|
+
import {
|
|
19
|
+
clearCodexAccountPin,
|
|
20
|
+
getCodexAccountPriority,
|
|
21
|
+
isCodexAccountPriorityKey,
|
|
22
|
+
pinnedCodexAccountId,
|
|
23
|
+
setCodexAccountPin,
|
|
24
|
+
setCodexAccountPriority,
|
|
25
|
+
} from "./account-priority";
|
|
18
26
|
import {
|
|
19
27
|
claimDueCodexQuotaRecoveryProbes,
|
|
20
28
|
clearCodexAccountCooldown,
|
|
21
29
|
clearThreadAccountMapForAccount,
|
|
22
30
|
getEffectiveActiveCodexAccountId,
|
|
31
|
+
isEffectiveCodexAccountPinned,
|
|
23
32
|
reconcileCodexActiveAfterExclusion,
|
|
24
33
|
resetCodexRoutingForManualSelection,
|
|
25
34
|
settleCodexQuotaRecoveryProbe,
|
|
26
35
|
} from "./routing";
|
|
27
36
|
import {
|
|
37
|
+
DEFAULT_ACCOUNT_PRIORITY,
|
|
38
|
+
MAX_ACCOUNT_PRIORITY,
|
|
39
|
+
MIN_ACCOUNT_PRIORITY,
|
|
28
40
|
normalizeAccountPoolStickyLimit,
|
|
29
41
|
normalizeAccountPoolStrategy,
|
|
30
42
|
parseAccountPoolStickyLimit,
|
|
31
43
|
parseAccountPoolStrategy,
|
|
44
|
+
parseAccountPriority,
|
|
32
45
|
} from "./pool-rotation";
|
|
33
46
|
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
34
47
|
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
@@ -191,6 +204,7 @@ function poolAccountDto(
|
|
|
191
204
|
quotaResult: PoolQuotaResult,
|
|
192
205
|
hasCredential: boolean,
|
|
193
206
|
paused: boolean,
|
|
207
|
+
priority: number,
|
|
194
208
|
): CodexAuthAccountDto {
|
|
195
209
|
const quota = quotaForPlan(quotaResult.quota, account.plan);
|
|
196
210
|
const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
|
|
@@ -203,6 +217,7 @@ function poolAccountDto(
|
|
|
203
217
|
...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
|
|
204
218
|
isMain: false,
|
|
205
219
|
paused,
|
|
220
|
+
priority,
|
|
206
221
|
quota: quota ? { ...quota } : null,
|
|
207
222
|
needsReauth,
|
|
208
223
|
hasCredential,
|
|
@@ -644,6 +659,8 @@ export interface CodexAuthAccountDto {
|
|
|
644
659
|
logLabel?: string;
|
|
645
660
|
isMain: boolean;
|
|
646
661
|
paused: boolean;
|
|
662
|
+
/** Selection order; higher is used earlier. Always present, 0 when unset. */
|
|
663
|
+
priority: number;
|
|
647
664
|
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
648
665
|
needsReauth?: boolean;
|
|
649
666
|
hasCredential: boolean;
|
|
@@ -1017,6 +1034,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1017
1034
|
{ quota: null, needsReauth: true },
|
|
1018
1035
|
false,
|
|
1019
1036
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1037
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1020
1038
|
)];
|
|
1021
1039
|
}
|
|
1022
1040
|
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
@@ -1035,6 +1053,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1035
1053
|
effectiveQuotaResult,
|
|
1036
1054
|
true,
|
|
1037
1055
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1056
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1038
1057
|
)];
|
|
1039
1058
|
});
|
|
1040
1059
|
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
|
|
@@ -1055,6 +1074,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1055
1074
|
plan: mainInfo.plan,
|
|
1056
1075
|
isMain: true,
|
|
1057
1076
|
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
1077
|
+
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
1058
1078
|
hasCredential: hasMainCredential,
|
|
1059
1079
|
needsReauth: mainNeedsReauth,
|
|
1060
1080
|
quota: mainInfo.quota ? {
|
|
@@ -1297,6 +1317,53 @@ export async function handleCodexAuthAPI(
|
|
|
1297
1317
|
});
|
|
1298
1318
|
}
|
|
1299
1319
|
|
|
1320
|
+
// Deliberately a route of its own rather than a field on the alias PATCH: aliases
|
|
1321
|
+
// are display-only and reject __main__, while selection order is routing metadata
|
|
1322
|
+
// that the Desktop account must be able to carry. Re-ordering never kicks a live
|
|
1323
|
+
// thread, so there is no affinity clearing and no appliesImmediately here.
|
|
1324
|
+
if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
|
|
1325
|
+
let parsedBody: unknown;
|
|
1326
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1327
|
+
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
1328
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
1329
|
+
}
|
|
1330
|
+
const body = parsedBody as { id?: unknown; priority?: unknown };
|
|
1331
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1332
|
+
if (!isCodexAccountPriorityKey(id)) {
|
|
1333
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
let priority = DEFAULT_ACCOUNT_PRIORITY;
|
|
1337
|
+
if (body.priority !== null) {
|
|
1338
|
+
const parsed = parseAccountPriority(body.priority);
|
|
1339
|
+
if (parsed === null) {
|
|
1340
|
+
return jsonResponse({
|
|
1341
|
+
error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
|
|
1342
|
+
}, 400);
|
|
1343
|
+
}
|
|
1344
|
+
priority = parsed;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1348
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1349
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1350
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1351
|
+
|
|
1352
|
+
setCodexAccountPriority(runtimeConfig, id, priority);
|
|
1353
|
+
// Both a pin and an order are the operator saying which account to use, so the newer
|
|
1354
|
+
// statement wins. Without this a pin made before any order existed — an ordinary
|
|
1355
|
+
// account switch — would outrank the order forever: it blocks preemption and caps
|
|
1356
|
+
// every eligibility list at its own tier until that account drains or is paused.
|
|
1357
|
+
clearCodexAccountPin(runtimeConfig);
|
|
1358
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1359
|
+
return jsonResponse({
|
|
1360
|
+
ok: true,
|
|
1361
|
+
id,
|
|
1362
|
+
priority,
|
|
1363
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1300
1367
|
if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
|
|
1301
1368
|
const runtimeConfig = getRuntimeConfig(config);
|
|
1302
1369
|
const result = await pauseExhaustedCodexAccounts(
|
|
@@ -1359,6 +1426,15 @@ export async function handleCodexAuthAPI(
|
|
|
1359
1426
|
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
1360
1427
|
}
|
|
1361
1428
|
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
1429
|
+
// "Use this account now" outranks selection order until the account is spent:
|
|
1430
|
+
// persisted here rather than in resetCodexRoutingForManualSelection, which is
|
|
1431
|
+
// runtime state only. A null id clears the selection instead of making one, so it
|
|
1432
|
+
// must release the pin rather than record one: pinning the `targetAccountId`
|
|
1433
|
+
// fallback would leave a pin that no effective active account matches, which
|
|
1434
|
+
// `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
|
|
1435
|
+
// honours it as a ceiling — invisibly capping the pool at the main account's tier.
|
|
1436
|
+
if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
|
|
1437
|
+
else setCodexAccountPin(runtimeConfig, targetAccountId);
|
|
1362
1438
|
resetCodexRoutingForManualSelection(targetAccountId);
|
|
1363
1439
|
saveRuntimeConfig(config, runtimeConfig);
|
|
1364
1440
|
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
|
|
@@ -1368,6 +1444,13 @@ export async function handleCodexAuthAPI(
|
|
|
1368
1444
|
const runtimeConfig = getRuntimeConfig(config);
|
|
1369
1445
|
return jsonResponse({
|
|
1370
1446
|
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1447
|
+
pinned: isEffectiveCodexAccountPinned(runtimeConfig),
|
|
1448
|
+
// Which account carries the pin, not just whether the active one does. Under
|
|
1449
|
+
// round-robin or fill-first the pin caps the tier ceiling at its own tier while the
|
|
1450
|
+
// strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
|
|
1451
|
+
// sibling's turn even though the pin is still suppressing every higher tier. The id
|
|
1452
|
+
// lets a surface mark the account the operator actually chose.
|
|
1453
|
+
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
|
|
1371
1454
|
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
1372
1455
|
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
1373
1456
|
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
@@ -112,15 +112,18 @@ export class CodexPoolAuthenticationError extends Error {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
|
|
116
|
+
"OpenCodex local native-main profile maintenance is active; retry this request";
|
|
117
|
+
|
|
115
118
|
export class CodexMainProfileDrainingError extends Error {
|
|
116
119
|
constructor() {
|
|
117
|
-
super(
|
|
120
|
+
super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
|
|
118
121
|
this.name = "CodexMainProfileDrainingError";
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
export function codexMainProfileDrainingResponse(): Response {
|
|
123
|
-
const response = formatErrorResponse(503, "server_busy",
|
|
126
|
+
const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
|
|
124
127
|
const headers = new Headers(response.headers);
|
|
125
128
|
headers.set("Retry-After", "1");
|
|
126
129
|
return new Response(response.body, { status: response.status, headers });
|
|
@@ -736,6 +736,17 @@ function modelInputModalities(
|
|
|
736
736
|
value === "text" || value === "image" || value === "audio"
|
|
737
737
|
));
|
|
738
738
|
if (explicit && explicit.length > 0) return explicit;
|
|
739
|
+
const architecture = plainRecord(item.architecture);
|
|
740
|
+
const architectureModality = typeof architecture?.modality === "string"
|
|
741
|
+
? normalizedMetadataString(architecture.modality, 64)
|
|
742
|
+
: undefined;
|
|
743
|
+
if (architectureModality?.includes("->")) {
|
|
744
|
+
const [rawInput = ""] = architectureModality.split("->");
|
|
745
|
+
const inferred = rawInput
|
|
746
|
+
.split("+")
|
|
747
|
+
.filter(value => value === "text" || value === "image" || value === "audio");
|
|
748
|
+
if (inferred.length > 0) return [...new Set(inferred)];
|
|
749
|
+
}
|
|
739
750
|
if (capabilityRecord?.vision === false) return ["text"];
|
|
740
751
|
if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) {
|
|
741
752
|
return ["text", "image"];
|
|
@@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
-
import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config";
|
|
5
|
+
import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config";
|
|
6
|
+
import { shouldSyncCodexOnStart } from "../desired-state";
|
|
6
7
|
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths";
|
|
7
8
|
import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
|
|
8
9
|
import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
|
|
@@ -652,6 +653,8 @@ interface RetainedCatalogSyncResult {
|
|
|
652
653
|
path: string;
|
|
653
654
|
catalogWritten: boolean;
|
|
654
655
|
comboOmissions: ComboCatalogOmission[];
|
|
656
|
+
/** `desired_disabled` observed under K after the provider await; nothing was written. */
|
|
657
|
+
skippedReason?: "desired_disabled";
|
|
655
658
|
}
|
|
656
659
|
|
|
657
660
|
interface RetainedCatalogSyncWrite {
|
|
@@ -965,6 +968,20 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
|
|
|
965
968
|
};
|
|
966
969
|
const goModels = await gatherRoutedModels(config, { comboOmissions });
|
|
967
970
|
const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
|
|
971
|
+
// Desired state can flip OFF during the provider await above. The catalog
|
|
972
|
+
// evidence revalidation below cannot see that — intent lives in our config,
|
|
973
|
+
// not in the catalog files — so the policy is re-read here, under K, right
|
|
974
|
+
// before the only write. A lost race becomes the discriminated skip instead
|
|
975
|
+
// of a routed catalog/cache surviving a completed disable.
|
|
976
|
+
if (!shouldSyncCodexOnStart(loadConfig())) {
|
|
977
|
+
return {
|
|
978
|
+
added: 0,
|
|
979
|
+
path: prepared.catalogPath,
|
|
980
|
+
catalogWritten: false,
|
|
981
|
+
comboOmissions,
|
|
982
|
+
skippedReason: "desired_disabled" as const,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
968
985
|
const current = revalidateRetainedCatalogSync(config, prepared);
|
|
969
986
|
if (current === null) return null;
|
|
970
987
|
return writeRetainedCatalogSync({
|
|
@@ -1049,6 +1066,11 @@ export function invalidateCodexModelsCacheWithPermit(
|
|
|
1049
1066
|
owningCodexHome: string,
|
|
1050
1067
|
): boolean {
|
|
1051
1068
|
try {
|
|
1069
|
+
// This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
|
|
1070
|
+
// K before this rewrite runs, so the commit-path desired-state check cannot
|
|
1071
|
+
// cover it. A disable landing in that gap must not be overwritten by a
|
|
1072
|
+
// routed cache write — re-read intent under this permit, same as the commit.
|
|
1073
|
+
if (!shouldSyncCodexOnStart(loadConfig())) return false;
|
|
1052
1074
|
const catalogPath = readCodexCatalogPath();
|
|
1053
1075
|
if (!existsSync(catalogPath)) return false;
|
|
1054
1076
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
@@ -66,7 +66,8 @@ export type CodexWriteLockRefusalReason =
|
|
|
66
66
|
|
|
67
67
|
export type CodexWriteLockResult<T> =
|
|
68
68
|
| { status: "acquired"; value: T; waitedMs: number; lockId: string }
|
|
69
|
-
| { status: "
|
|
69
|
+
| { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number }
|
|
70
|
+
| { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string }
|
|
70
71
|
| {
|
|
71
72
|
status: "refused";
|
|
72
73
|
reason: CodexWriteLockRefusalReason;
|
|
@@ -124,6 +125,14 @@ export interface CodexWriteCommitContext {
|
|
|
124
125
|
readonly coordinator: CodexCoordinatorTransaction;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/** A synchronous under-lock policy re-read proved the requested apply stale. */
|
|
129
|
+
export class CodexWriteLockSkipped extends Error {
|
|
130
|
+
constructor(readonly reason: "desired_disabled" | "desired_enabled") {
|
|
131
|
+
super(reason);
|
|
132
|
+
this.name = "CodexWriteLockSkipped";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
127
136
|
/** Rejects an `async` callback at typecheck; a cast thenable is caught at runtime. */
|
|
128
137
|
type Synchronous<T> = T extends PromiseLike<unknown> ? never : T;
|
|
129
138
|
|
|
@@ -291,7 +300,7 @@ export async function withCodexWriteLock<T>(
|
|
|
291
300
|
const waited = (): number => Math.round(performance.now() - started);
|
|
292
301
|
|
|
293
302
|
for (;;) {
|
|
294
|
-
if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited() };
|
|
303
|
+
if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited(), lockId: target.lockId };
|
|
295
304
|
|
|
296
305
|
let transaction: ReturnType<typeof openCodexCoordinatorTransaction> | undefined;
|
|
297
306
|
try {
|
|
@@ -306,7 +315,7 @@ export async function withCodexWriteLock<T>(
|
|
|
306
315
|
error instanceof Error ? error.message : "The Codex write lock could not be opened.");
|
|
307
316
|
}
|
|
308
317
|
if (performance.now() >= deadline) {
|
|
309
|
-
return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() };
|
|
318
|
+
return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId };
|
|
310
319
|
}
|
|
311
320
|
await sleepJittered(deadline - performance.now(), signal);
|
|
312
321
|
continue;
|
|
@@ -345,13 +354,16 @@ export async function withCodexWriteLock<T>(
|
|
|
345
354
|
return { status: "acquired", value: value as T, waitedMs: waited(), lockId: target.lockId };
|
|
346
355
|
} catch (error) {
|
|
347
356
|
transaction.rollback();
|
|
357
|
+
if (error instanceof CodexWriteLockSkipped) {
|
|
358
|
+
return { status: "skipped", reason: error.reason, waitedMs: waited() };
|
|
359
|
+
}
|
|
348
360
|
if (error instanceof CodexWriteLockStaleAdmission) {
|
|
349
361
|
return refuse("authority_not_proven",
|
|
350
362
|
"The admitted state changed before the commit could be made under the lock.");
|
|
351
363
|
}
|
|
352
364
|
if (isBusyError(error)) {
|
|
353
365
|
if (performance.now() >= deadline) {
|
|
354
|
-
return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() };
|
|
366
|
+
return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId };
|
|
355
367
|
}
|
|
356
368
|
await sleepJittered(deadline - performance.now(), signal);
|
|
357
369
|
continue;
|