@bitkyc08/opencodex 2.49.0 → 2.50.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/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +107 -23
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +3 -2
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +58 -0
- package/src/generated/compatibility-version.json +72 -60
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +4 -4
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +5 -5
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +10 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +144 -38
- package/src/server/responses/policy-fallback.ts +6 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +92 -7
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +36 -0
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/gui/dist/assets/index-BtyONQrZ.js +0 -115
package/src/oauth/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { parseCallbackInput } from "./callback-server";
|
|
|
4
4
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
5
5
|
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
|
|
6
6
|
import { resolveProviderApiKey } from "../providers/key-store";
|
|
7
|
-
import {
|
|
7
|
+
import { projectEmail } from "../lib/privacy";
|
|
8
8
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
9
9
|
import {
|
|
10
10
|
OAuthMutationBusyError,
|
|
@@ -1781,19 +1781,54 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
|
|
|
1781
1781
|
return { ok: true };
|
|
1782
1782
|
}
|
|
1783
1783
|
|
|
1784
|
-
export interface OAuthAccountSummary {
|
|
1784
|
+
export interface OAuthAccountSummary {
|
|
1785
|
+
id: string;
|
|
1786
|
+
alias?: string;
|
|
1787
|
+
email?: string;
|
|
1788
|
+
active: boolean;
|
|
1789
|
+
needsReauth?: boolean;
|
|
1790
|
+
expiresAt?: number;
|
|
1791
|
+
/**
|
|
1792
|
+
* Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
|
|
1793
|
+
* weighting a multi-account pool by seat size needs no per-provider branching (#3777).
|
|
1794
|
+
*
|
|
1795
|
+
* Always present and explicitly `null` when the tier is unknown. The distinction matters:
|
|
1796
|
+
* an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
|
|
1797
|
+
* version looked and upstream did not say. Omitting it would make those indistinguishable and
|
|
1798
|
+
* invite a consumer to assume a tier.
|
|
1799
|
+
*
|
|
1800
|
+
* Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
|
|
1801
|
+
* buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
|
|
1802
|
+
* and carries no subscription/tier field, and its token response carries none either. See
|
|
1803
|
+
* `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
|
|
1804
|
+
*/
|
|
1805
|
+
plan: string | null;
|
|
1806
|
+
}
|
|
1785
1807
|
|
|
1786
|
-
|
|
1808
|
+
/**
|
|
1809
|
+
* Token-safe login state for one provider.
|
|
1810
|
+
*
|
|
1811
|
+
* `maskEmails` is an explicit boolean rather than a config read (#3859). This module must not
|
|
1812
|
+
* acquire a dependency on config I/O to answer a redaction question: the caller already holds
|
|
1813
|
+
* the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
|
|
1814
|
+
* The default masks, so every existing caller keeps today's behaviour.
|
|
1815
|
+
*/
|
|
1816
|
+
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
1787
1817
|
const cred = getCredential(provider);
|
|
1788
1818
|
const st = loginState.get(provider);
|
|
1789
1819
|
const set = getAccountSet(provider);
|
|
1790
1820
|
const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
|
|
1791
1821
|
id: a.id,
|
|
1792
1822
|
...(a.alias ? { alias: a.alias } : {}),
|
|
1793
|
-
email:
|
|
1823
|
+
email: projectEmail(a.credential.email, maskEmails) ?? undefined,
|
|
1794
1824
|
active: a.id === set.activeAccountId,
|
|
1795
1825
|
...(a.needsReauth ? { needsReauth: true } : {}),
|
|
1796
1826
|
expiresAt: a.credential.expires,
|
|
1827
|
+
// Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
|
|
1828
|
+
// exposes a subscription tier today, so there is nothing truthful to put here; deriving one
|
|
1829
|
+
// from quota percentages is not possible, because they are normalized per account and a
|
|
1830
|
+
// half-consumed small seat is indistinguishable from a half-consumed large one.
|
|
1831
|
+
plan: null,
|
|
1797
1832
|
}));
|
|
1798
1833
|
|
|
1799
1834
|
// A stored credential counts as "logged in" when it exists and is not marked for
|
|
@@ -1805,7 +1840,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
|
|
|
1805
1840
|
.find(a => a.id === set.activeAccountId)?.needsReauth === true;
|
|
1806
1841
|
return {
|
|
1807
1842
|
loggedIn: !!cred && !activeNeedsReauth,
|
|
1808
|
-
email:
|
|
1843
|
+
email: projectEmail(cred?.email, maskEmails) ?? undefined,
|
|
1809
1844
|
source: cred?.source,
|
|
1810
1845
|
error: st?.error,
|
|
1811
1846
|
done: st?.done ?? false,
|
|
@@ -1813,10 +1848,13 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
|
|
|
1813
1848
|
};
|
|
1814
1849
|
}
|
|
1815
1850
|
|
|
1816
|
-
/**
|
|
1817
|
-
|
|
1851
|
+
/**
|
|
1852
|
+
* Token-safe per-provider login state for the CLI `ocx status` logins section. Never tokens; the
|
|
1853
|
+
* email follows the operator's `privacy.maskEmails` policy, masked by default (#3859).
|
|
1854
|
+
*/
|
|
1855
|
+
export function oauthLoginSummary(maskEmails = true): Array<{ provider: string; loggedIn: boolean; email?: string }> {
|
|
1818
1856
|
return listOAuthProviders().map(provider => {
|
|
1819
|
-
const status = getLoginStatus(provider);
|
|
1857
|
+
const status = getLoginStatus(provider, maskEmails);
|
|
1820
1858
|
return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
|
|
1821
1859
|
});
|
|
1822
1860
|
}
|
|
@@ -211,21 +211,30 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
211
211
|
if (!cred) continue;
|
|
212
212
|
const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
|
|
213
213
|
const needsWarmup = opts.codexWarmupEnabled
|
|
214
|
+
&& !record.codexValidationPending
|
|
214
215
|
&& (record.lastCodexValidatedAt === undefined || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
|
|
215
216
|
if (!needsRefresh && !needsWarmup) continue;
|
|
216
217
|
const key = `codex:${id}`;
|
|
217
218
|
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
219
|
+
// The generation this sweep is acting on. A successful refresh commits a new one, and a
|
|
220
|
+
// failure that follows belongs to THAT credential, so the fence has to move with it.
|
|
221
|
+
let observedGeneration = record.generation;
|
|
218
222
|
tasks.push(async () => {
|
|
223
|
+
let warmupGeneration: number | undefined;
|
|
219
224
|
try {
|
|
220
225
|
const token = await getValidCodexToken(id);
|
|
226
|
+
observedGeneration = token.generation;
|
|
221
227
|
if (needsRefresh) result.refreshed.push(key);
|
|
222
|
-
|
|
228
|
+
const current = readCodexAccountRecord(id);
|
|
229
|
+
if (needsWarmup && current?.credential && current.deletedAt == null
|
|
230
|
+
&& !current.codexValidationPending && current.generation === token.generation) {
|
|
231
|
+
warmupGeneration = token.generation;
|
|
223
232
|
await warmCodexAccount({
|
|
224
233
|
accessToken: token.accessToken,
|
|
225
234
|
chatgptAccountId: token.chatgptAccountId,
|
|
226
235
|
model: opts.codexWarmupModel,
|
|
227
236
|
});
|
|
228
|
-
markCodexAccountValidated(id, Date.now());
|
|
237
|
+
markCodexAccountValidated(id, Date.now(), token.generation);
|
|
229
238
|
result.warmed.push(key);
|
|
230
239
|
}
|
|
231
240
|
backoff.delete(key);
|
|
@@ -235,11 +244,28 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
235
244
|
result.skippedBackoff.push(key);
|
|
236
245
|
return;
|
|
237
246
|
}
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
const terminal = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired")
|
|
248
|
+
? err
|
|
249
|
+
: undefined;
|
|
250
|
+
if (terminal) {
|
|
251
|
+
// A revoked or expired refresh grant is the strongest terminal evidence there is, and
|
|
252
|
+
// it used to be the one class that never reached the record: the persisted-verdict
|
|
253
|
+
// branch below requires `needsWarmup`, which is false in the default configuration,
|
|
254
|
+
// and additionally excluded every TokenRefreshError. The verdict landed only in the
|
|
255
|
+
// in-memory backoff map, which no health surface reads and no restart survives, so the
|
|
256
|
+
// account kept its login-time "ok" while every request with it 401'd (#4120).
|
|
257
|
+
markCodexAccountValidationFailed(id, `refresh_${terminal.reason}`, {
|
|
258
|
+
expectedGeneration: observedGeneration,
|
|
259
|
+
terminal: true,
|
|
260
|
+
});
|
|
261
|
+
} else if (warmupGeneration !== undefined && !(err instanceof TokenRefreshError)) {
|
|
262
|
+
// warmupGeneration is set only once the warmup actually started against a record
|
|
263
|
+
// still at the token's generation, so it is a tighter fence than the pre-sweep read.
|
|
264
|
+
markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), {
|
|
265
|
+
expectedGeneration: warmupGeneration,
|
|
266
|
+
});
|
|
241
267
|
}
|
|
242
|
-
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds,
|
|
268
|
+
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, terminal !== undefined, writerGeneration);
|
|
243
269
|
result.failed.push(key);
|
|
244
270
|
}
|
|
245
271
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractModelEnvelopeRows,
|
|
3
|
+
isValidModelDiscoveryModelId,
|
|
4
|
+
type ProviderModelItemsResult,
|
|
5
|
+
type ProviderModelsApiItem,
|
|
6
|
+
} from "./model-discovery";
|
|
7
|
+
|
|
8
|
+
const GOOGLE_MODEL_PREFIX = "models/";
|
|
9
|
+
const MAX_GENERATION_METHODS = 32;
|
|
10
|
+
const MAX_GENERATION_METHOD_LENGTH = 64;
|
|
11
|
+
|
|
12
|
+
/** Returns the value if it is a positive safe integer; otherwise undefined. */
|
|
13
|
+
function positiveSafeInteger(value: unknown): number | undefined {
|
|
14
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
15
|
+
? value
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extracts and normalizes supported model items from a Google AI Studio
|
|
21
|
+
* /v1beta/models response payload.
|
|
22
|
+
*
|
|
23
|
+
* Validates the native models[] envelope, strips the 'models/' prefix, filters
|
|
24
|
+
* to rows supporting 'generateContent', maps input/output token limits, and
|
|
25
|
+
* resiliently skips toxic or malformed individual rows.
|
|
26
|
+
*/
|
|
27
|
+
export function extractGoogleAiStudioModelItems(
|
|
28
|
+
value: unknown,
|
|
29
|
+
maxModels: number,
|
|
30
|
+
): ProviderModelItemsResult {
|
|
31
|
+
const envelope = extractModelEnvelopeRows(value, maxModels, ["models"]);
|
|
32
|
+
if (!envelope.ok) return envelope;
|
|
33
|
+
|
|
34
|
+
const items: ProviderModelsApiItem[] = [];
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const raw of envelope.rows) {
|
|
37
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const name = Reflect.get(raw, "name");
|
|
41
|
+
const generationMethods = Reflect.get(raw, "supportedGenerationMethods");
|
|
42
|
+
if (!isValidModelDiscoveryModelId(name)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (generationMethods === undefined) continue;
|
|
46
|
+
if (
|
|
47
|
+
!Array.isArray(generationMethods)
|
|
48
|
+
|| generationMethods.length > MAX_GENERATION_METHODS
|
|
49
|
+
|| generationMethods.some(method => typeof method !== "string" || method.length > MAX_GENERATION_METHOD_LENGTH)
|
|
50
|
+
) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!generationMethods.includes("generateContent")) continue;
|
|
54
|
+
|
|
55
|
+
const id = name.startsWith(GOOGLE_MODEL_PREFIX)
|
|
56
|
+
? name.slice(GOOGLE_MODEL_PREFIX.length)
|
|
57
|
+
: name;
|
|
58
|
+
if (!isValidModelDiscoveryModelId(id) || seen.has(id)) continue;
|
|
59
|
+
seen.add(id);
|
|
60
|
+
|
|
61
|
+
const inputTokenLimit = positiveSafeInteger(Reflect.get(raw, "inputTokenLimit"));
|
|
62
|
+
const outputTokenLimit = positiveSafeInteger(Reflect.get(raw, "outputTokenLimit"));
|
|
63
|
+
items.push({
|
|
64
|
+
id,
|
|
65
|
+
owned_by: "google",
|
|
66
|
+
...(inputTokenLimit !== undefined
|
|
67
|
+
? { context_length: inputTokenLimit, max_input_tokens: inputTokenLimit }
|
|
68
|
+
: {}),
|
|
69
|
+
...(outputTokenLimit !== undefined ? { max_output_tokens: outputTokenLimit } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, items, rawCount: envelope.rows.length };
|
|
73
|
+
}
|
|
74
|
+
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
|
|
8
8
|
* present they still take precedence. Distinct from the keyless desktop
|
|
9
9
|
* ~200 requests / 5h quota documented on `opencode-free`.
|
|
10
|
+
*
|
|
11
|
+
* The same module also owns the keyless free-tier admission explanation (#4121):
|
|
12
|
+
* Zen rejects a request that carries no `x-opencode-session` header with
|
|
13
|
+
* `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode".
|
|
14
|
+
* opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}.
|
|
10
15
|
*/
|
|
11
16
|
import { validateClientRetryAfterHeader } from "../lib/retry-after";
|
|
12
17
|
import { registryEntryForProviderDestination } from "./registry";
|
|
@@ -100,3 +105,73 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
100
105
|
+ paceHint
|
|
101
106
|
);
|
|
102
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Zen's keyless free tier admits only OpenCode's own client. A request without an
|
|
111
|
+
* `x-opencode-session` header is refused with error type `MissingSessionID` and the
|
|
112
|
+
* message "OpenCode's free tier can only be used in OpenCode" (#4121).
|
|
113
|
+
*
|
|
114
|
+
* Presence of the header is the whole gate — any value clears it — so opencodex could
|
|
115
|
+
* pass by minting one. It does not. Fabricating a session identifier and a versioned
|
|
116
|
+
* `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
|
|
117
|
+
* contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
|
|
118
|
+
* bypassed admission check, not permission. Until OpenCode publishes a third-party
|
|
119
|
+
* integration path for this exact keyless tier, the supported route is the keyed
|
|
120
|
+
* `opencode-zen` provider.
|
|
121
|
+
*
|
|
122
|
+
* Two markers are matched because the two request surfaces expose different parts of the
|
|
123
|
+
* upstream envelope: the Responses path forwards the bounded raw body (which carries the
|
|
124
|
+
* `MissingSessionID` type), while the native Chat path forwards only the parsed message.
|
|
125
|
+
*/
|
|
126
|
+
const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
|
|
127
|
+
|
|
128
|
+
/** Idempotence marker — the appended guidance must not stack across enrichment layers. */
|
|
129
|
+
const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
|
|
130
|
+
|
|
131
|
+
/** True when an upstream error body is Zen's keyless free-tier admission refusal. */
|
|
132
|
+
export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
|
|
133
|
+
if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
|
|
134
|
+
return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
|
|
139
|
+
* restriction and the supported alternative. No-op for every other provider and every
|
|
140
|
+
* other error, and idempotent so layered enrichment cannot append it twice.
|
|
141
|
+
*/
|
|
142
|
+
export function enrichOpenCodeZenFreeTierMessage(
|
|
143
|
+
message: string,
|
|
144
|
+
opts: {
|
|
145
|
+
providerName?: string;
|
|
146
|
+
baseUrl?: string;
|
|
147
|
+
adapter?: string;
|
|
148
|
+
/** Upstream `error.type`, when the caller parsed one out of the envelope. */
|
|
149
|
+
upstreamErrorType?: string | null;
|
|
150
|
+
},
|
|
151
|
+
): string {
|
|
152
|
+
if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
|
|
153
|
+
if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
|
|
154
|
+
if (!isOpenCodeZenRateLimitProvider(opts)) return message;
|
|
155
|
+
return (
|
|
156
|
+
`${message}`
|
|
157
|
+
+ " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
|
|
158
|
+
+ " request that arrives without an x-opencode-session header."
|
|
159
|
+
+ ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
|
|
160
|
+
+ " client is a claim no upstream contract supports."
|
|
161
|
+
+ " Use the keyed opencode-zen provider with an OpenCode Zen API key"
|
|
162
|
+
+ " (https://opencode.ai/auth), or route this model through another provider."
|
|
163
|
+
+ " Upstream terms: https://opencode.ai/docs/zen/."
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Single entry point for Zen upstream-error guidance on the Responses wire: short-window
|
|
169
|
+
* rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
|
|
170
|
+
* outside its own case, so the composition is safe for every other upstream failure.
|
|
171
|
+
*/
|
|
172
|
+
export function enrichOpenCodeZenUpstreamMessage(
|
|
173
|
+
message: string,
|
|
174
|
+
opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
|
|
175
|
+
): string {
|
|
176
|
+
return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
|
|
177
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -1398,6 +1398,21 @@ function parseClaudeLimit(value: unknown): { label: string; percent: number; res
|
|
|
1398
1398
|
/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
|
|
1399
1399
|
const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
|
|
1400
1400
|
|
|
1401
|
+
/**
|
|
1402
|
+
* Anthropic per-credential usage.
|
|
1403
|
+
*
|
|
1404
|
+
* This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the
|
|
1405
|
+
* model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array,
|
|
1406
|
+
* and **no subscription or tier field** — nor does the OAuth token response, which yields only
|
|
1407
|
+
* `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why
|
|
1408
|
+
* `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is
|
|
1409
|
+
* a missing upstream field, not an unfinished mapping.
|
|
1410
|
+
*
|
|
1411
|
+
* A tier must not be inferred from what is here. Percentages are normalized per account, so a
|
|
1412
|
+
* Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a
|
|
1413
|
+
* model-scoped window tracks entitlement rather than seat size. Populate `plan` only when
|
|
1414
|
+
* upstream returns the tier itself.
|
|
1415
|
+
*/
|
|
1401
1416
|
async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
1402
1417
|
const joinable = anthropicUsageInflight.get(accessToken);
|
|
1403
1418
|
if (joinable) return joinable;
|
|
@@ -3018,7 +3018,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
3018
3018
|
keyOptional: true,
|
|
3019
3019
|
featured: true,
|
|
3020
3020
|
liveModels: true,
|
|
3021
|
-
note: "No key needed
|
|
3021
|
+
note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
|
|
3022
3022
|
dashboardUrl: "https://opencode.ai",
|
|
3023
3023
|
staticHeaders: {
|
|
3024
3024
|
// Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
codexAutoStartEnabled,
|
|
7
7
|
modelPreferHostedToolsConfigError,
|
|
8
8
|
providerModelCostsConfigError,
|
|
9
|
+
providerWebSearchBridgeConfigError,
|
|
9
10
|
requestPacingConfigError,
|
|
10
11
|
retryOn429PolicyConfigError,
|
|
11
12
|
sanitizeModelCostsForDisplay,
|
|
@@ -650,6 +651,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
650
651
|
if (requestPacingError) {
|
|
651
652
|
return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
|
|
652
653
|
}
|
|
654
|
+
const webSearchBridgeError = providerWebSearchBridgeConfigError(raw.webSearchBridge);
|
|
655
|
+
if (webSearchBridgeError) {
|
|
656
|
+
return `provider ${JSON.stringify(redactSecretString(name))} ${webSearchBridgeError}`;
|
|
657
|
+
}
|
|
653
658
|
const upstreamHttpVersionError = upstreamHttpVersionConfigError(raw.upstreamHttpVersion);
|
|
654
659
|
if (upstreamHttpVersionError) {
|
|
655
660
|
return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`;
|
|
@@ -847,6 +852,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
847
852
|
xaiResponsesDefaultVersion: "runtime",
|
|
848
853
|
supportsResponsesCustomTools: "editor",
|
|
849
854
|
responsesSnapshotRepair: "editor",
|
|
855
|
+
webSearchBridge: "editor",
|
|
850
856
|
reasoningEffortMap: "editor",
|
|
851
857
|
modelReasoningEffortMap: "editor",
|
|
852
858
|
reasoningWireFormat: "editor",
|
|
@@ -28,7 +28,7 @@ import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
|
28
28
|
import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
|
|
29
29
|
import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
|
|
30
30
|
import type { OcxConfig } from "../types";
|
|
31
|
-
import { readJsonRequestBody } from "./request-decompress";
|
|
31
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
32
32
|
import {
|
|
33
33
|
addFinalRequestLog,
|
|
34
34
|
httpStatusForRequestLogTerminal,
|
|
@@ -62,9 +62,9 @@ type Rec = Record<string, unknown>;
|
|
|
62
62
|
function isRec(v: unknown): v is Rec {
|
|
63
63
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
64
64
|
}
|
|
65
|
-
async function readChatBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
|
|
65
|
+
async function readChatBody(req: Request, budget: TranslatorBudget, maxBytes: number): Promise<unknown> {
|
|
66
66
|
try {
|
|
67
|
-
return await readJsonRequestBody(req, budget);
|
|
67
|
+
return await readJsonRequestBody(req, budget, maxBytes);
|
|
68
68
|
} catch (err) {
|
|
69
69
|
if (isTranslatorBudgetExceededError(err)) throw err;
|
|
70
70
|
throw new ChatCompletionsRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
@@ -106,7 +106,7 @@ async function handleChatCompletionsWithBudget(
|
|
|
106
106
|
): Promise<Response> {
|
|
107
107
|
let chatBody: Rec;
|
|
108
108
|
try {
|
|
109
|
-
const rawBody = await readChatBody(req, translatorBudget);
|
|
109
|
+
const rawBody = await readChatBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
110
110
|
assertChatCompletionsRoutingBody(rawBody);
|
|
111
111
|
chatBody = rawBody;
|
|
112
112
|
} catch (err) {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "../providers/key-failover";
|
|
41
41
|
import { fastPolicyForModel } from "../providers/service-tier";
|
|
42
42
|
import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
|
|
43
|
+
import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
|
|
43
44
|
import type { OcxProviderTransport } from "../providers/xai-transport";
|
|
44
45
|
import type { RouteResult } from "../router";
|
|
45
46
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
@@ -438,12 +439,20 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
438
439
|
&& (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage))
|
|
439
440
|
? upstreamMessage
|
|
440
441
|
: detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
|
|
442
|
+
// Zen's keyless free tier refuses the request outright rather than rate-limiting it, and
|
|
443
|
+
// the raw `MissingSessionID` tells a user nothing about why or what to do (#4121).
|
|
444
|
+
const clientMessage = enrichOpenCodeZenFreeTierMessage(message, {
|
|
445
|
+
providerName: route.providerName,
|
|
446
|
+
baseUrl: route.provider.baseUrl,
|
|
447
|
+
adapter: route.provider.adapter,
|
|
448
|
+
upstreamErrorType: upstreamType,
|
|
449
|
+
});
|
|
441
450
|
const classified = classifyError(
|
|
442
451
|
response.status,
|
|
443
452
|
upstreamType ?? (response.status === 401 ? "authentication_error"
|
|
444
453
|
: response.status === 429 ? "rate_limit_error"
|
|
445
454
|
: response.status >= 500 ? "server_error" : "invalid_request_error"),
|
|
446
|
-
|
|
455
|
+
clientMessage,
|
|
447
456
|
);
|
|
448
457
|
if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
|
|
449
458
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
@@ -34,7 +34,7 @@ import { registryEntryForProviderDestination } from "../providers/registry";
|
|
|
34
34
|
import { evidenceFromBody } from "../routing/request-evidence";
|
|
35
35
|
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
36
36
|
import type { OcxConfig } from "../types";
|
|
37
|
-
import { readJsonRequestBody } from "./request-decompress";
|
|
37
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
38
38
|
import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
|
|
39
39
|
import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
|
|
40
40
|
import { responseWithDeferredRequestLog } from "./relay";
|
|
@@ -129,9 +129,9 @@ function claudeInboundDisabled(config: OcxConfig): Response | null {
|
|
|
129
129
|
return null;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
|
|
132
|
+
async function readAnthropicBody(req: Request, budget: TranslatorBudget, maxBytes: number): Promise<unknown> {
|
|
133
133
|
try {
|
|
134
|
-
return await readJsonRequestBody(req, budget);
|
|
134
|
+
return await readJsonRequestBody(req, budget, maxBytes);
|
|
135
135
|
} catch (err) {
|
|
136
136
|
if (isTranslatorBudgetExceededError(err)) throw err;
|
|
137
137
|
throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
@@ -655,7 +655,7 @@ async function handleClaudeMessagesWithBudget(
|
|
|
655
655
|
let fastRow: ParsedFastRowId | null = null;
|
|
656
656
|
let requestedModel = "";
|
|
657
657
|
try {
|
|
658
|
-
anthropicBody = await readAnthropicBody(req, translatorBudget);
|
|
658
|
+
anthropicBody = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
659
659
|
// Defensive [1m] strip (devlog 138): clients normally remove the context-variant
|
|
660
660
|
// marker themselves; the 1M signal we act on is the anthropic-beta header.
|
|
661
661
|
// Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
|
|
@@ -1154,7 +1154,7 @@ export async function handleClaudeCountTokens(
|
|
|
1154
1154
|
let body: unknown;
|
|
1155
1155
|
const translatorBudget = createTranslatorBudget();
|
|
1156
1156
|
try {
|
|
1157
|
-
body = await readAnthropicBody(req, translatorBudget);
|
|
1157
|
+
body = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
1158
1158
|
} catch (err) {
|
|
1159
1159
|
if (err instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(err);
|
|
1160
1160
|
if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
|
package/src/server/images.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
|
29
29
|
import type { OcxConfig } from "../types";
|
|
30
30
|
import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
|
|
31
31
|
import { getProviderRegistryEntry } from "../providers/registry";
|
|
32
|
-
import { readJsonRequestBody } from "./request-decompress";
|
|
32
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
33
33
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
34
34
|
import type { RequestLogContext } from "./request-log";
|
|
35
35
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
@@ -602,7 +602,7 @@ export async function handleImages(
|
|
|
602
602
|
): Promise<Response> {
|
|
603
603
|
let body: unknown;
|
|
604
604
|
try {
|
|
605
|
-
body = await readJsonRequestBody(req);
|
|
605
|
+
body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
606
606
|
} catch (err) {
|
|
607
607
|
return decodeRequestErrorResponse(err, "images");
|
|
608
608
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -63,7 +63,11 @@ import { runModelRenameStartupMigration } from "../providers/model-rename-startu
|
|
|
63
63
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
64
64
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
65
65
|
import type { StorageCleanupPolicy } from "../types";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
68
|
+
MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
69
|
+
resolveInboundBodyLimitBytes,
|
|
70
|
+
} from "./request-decompress";
|
|
67
71
|
import {
|
|
68
72
|
CodexAccountCooldownError,
|
|
69
73
|
cooldownErrorMessage,
|
|
@@ -1023,6 +1027,22 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1023
1027
|
let loopbackServer: Server<WsData> | null = null;
|
|
1024
1028
|
let managementIngressServer: Server<WsData> | null = null;
|
|
1025
1029
|
|
|
1030
|
+
// Resolved once, before any listener binds. The clamp is silent inside the resolver so it
|
|
1031
|
+
// stays pure and per-request cheap; the operator is told here instead, once, because a
|
|
1032
|
+
// config value that was quietly reduced is exactly the thing they would otherwise debug
|
|
1033
|
+
// against the wrong limit.
|
|
1034
|
+
const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
|
|
1035
|
+
const requestedInboundBodyLimit = config.maxInboundBodyBytes;
|
|
1036
|
+
if (requestedInboundBodyLimit !== undefined
|
|
1037
|
+
&& requestedInboundBodyLimit > 0
|
|
1038
|
+
&& requestedInboundBodyLimit !== inboundBodyLimitBytes) {
|
|
1039
|
+
console.warn(
|
|
1040
|
+
`[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range `
|
|
1041
|
+
+ `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
|
|
1042
|
+
+ `using ${inboundBodyLimitBytes} bytes.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1026
1046
|
type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
|
|
1027
1047
|
function ingressForServer(requestServer: Server<WsData>): ServerIngress {
|
|
1028
1048
|
if (requestServer === loopbackServer) return "unauthenticated-loopback";
|
|
@@ -1042,7 +1062,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
1042
1062
|
userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
|
|
1043
1063
|
const serveOptions = {
|
|
1044
1064
|
idleTimeout: 255,
|
|
1045
|
-
|
|
1065
|
+
// Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
|
|
1066
|
+
// with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
|
|
1067
|
+
// `maxInboundBodyBytes` edit needs a restart, which the config doc states.
|
|
1068
|
+
maxRequestBodySize: inboundBodyLimitBytes,
|
|
1046
1069
|
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
1047
1070
|
const ingress = ingressForServer(requestServer);
|
|
1048
1071
|
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
@@ -115,7 +115,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
115
115
|
}
|
|
116
116
|
const all = getRequestLogEntries();
|
|
117
117
|
const total = filteredRequestLogCount(all, url.searchParams);
|
|
118
|
-
|
|
118
|
+
// Not point-free: requestLogDto takes an options object second, and Array.map would pass the
|
|
119
|
+
// element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
|
|
120
|
+
// what /api/logs wants; /api/request-history opts out at its own call sites.
|
|
121
|
+
const logs = filterRequestLogs(all, url.searchParams).map(entry => requestLogDto(entry));
|
|
119
122
|
const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
|
|
120
123
|
return jsonResponse({
|
|
121
124
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -147,10 +147,25 @@ export async function listManagementModelRows(
|
|
|
147
147
|
};
|
|
148
148
|
});
|
|
149
149
|
const publicModels = uniqueCatalogModelsForPublicList(models);
|
|
150
|
+
// Custom rows below are REBUILT from config.customModels rather than spread from a
|
|
151
|
+
// CatalogModel, so every field gather computed for the same slug has to be carried across by
|
|
152
|
+
// hand. Without this a custom model whose provider is out of credit would be the one row on
|
|
153
|
+
// the page that never shows as inactive (#1711), because the gather-derived row it replaces
|
|
154
|
+
// is dropped by the slug dedup below.
|
|
155
|
+
const quotaInactiveByNamespaced = new Map(
|
|
156
|
+
publicModels
|
|
157
|
+
.filter(model => model.quotaInactiveReason !== undefined)
|
|
158
|
+
.map(model => [catalogModelSlug(model), model.quotaInactiveReason!] as const),
|
|
159
|
+
);
|
|
150
160
|
const comboNamespaced = new Set(
|
|
151
161
|
publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
|
|
152
162
|
);
|
|
153
|
-
const visibleCustomModels = customModels
|
|
163
|
+
const visibleCustomModels = customModels
|
|
164
|
+
.filter(model => !comboNamespaced.has(model.namespaced))
|
|
165
|
+
.map(model => {
|
|
166
|
+
const quotaInactiveReason = quotaInactiveByNamespaced.get(model.namespaced);
|
|
167
|
+
return quotaInactiveReason ? { ...model, quotaInactiveReason } : model;
|
|
168
|
+
});
|
|
154
169
|
// Custom metadata wins when a physical live/static row resolves to the same Codex-facing
|
|
155
170
|
// slug, while a combo keeps the same precedence it has in routing and /v1/models.
|
|
156
171
|
const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../../oauth";
|
|
26
26
|
import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
|
|
27
27
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
28
|
+
import { emailMaskingEnabled } from "../../lib/privacy";
|
|
28
29
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
29
30
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
30
31
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
@@ -233,7 +234,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
233
234
|
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
234
235
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
235
236
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
236
|
-
|
|
237
|
+
// Resolved here, at the request boundary that already holds the config, and passed down.
|
|
238
|
+
// getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
|
|
239
|
+
// consumes the already-projected status rather than redacting a second time.
|
|
240
|
+
const status = getLoginStatus(provider, emailMaskingEnabled(config));
|
|
237
241
|
return jsonResponse(status);
|
|
238
242
|
}
|
|
239
243
|
|
|
@@ -269,7 +273,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
269
273
|
} = await import("../../oauth/health");
|
|
270
274
|
const projectAccounts = () => {
|
|
271
275
|
const set = getAccountSet(provider);
|
|
272
|
-
const current = getLoginStatus(provider);
|
|
276
|
+
const current = getLoginStatus(provider, emailMaskingEnabled(config));
|
|
273
277
|
return {
|
|
274
278
|
activeAccountId: current.activeAccountId ?? null,
|
|
275
279
|
accounts: (current.accounts ?? []).map(summary => {
|