@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { maskAccountId } from "../lib/privacy";
|
|
2
|
+
import {
|
|
3
|
+
CODEX_HEALTH_UNAVAILABLE_NOTE,
|
|
4
|
+
MASKED_ACCOUNT_FALLBACK,
|
|
5
|
+
type OAuthAccountHealth,
|
|
6
|
+
type OAuthCliHealthReport,
|
|
7
|
+
type OAuthHealthEntry,
|
|
8
|
+
} from "../oauth/health";
|
|
9
|
+
|
|
10
|
+
function describeHealth(health: OAuthAccountHealth): string {
|
|
11
|
+
switch (health.status) {
|
|
12
|
+
case "healthy":
|
|
13
|
+
return "healthy";
|
|
14
|
+
case "reauth_required":
|
|
15
|
+
return "reauthentication required";
|
|
16
|
+
case "cooldown":
|
|
17
|
+
return health.reason === "rate_limit"
|
|
18
|
+
? `rate limited until ${health.until}`
|
|
19
|
+
: `quota limited until ${health.until}`;
|
|
20
|
+
case "warning":
|
|
21
|
+
switch (health.reason) {
|
|
22
|
+
case "refresh_conflict":
|
|
23
|
+
return "refresh conflict";
|
|
24
|
+
case "metadata_mismatch":
|
|
25
|
+
return "metadata mismatch";
|
|
26
|
+
case "stale_credentials":
|
|
27
|
+
return "stale credentials";
|
|
28
|
+
default:
|
|
29
|
+
return "warning";
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatEntryBlock(entries: OAuthHealthEntry[]): string {
|
|
37
|
+
if (entries.length === 0) return "";
|
|
38
|
+
|
|
39
|
+
const notable = entries.filter((entry) => entry.health.status !== "healthy");
|
|
40
|
+
if (notable.length === 0) return "OAuth health: ok";
|
|
41
|
+
|
|
42
|
+
const lines = ["OAuth health: warning"];
|
|
43
|
+
for (const entry of notable) {
|
|
44
|
+
const masked = maskAccountId(entry.accountId) ?? MASKED_ACCOUNT_FALLBACK;
|
|
45
|
+
lines.push(` ${entry.provider} ${masked} ${describeHealth(entry.health)}`);
|
|
46
|
+
if (entry.action) {
|
|
47
|
+
lines.push(` Action: ${entry.action}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Human-readable OAuth health block for `ocx status` (redacted account ids, no tokens). */
|
|
54
|
+
export function formatOAuthHealthForStatus(
|
|
55
|
+
input: OAuthHealthEntry[] | OAuthCliHealthReport,
|
|
56
|
+
): string {
|
|
57
|
+
const report: OAuthCliHealthReport = Array.isArray(input)
|
|
58
|
+
? { entries: input, codexHealthSource: "management-api" }
|
|
59
|
+
: input;
|
|
60
|
+
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
if (report.codexHealthSource === "unavailable") {
|
|
63
|
+
parts.push(CODEX_HEALTH_UNAVAILABLE_NOTE);
|
|
64
|
+
}
|
|
65
|
+
const oauthBlock = formatEntryBlock(report.entries);
|
|
66
|
+
if (oauthBlock) parts.push(oauthBlock);
|
|
67
|
+
return parts.join("\n");
|
|
68
|
+
}
|
package/src/cli/status.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { getCodexRoutingKind } from "../codex/inject";
|
|
|
9
9
|
import { diagnoseCodexShim } from "../codex/shim";
|
|
10
10
|
import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
|
|
11
11
|
import { redactSecretString, redactUserPath } from "../lib/redact";
|
|
12
|
+
import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
|
|
12
13
|
|
|
13
14
|
type HealthCheck = {
|
|
14
15
|
ok: boolean;
|
|
@@ -65,6 +66,7 @@ export type CliStatusJson = {
|
|
|
65
66
|
runtimeVersion: string | null;
|
|
66
67
|
};
|
|
67
68
|
};
|
|
69
|
+
codexHome: OrcaCodexHomeDiagnostic;
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
export type CliStatusView = {
|
|
@@ -163,6 +165,7 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
163
165
|
})();
|
|
164
166
|
const lastClamp = loadLastEffortClamp();
|
|
165
167
|
const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
|
|
168
|
+
const codexHome = collectOrcaCodexHomeDiagnostic();
|
|
166
169
|
const warningParts: string[] = [];
|
|
167
170
|
if (
|
|
168
171
|
resolvedRuntime.replacedConfigured
|
|
@@ -256,6 +259,7 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
256
259
|
codexShim: { summary: codexShimSummary },
|
|
257
260
|
codexPlugins,
|
|
258
261
|
codexRuntime,
|
|
262
|
+
codexHome,
|
|
259
263
|
},
|
|
260
264
|
};
|
|
261
265
|
}
|
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
import { removeCodexAccountCredential } from "./account-store";
|
|
2
2
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
3
|
+
import { getMainChatgptAccountId } from "./auth-collision";
|
|
4
|
+
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
3
5
|
import { clearAccountQuota } from "./quota";
|
|
4
6
|
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
|
|
5
7
|
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
|
|
8
|
+
import { clearMainAccountInfoCache } from "./main-account-cache";
|
|
6
9
|
import type { OcxConfig } from "../types";
|
|
7
10
|
|
|
11
|
+
let observedMainChatgptAccountId: string | undefined;
|
|
12
|
+
|
|
8
13
|
export function purgeCodexAccountRuntimeState(accountId: string): void {
|
|
9
14
|
clearAccountNeedsReauth(accountId);
|
|
10
15
|
clearAccountQuota(accountId);
|
|
11
16
|
clearThreadAccountMapForAccount(accountId);
|
|
12
17
|
clearCodexUpstreamHealthForAccount(accountId);
|
|
18
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) clearMainAccountInfoCache();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The main Codex login is stored under the stable `__main__` alias, while
|
|
23
|
+
* `~/.codex/auth.json` can be replaced with credentials for another physical
|
|
24
|
+
* ChatGPT account. Drop alias-keyed runtime state when that identity changes so
|
|
25
|
+
* cooldown, quota, reauth, and thread affinity do not leak across accounts.
|
|
26
|
+
*/
|
|
27
|
+
export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
28
|
+
const currentAccountId = getMainChatgptAccountId();
|
|
29
|
+
// A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the
|
|
30
|
+
// prior observation and its safety state until a real account id can be read again.
|
|
31
|
+
if (currentAccountId === null) return false;
|
|
32
|
+
const previousAccountId = observedMainChatgptAccountId;
|
|
33
|
+
observedMainChatgptAccountId = currentAccountId;
|
|
34
|
+
if (previousAccountId === undefined || previousAccountId === currentAccountId) return false;
|
|
35
|
+
|
|
36
|
+
purgeCodexAccountRuntimeState(MAIN_CODEX_ACCOUNT_ID);
|
|
37
|
+
setMainAccountPlan(null);
|
|
38
|
+
invalidateCodexWebSocketsForAccount(MAIN_CODEX_ACCOUNT_ID);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resetMainCodexAccountIdentityTrackingForTests(): void {
|
|
43
|
+
observedMainChatgptAccountId = undefined;
|
|
13
44
|
}
|
|
14
45
|
|
|
15
46
|
export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadConfig,
|
|
1
|
+
import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
2
2
|
import { withCodexAccountLogLabel } from "./account-label";
|
|
3
3
|
import {
|
|
4
4
|
getCodexAccountCredential,
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
CodexCredentialRefreshLockTimeoutError,
|
|
10
10
|
TokenRefreshError,
|
|
11
11
|
} from "./account-store";
|
|
12
|
-
import { deleteCodexAccount } from "./account-lifecycle";
|
|
13
|
-
import {
|
|
12
|
+
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
13
|
+
import { clearCodexAccountCooldown } from "./routing";
|
|
14
|
+
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
14
15
|
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
15
16
|
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
16
17
|
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
@@ -34,6 +35,13 @@ export {
|
|
|
34
35
|
} from "./quota";
|
|
35
36
|
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
36
37
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
38
|
+
import {
|
|
39
|
+
clearMainAccountInfoCache,
|
|
40
|
+
getMainAccountInfoCache,
|
|
41
|
+
setMainAccountInfoCache,
|
|
42
|
+
type MainAccountInfo,
|
|
43
|
+
} from "./main-account-cache";
|
|
44
|
+
export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
37
45
|
import { maskEmail } from "../lib/privacy";
|
|
38
46
|
import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
|
|
39
47
|
export { maskEmail } from "../lib/privacy";
|
|
@@ -41,6 +49,12 @@ import type { CodexAccount, OcxConfig } from "../types";
|
|
|
41
49
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
42
50
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
43
51
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
52
|
+
import {
|
|
53
|
+
oauthAccountHealthFields,
|
|
54
|
+
projectCodexAccountHealth,
|
|
55
|
+
type OAuthAccountHealth,
|
|
56
|
+
type OAuthHealthLabel,
|
|
57
|
+
} from "../oauth/health";
|
|
44
58
|
|
|
45
59
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
46
60
|
return new Response(JSON.stringify(data), {
|
|
@@ -83,6 +97,8 @@ function poolAccountDto(
|
|
|
83
97
|
hasCredential: boolean,
|
|
84
98
|
): CodexAuthAccountDto {
|
|
85
99
|
const quota = quotaForPlan(quotaResult.quota, account.plan);
|
|
100
|
+
const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
|
|
101
|
+
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
86
102
|
return {
|
|
87
103
|
id: account.id,
|
|
88
104
|
email: maskEmail(account.email) ?? account.email,
|
|
@@ -91,8 +107,9 @@ function poolAccountDto(
|
|
|
91
107
|
...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
|
|
92
108
|
isMain: false,
|
|
93
109
|
quota: quota ? { ...quota } : null,
|
|
94
|
-
needsReauth
|
|
110
|
+
needsReauth,
|
|
95
111
|
hasCredential,
|
|
112
|
+
...oauthAccountHealthFields("codex", account.id, health),
|
|
96
113
|
};
|
|
97
114
|
}
|
|
98
115
|
|
|
@@ -187,7 +204,6 @@ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"):
|
|
|
187
204
|
}
|
|
188
205
|
}
|
|
189
206
|
|
|
190
|
-
let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
|
|
191
207
|
const MAIN_CACHE_TTL = 5 * 60_000;
|
|
192
208
|
const POOL_CACHE_TTL = 5 * 60_000;
|
|
193
209
|
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
@@ -201,7 +217,7 @@ function getRuntimeConfig(config: OcxConfig): OcxConfig {
|
|
|
201
217
|
}
|
|
202
218
|
|
|
203
219
|
function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
|
|
204
|
-
|
|
220
|
+
saveConfigPreservingClaudeCode(nextConfig);
|
|
205
221
|
if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
|
|
206
222
|
for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
|
|
207
223
|
delete sourceConfig[key];
|
|
@@ -253,15 +269,49 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
|
|
|
253
269
|
}
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
272
|
+
interface MainAccountInfoFetchResult {
|
|
273
|
+
info: MainAccountInfo;
|
|
274
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
275
|
+
freshResetCredits?: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
|
|
279
|
+
const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
280
|
+
return info;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
|
|
284
|
+
|
|
285
|
+
async function retryMainAccountInfoIfIdentityChanged(
|
|
286
|
+
requestAccountId: string | null,
|
|
287
|
+
retriesRemaining: number,
|
|
288
|
+
): Promise<MainAccountInfoFetchResult | null> {
|
|
289
|
+
const currentAccountId = getMainChatgptAccountId();
|
|
290
|
+
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
291
|
+
reconcileMainCodexAccountRuntimeState();
|
|
292
|
+
return retriesRemaining > 0
|
|
293
|
+
? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
|
|
294
|
+
: { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
|
|
298
|
+
reconcileMainCodexAccountRuntimeState();
|
|
299
|
+
const tokenRead = readCodexTokensResult();
|
|
300
|
+
if (tokenRead.status !== "ok") {
|
|
301
|
+
// A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
|
|
302
|
+
// gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
|
|
303
|
+
// account for reauth here destroyed healthy email/plan/quota state and pinned a working
|
|
304
|
+
// account as unusable. Preserve what we already know and let the caller retry; request
|
|
305
|
+
// routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
|
|
306
|
+
// account DTO still reports hasCredential=false while the file is unreadable.
|
|
307
|
+
const preserved = getMainAccountInfoCache();
|
|
308
|
+
return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO };
|
|
262
309
|
}
|
|
263
|
-
|
|
264
|
-
|
|
310
|
+
const tokens = tokenRead.tokens;
|
|
311
|
+
const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
312
|
+
const cached = getMainAccountInfoCache();
|
|
313
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
|
|
314
|
+
return { info: cached };
|
|
265
315
|
}
|
|
266
316
|
try {
|
|
267
317
|
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
@@ -269,20 +319,27 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
|
|
|
269
319
|
signal: AbortSignal.timeout(8000),
|
|
270
320
|
});
|
|
271
321
|
if (!resp.ok) {
|
|
272
|
-
|
|
273
|
-
|
|
322
|
+
const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
|
|
323
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
324
|
+
if (retried) return retried;
|
|
325
|
+
if (terminalAuthFailure) {
|
|
326
|
+
clearMainAccountInfoCache();
|
|
274
327
|
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
275
328
|
}
|
|
276
|
-
return {
|
|
329
|
+
return { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
277
330
|
}
|
|
278
331
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
332
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
333
|
+
if (retried) return retried;
|
|
334
|
+
const quota = parseUsageQuota(data);
|
|
335
|
+
const freshResetCredits = quota?.resetCredits;
|
|
279
336
|
const result = {
|
|
280
337
|
email: data.email ?? null,
|
|
281
338
|
plan: data.plan_type ?? null,
|
|
282
|
-
quota
|
|
339
|
+
quota,
|
|
283
340
|
ts: Date.now(),
|
|
284
341
|
};
|
|
285
|
-
|
|
342
|
+
setMainAccountInfoCache(result);
|
|
286
343
|
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
287
344
|
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
288
345
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
@@ -290,15 +347,21 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
|
|
|
290
347
|
if (result.quota) {
|
|
291
348
|
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
|
|
292
349
|
}
|
|
293
|
-
return
|
|
350
|
+
return {
|
|
351
|
+
info: result,
|
|
352
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
353
|
+
};
|
|
294
354
|
} catch {
|
|
295
|
-
|
|
355
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
356
|
+
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
296
357
|
}
|
|
297
358
|
}
|
|
298
359
|
|
|
299
360
|
interface PoolQuotaResult {
|
|
300
361
|
quota: StoredAccountQuota | null;
|
|
301
362
|
needsReauth: boolean;
|
|
363
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
364
|
+
freshResetCredits?: number;
|
|
302
365
|
}
|
|
303
366
|
|
|
304
367
|
export interface CodexAuthAccountDto {
|
|
@@ -311,6 +374,10 @@ export interface CodexAuthAccountDto {
|
|
|
311
374
|
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
312
375
|
needsReauth?: boolean;
|
|
313
376
|
hasCredential: boolean;
|
|
377
|
+
health: OAuthAccountHealth;
|
|
378
|
+
healthLabel: OAuthHealthLabel;
|
|
379
|
+
healthSummary: string;
|
|
380
|
+
healthAction?: string;
|
|
314
381
|
}
|
|
315
382
|
|
|
316
383
|
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
|
|
@@ -327,9 +394,14 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
327
394
|
if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
|
|
328
395
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
329
396
|
const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
|
|
397
|
+
const freshResetCredits = quota?.resetCredits;
|
|
330
398
|
if (!quota) return { quota: existing ?? null, needsReauth: false };
|
|
331
399
|
setAccountQuotaFromParsed(accountId, quota);
|
|
332
|
-
return {
|
|
400
|
+
return {
|
|
401
|
+
quota: getAccountQuota(accountId),
|
|
402
|
+
needsReauth: false,
|
|
403
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
404
|
+
};
|
|
333
405
|
} catch (e) {
|
|
334
406
|
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
|
|
335
407
|
if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
|
|
@@ -363,6 +435,10 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
|
|
|
363
435
|
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
|
|
364
436
|
) return;
|
|
365
437
|
if (primeInFlight) return primeInFlight;
|
|
438
|
+
// Seed the observed physical main identity before startup/lazy priming can populate quota or
|
|
439
|
+
// plan state. Otherwise the first post-startup account switch sees no previous identity and
|
|
440
|
+
// skips the purge that protects the stable __main__ alias.
|
|
441
|
+
reconcileMainCodexAccountRuntimeState();
|
|
366
442
|
primeInFlight = (async () => {
|
|
367
443
|
const runtimeConfig = getRuntimeConfig(config);
|
|
368
444
|
const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
|
|
@@ -407,14 +483,20 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
|
|
|
407
483
|
return poolAccountDto(a, quotaResult, !!cred);
|
|
408
484
|
});
|
|
409
485
|
const hasMainCredential = readCodexTokens() !== null;
|
|
486
|
+
const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
487
|
+
const mainHealth = projectCodexAccountHealth({
|
|
488
|
+
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
489
|
+
needsReauth: mainNeedsReauth,
|
|
490
|
+
});
|
|
410
491
|
const main: CodexAuthAccountDto = {
|
|
411
492
|
id: MAIN_CODEX_ACCOUNT_ID,
|
|
412
493
|
email: maskEmail(mainInfo.email) ?? "Codex App login",
|
|
413
494
|
plan: mainInfo.plan,
|
|
414
495
|
isMain: true,
|
|
415
496
|
hasCredential: hasMainCredential,
|
|
416
|
-
needsReauth:
|
|
497
|
+
needsReauth: mainNeedsReauth,
|
|
417
498
|
quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
|
|
499
|
+
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
418
500
|
};
|
|
419
501
|
return [main, ...withQuota];
|
|
420
502
|
}
|
|
@@ -501,6 +583,21 @@ export async function handleCodexAuthAPI(
|
|
|
501
583
|
return jsonResponse({ ok: true, id, alias: alias || null });
|
|
502
584
|
}
|
|
503
585
|
|
|
586
|
+
// Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
|
|
587
|
+
// model path for Codex Desktop, so a cooldown that outlives the real upstream limit
|
|
588
|
+
// otherwise leaves editing config.toml as the user's only recovery.
|
|
589
|
+
//
|
|
590
|
+
// Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
|
|
591
|
+
// exactly like an account that simply had no live cooldown, so this route cannot be used
|
|
592
|
+
// to enumerate configured accounts. Cooldown state is runtime-only and independent of the
|
|
593
|
+
// account list, so 404 would carry no useful meaning anyway.
|
|
594
|
+
if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
|
|
595
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown };
|
|
596
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
597
|
+
if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
598
|
+
return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
|
|
599
|
+
}
|
|
600
|
+
|
|
504
601
|
if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
|
|
505
602
|
let body: { accountId: string | null };
|
|
506
603
|
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
@@ -608,13 +705,23 @@ export async function handleCodexAuthAPI(
|
|
|
608
705
|
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
609
706
|
}
|
|
610
707
|
const result = safeResetCreditConsumeDto(await resp.json());
|
|
611
|
-
|
|
708
|
+
// After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
|
|
709
|
+
// and return remaining only when that refresh freshly parsed available_count.
|
|
710
|
+
// Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
|
|
711
|
+
if (result.code === "reset" || result.code === "already_redeemed") {
|
|
712
|
+
let freshResetCredits: number | undefined;
|
|
612
713
|
if (auth.isMain) {
|
|
613
|
-
await
|
|
714
|
+
({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1));
|
|
614
715
|
} else {
|
|
615
716
|
const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
|
|
616
|
-
await fetchPoolAccountQuota(body.accountId, true, account?.plan);
|
|
717
|
+
({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan));
|
|
617
718
|
}
|
|
719
|
+
return jsonResponse({
|
|
720
|
+
code: result.code,
|
|
721
|
+
...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
|
|
722
|
+
? { remaining: freshResetCredits }
|
|
723
|
+
: {}),
|
|
724
|
+
});
|
|
618
725
|
}
|
|
619
726
|
return jsonResponse(result);
|
|
620
727
|
} catch (e) {
|
|
@@ -1,25 +1,69 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getCodexAccountCredential } from "./account-store";
|
|
4
4
|
import { loadConfig } from "../config";
|
|
5
5
|
import { resolveCodexHomeDir } from "./home";
|
|
6
6
|
import { extractAccountId } from "../oauth/chatgpt";
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export interface CodexTokens {
|
|
9
|
+
access_token: string;
|
|
10
|
+
account_id: string;
|
|
11
|
+
id_token?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Why a read *outcome* and not just `Tokens | null`: callers need to tell a real sign-out apart
|
|
16
|
+
* from a transient read failure. A single null collapsed "file absent", "malformed JSON", and
|
|
17
|
+
* "read error" into one answer, so a half-written `auth.json` looked exactly like a logout and
|
|
18
|
+
* callers destroyed healthy cached account state because of it.
|
|
19
|
+
*/
|
|
20
|
+
export type CodexTokenReadResult =
|
|
21
|
+
| { status: "ok"; tokens: CodexTokens }
|
|
22
|
+
| { status: "missing" | "invalid" | "unreadable" };
|
|
23
|
+
|
|
24
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
25
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
26
|
+
&& (error as { code?: unknown }).code === code;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an
|
|
31
|
+
* `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent.
|
|
32
|
+
* Never returns or logs the raw error or any token material.
|
|
33
|
+
*/
|
|
34
|
+
export function readCodexTokensResult(): CodexTokenReadResult {
|
|
35
|
+
let raw: string;
|
|
9
36
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
37
|
+
raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const j = JSON.parse(raw) as {
|
|
14
43
|
tokens?: { access_token?: string; account_id?: string; id_token?: string };
|
|
15
44
|
};
|
|
16
|
-
if (!j?.tokens?.access_token) return
|
|
45
|
+
if (!j?.tokens?.access_token) return { status: "invalid" };
|
|
17
46
|
return {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
47
|
+
status: "ok",
|
|
48
|
+
tokens: {
|
|
49
|
+
access_token: j.tokens.access_token,
|
|
50
|
+
account_id: j.tokens.account_id ?? "",
|
|
51
|
+
id_token: j.tokens.id_token,
|
|
52
|
+
},
|
|
21
53
|
};
|
|
22
|
-
} catch {
|
|
54
|
+
} catch {
|
|
55
|
+
return { status: "invalid" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compatibility wrapper: any usable-token check keeps the original null contract, which keeps
|
|
61
|
+
* request routing fail-closed. Only callers that must distinguish the failure reason should use
|
|
62
|
+
* `readCodexTokensResult()`.
|
|
63
|
+
*/
|
|
64
|
+
export function readCodexTokens(): CodexTokens | null {
|
|
65
|
+
const result = readCodexTokensResult();
|
|
66
|
+
return result.status === "ok" ? result.tokens : null;
|
|
23
67
|
}
|
|
24
68
|
|
|
25
69
|
export function getMainChatgptAccountId(): string | null {
|
|
@@ -6,14 +6,18 @@ import {
|
|
|
6
6
|
} from "./account-store";
|
|
7
7
|
import { markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
8
|
import { isCodexAccountUsable } from "./account-usability";
|
|
9
|
+
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
9
10
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
|
|
10
11
|
import {
|
|
11
|
-
|
|
12
|
+
getCodexAccountHealthSnapshot,
|
|
12
13
|
releaseCodexQuotaProbeLease,
|
|
13
14
|
tryAcquireCodexQuotaProbeLease,
|
|
14
15
|
pickLowestUsageCodexAccount,
|
|
15
16
|
resolveCodexAccountForThreadDetailed,
|
|
16
17
|
} from "./routing";
|
|
18
|
+
import type { CodexCooldownSource } from "./routing";
|
|
19
|
+
import { maskAccountId } from "../lib/privacy";
|
|
20
|
+
import { formatErrorResponse } from "../bridge";
|
|
17
21
|
import { getAccountQuota } from "./quota";
|
|
18
22
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
19
23
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
@@ -94,15 +98,49 @@ export function hasCallerCodexBearer(headers: Headers): boolean {
|
|
|
94
98
|
export class CodexAccountCooldownError extends Error {
|
|
95
99
|
accountId: string;
|
|
96
100
|
cooldownUntil: number;
|
|
101
|
+
cooldownSource?: CodexCooldownSource;
|
|
97
102
|
|
|
98
|
-
constructor(accountId: string, cooldownUntil: number) {
|
|
103
|
+
constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) {
|
|
99
104
|
super("Selected Codex account is cooling down");
|
|
100
105
|
this.name = "CodexAccountCooldownError";
|
|
101
106
|
this.accountId = accountId;
|
|
102
107
|
this.cooldownUntil = cooldownUntil;
|
|
108
|
+
this.cooldownSource = cooldownSource;
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Human-readable account label for a client-visible error. NEVER the raw id: the proxy
|
|
114
|
+
* supports non-loopback binds (auth-cors.ts `isApiAuthRequired` requires a token there
|
|
115
|
+
* rather than refusing), so data-plane bodies can reach remote authenticated clients.
|
|
116
|
+
* The main login has no secret id, so it renders as the literal alias users type.
|
|
117
|
+
*/
|
|
118
|
+
export function cooldownAccountLabel(accountId: string): string {
|
|
119
|
+
return accountId === MAIN_CODEX_ACCOUNT_ID ? "main" : maskAccountId(accountId) ?? "account-…????";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Actionable message for a cooled-down account: until when, why, and how to escape.
|
|
124
|
+
* Shared by every transport so the WebSocket surface (Codex Desktop) says the same thing
|
|
125
|
+
* as HTTP. The bare "cooling down" string left users with no route but commenting out the
|
|
126
|
+
* injected `openai_base_url` in config.toml.
|
|
127
|
+
*/
|
|
128
|
+
export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
|
|
129
|
+
const until = new Date(err.cooldownUntil).toISOString();
|
|
130
|
+
return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}`
|
|
131
|
+
+ ` (source: ${err.cooldownSource ?? "default"}).`
|
|
132
|
+
+ ` Run 'ocx account list openai' to find the id, then`
|
|
133
|
+
+ ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */
|
|
137
|
+
export function cooldownErrorResponse(err: CodexAccountCooldownError, now = Date.now()): Response {
|
|
138
|
+
const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err));
|
|
139
|
+
const headers = new Headers(res.headers);
|
|
140
|
+
headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
|
|
141
|
+
return new Response(res.body, { status: res.status, headers });
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
export class CodexThreadAffinityExpiredError extends Error {
|
|
107
145
|
accountId: string;
|
|
108
146
|
|
|
@@ -131,6 +169,7 @@ export async function resolveCodexAuthContext(
|
|
|
131
169
|
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
132
170
|
return { kind: "main", accountId: null };
|
|
133
171
|
}
|
|
172
|
+
reconcileMainCodexAccountRuntimeState();
|
|
134
173
|
const threadId = headers.get("x-codex-parent-thread-id");
|
|
135
174
|
const resolution = options.excludeAccountId
|
|
136
175
|
? (() => {
|
|
@@ -153,14 +192,17 @@ export async function resolveCodexAuthContext(
|
|
|
153
192
|
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
|
|
154
193
|
.catch(() => {});
|
|
155
194
|
}
|
|
156
|
-
|
|
195
|
+
// Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
|
|
196
|
+
// a literal Retry-After reads very differently to a user than a reset-derived guess.
|
|
197
|
+
const cooldown = getCodexAccountHealthSnapshot(accountId);
|
|
198
|
+
const cooldownUntil = cooldown?.cooldownUntil;
|
|
157
199
|
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
158
200
|
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
159
201
|
// interval; its outcome decides whether the cooldown ends (#433).
|
|
160
202
|
let probeLeaseId: string | undefined;
|
|
161
203
|
if (cooldownUntil) {
|
|
162
204
|
probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
|
|
163
|
-
if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
205
|
+
if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource);
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
@@ -203,8 +245,10 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine
|
|
|
203
245
|
if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
|
|
204
246
|
// A context holding the probe lease was deliberately admitted through the cooldown.
|
|
205
247
|
if (ctx.probeLeaseId) return;
|
|
206
|
-
const
|
|
207
|
-
if (cooldownUntil)
|
|
248
|
+
const cooldown = getCodexAccountHealthSnapshot(ctx.accountId);
|
|
249
|
+
if (cooldown?.cooldownUntil) {
|
|
250
|
+
throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource);
|
|
251
|
+
}
|
|
208
252
|
}
|
|
209
253
|
|
|
210
254
|
export function applyCodexAuthContextToProvider(
|
|
@@ -267,7 +267,8 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
267
267
|
if (liveResult.ok) {
|
|
268
268
|
const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
|
|
269
269
|
const result = available.length > 0 ? available : configured;
|
|
270
|
-
|
|
270
|
+
// Count what discovery actually returned, not the configured rows we fall back to.
|
|
271
|
+
markProviderDiscoveryOk(name, liveResult.models.length);
|
|
271
272
|
setCached(name, result);
|
|
272
273
|
return result;
|
|
273
274
|
}
|
|
@@ -384,6 +385,9 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
384
385
|
...catalogHintsFromModelsApiItem(name, m),
|
|
385
386
|
}, contextCap))
|
|
386
387
|
.filter(m => shouldExposeProviderModel(name, m.id));
|
|
388
|
+
// Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
|
|
389
|
+
// `live`; otherwise configured entries would be reported as discovered ones.
|
|
390
|
+
const liveModelCount = live.length;
|
|
387
391
|
const liveIds = new Set(live.map(m => m.id));
|
|
388
392
|
// Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
|
|
389
393
|
// ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
|
|
@@ -412,7 +416,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
412
416
|
&& !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
|
|
413
417
|
warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
|
|
414
418
|
}
|
|
415
|
-
markProviderDiscoveryOk(name);
|
|
419
|
+
markProviderDiscoveryOk(name, liveModelCount);
|
|
416
420
|
setCached(name, live);
|
|
417
421
|
return live;
|
|
418
422
|
} catch (error) {
|