@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
getProviderRegistryEntry,
|
|
4
|
+
providerMatchesRegistryTransport,
|
|
5
|
+
type ProviderModelDiscoveryFilter,
|
|
6
|
+
type ProviderModelDiscoveryPredicate,
|
|
7
|
+
type ProviderModelDiscoveryScalar,
|
|
8
|
+
type ProviderModelDiscoverySpec,
|
|
9
|
+
} from "./registry";
|
|
10
|
+
|
|
11
|
+
/** Hard process-wide limits. Registry entries may lower, but never raise, these ceilings. */
|
|
12
|
+
export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
export const MODEL_DISCOVERY_MAX_MODELS = 2_000;
|
|
14
|
+
export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024;
|
|
15
|
+
const MODEL_DISCOVERY_MAX_FILTER_VALUES = 256;
|
|
16
|
+
const MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH = 1_024;
|
|
17
|
+
const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
18
|
+
|
|
19
|
+
export interface ResolvedProviderModelDiscovery {
|
|
20
|
+
spec?: ProviderModelDiscoverySpec;
|
|
21
|
+
maxResponseBytes: number;
|
|
22
|
+
maxModels: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ProviderModelsApiItem = Record<string, unknown> & { id: string };
|
|
26
|
+
|
|
27
|
+
export type ModelDiscoveryResponseFailure =
|
|
28
|
+
| "response_too_large"
|
|
29
|
+
| "invalid_json"
|
|
30
|
+
| "invalid_shape"
|
|
31
|
+
| "too_many_models";
|
|
32
|
+
|
|
33
|
+
export type BoundedDiscoveryJsonResult =
|
|
34
|
+
| { ok: true; value: unknown }
|
|
35
|
+
| { ok: false; reason: "response_too_large" | "invalid_json" };
|
|
36
|
+
|
|
37
|
+
export type ProviderModelItemsResult =
|
|
38
|
+
| { ok: true; items: ProviderModelsApiItem[]; rawCount: number }
|
|
39
|
+
| { ok: false; reason: "invalid_shape" | "too_many_models" };
|
|
40
|
+
|
|
41
|
+
export type ModelEnvelopeRowsResult =
|
|
42
|
+
| { ok: true; rows: unknown[] }
|
|
43
|
+
| { ok: false; reason: "invalid_shape" | "too_many_models" };
|
|
44
|
+
|
|
45
|
+
function positiveIntegerAtMost(value: number | undefined, hardLimit: number): number {
|
|
46
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return hardLimit;
|
|
47
|
+
return Math.min(Math.floor(value), hardLimit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function discoveryPredicateError(predicate: ProviderModelDiscoveryPredicate): string | null {
|
|
51
|
+
if (!Array.isArray(predicate.path) || predicate.path.length === 0 || predicate.path.length > 8) {
|
|
52
|
+
return "predicate path must contain 1-8 segments";
|
|
53
|
+
}
|
|
54
|
+
if (predicate.path.some(segment => typeof segment !== "string" || !segment.trim() || segment.length > 64)) {
|
|
55
|
+
return "predicate path segments must be nonblank strings up to 64 characters";
|
|
56
|
+
}
|
|
57
|
+
const values = "equalsAny" in predicate
|
|
58
|
+
? predicate.equalsAny
|
|
59
|
+
: "containsAny" in predicate
|
|
60
|
+
? predicate.containsAny
|
|
61
|
+
: predicate.containsAll;
|
|
62
|
+
if (!Array.isArray(values) || values.length === 0 || values.length > 32) {
|
|
63
|
+
return "predicate values must contain 1-32 scalars";
|
|
64
|
+
}
|
|
65
|
+
if (values.some(value => (
|
|
66
|
+
(typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
67
|
+
|| (typeof value === "string" && (!value.trim() || value.length > 128))
|
|
68
|
+
|| (typeof value === "number" && !Number.isFinite(value))
|
|
69
|
+
))) {
|
|
70
|
+
return "predicate values must be finite booleans/numbers or nonblank strings up to 128 characters";
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Static registry validation used by parity tests; discovery metadata never comes from config. */
|
|
76
|
+
export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec): string | null {
|
|
77
|
+
if (spec.url && spec.path) return "url and path are mutually exclusive";
|
|
78
|
+
if (spec.url !== undefined) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(spec.url);
|
|
81
|
+
if (parsed.protocol !== "https:") return "absolute discovery url must use https";
|
|
82
|
+
if (parsed.username || parsed.password || parsed.hash) return "absolute discovery url must not contain credentials or a fragment";
|
|
83
|
+
} catch {
|
|
84
|
+
return "absolute discovery url must be valid";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (spec.path !== undefined) {
|
|
88
|
+
const path = spec.path.trim();
|
|
89
|
+
if (!path || path.length > 512) return "discovery path must be 1-512 characters";
|
|
90
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || path.includes("?") || path.includes("#")) {
|
|
91
|
+
return "discovery path must be a query-free relative/origin path";
|
|
92
|
+
}
|
|
93
|
+
if (path.includes("\\")) return "discovery path must use forward slashes";
|
|
94
|
+
if (path.split("/").some(segment => segment.replace(/%2e/gi, ".") === "..")) {
|
|
95
|
+
return "discovery path must not contain parent-directory segments";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const queryEntries = Object.entries(spec.query ?? {});
|
|
99
|
+
if (queryEntries.length > 32) return "discovery query may contain at most 32 entries";
|
|
100
|
+
if (queryEntries.some(([key, value]) => !key.trim() || key.length > 128 || typeof value !== "string" || value.length > 512)) {
|
|
101
|
+
return "discovery query keys/values exceed their bounds";
|
|
102
|
+
}
|
|
103
|
+
for (const [field, value, hardLimit] of [
|
|
104
|
+
["maxResponseBytes", spec.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES],
|
|
105
|
+
["maxModels", spec.maxModels, MODEL_DISCOVERY_MAX_MODELS],
|
|
106
|
+
] as const) {
|
|
107
|
+
if (value !== undefined && (!Number.isInteger(value) || value <= 0 || value > hardLimit)) {
|
|
108
|
+
return `${field} must be a positive integer no greater than ${hardLimit}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const [group, predicates] of Object.entries(spec.filter ?? {})) {
|
|
112
|
+
if (!Array.isArray(predicates) || predicates.length === 0 || predicates.length > 32) {
|
|
113
|
+
return `${group} must contain 1-32 predicates`;
|
|
114
|
+
}
|
|
115
|
+
for (const predicate of predicates) {
|
|
116
|
+
const error = discoveryPredicateError(predicate);
|
|
117
|
+
if (error) return `${group}: ${error}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveProviderModelDiscovery(
|
|
124
|
+
providerName: string,
|
|
125
|
+
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
|
|
126
|
+
): ResolvedProviderModelDiscovery {
|
|
127
|
+
const entry = providerMatchesRegistryTransport(providerName, provider)
|
|
128
|
+
? getProviderRegistryEntry(providerName)
|
|
129
|
+
: undefined;
|
|
130
|
+
const spec = entry?.modelDiscovery;
|
|
131
|
+
return {
|
|
132
|
+
...(spec ? { spec } : {}),
|
|
133
|
+
maxResponseBytes: positiveIntegerAtMost(spec?.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES),
|
|
134
|
+
maxModels: positiveIntegerAtMost(spec?.maxModels, MODEL_DISCOVERY_MAX_MODELS),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendDiscoveryQuery(url: URL, query: Readonly<Record<string, string>> | undefined): URL {
|
|
139
|
+
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, value);
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */
|
|
144
|
+
export function resolveProviderModelDiscoveryUrl(
|
|
145
|
+
providerName: string,
|
|
146
|
+
configuredProvider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
|
|
147
|
+
effectiveBaseUrl: string,
|
|
148
|
+
defaultUrl: string,
|
|
149
|
+
): string {
|
|
150
|
+
const { spec } = resolveProviderModelDiscovery(providerName, configuredProvider);
|
|
151
|
+
if (!spec) return defaultUrl;
|
|
152
|
+
|
|
153
|
+
let resolved: URL;
|
|
154
|
+
if (spec.url) {
|
|
155
|
+
resolved = new URL(spec.url);
|
|
156
|
+
} else if (spec.path) {
|
|
157
|
+
const base = new URL(effectiveBaseUrl.endsWith("/") ? effectiveBaseUrl : `${effectiveBaseUrl}/`);
|
|
158
|
+
resolved = spec.path.startsWith("/")
|
|
159
|
+
? new URL(spec.path, base.origin)
|
|
160
|
+
: new URL(spec.path, base);
|
|
161
|
+
} else {
|
|
162
|
+
resolved = new URL(defaultUrl);
|
|
163
|
+
}
|
|
164
|
+
return appendDiscoveryQuery(resolved, spec.query).toString();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason: unknown): void {
|
|
168
|
+
try {
|
|
169
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
170
|
+
} catch {
|
|
171
|
+
// A non-conforming stream may throw synchronously from cancel().
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read a discovery response under a strict byte ceiling before JSON.parse can allocate freely. */
|
|
176
|
+
export async function readBoundedDiscoveryJson(
|
|
177
|
+
response: Response,
|
|
178
|
+
maxResponseBytes: number,
|
|
179
|
+
): Promise<BoundedDiscoveryJsonResult> {
|
|
180
|
+
const limit = positiveIntegerAtMost(maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES);
|
|
181
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
182
|
+
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
183
|
+
try {
|
|
184
|
+
void response.body?.cancel(new DOMException("Model discovery response is too large", "QuotaExceededError"))
|
|
185
|
+
.catch(() => undefined);
|
|
186
|
+
} catch {
|
|
187
|
+
// Best-effort cancellation only.
|
|
188
|
+
}
|
|
189
|
+
return { ok: false, reason: "response_too_large" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!response.body) return { ok: false, reason: "invalid_json" };
|
|
193
|
+
const reader = response.body.getReader();
|
|
194
|
+
const chunks: Uint8Array[] = [];
|
|
195
|
+
let total = 0;
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
const { value, done } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
if (!value || value.byteLength === 0) continue;
|
|
201
|
+
if (value.byteLength > limit - total) {
|
|
202
|
+
cancelWithoutWaiting(
|
|
203
|
+
reader,
|
|
204
|
+
new DOMException("Model discovery response is too large", "QuotaExceededError"),
|
|
205
|
+
);
|
|
206
|
+
return { ok: false, reason: "response_too_large" };
|
|
207
|
+
}
|
|
208
|
+
chunks.push(value);
|
|
209
|
+
total += value.byteLength;
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
reader.releaseLock();
|
|
214
|
+
} catch {
|
|
215
|
+
// Cancellation may keep the lock briefly.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const bytes = new Uint8Array(total);
|
|
220
|
+
let offset = 0;
|
|
221
|
+
for (const chunk of chunks) {
|
|
222
|
+
bytes.set(chunk, offset);
|
|
223
|
+
offset += chunk.byteLength;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
227
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
228
|
+
} catch {
|
|
229
|
+
return { ok: false, reason: "invalid_json" };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function valueAtPath(item: Record<string, unknown>, path: readonly string[]): unknown {
|
|
234
|
+
let current: unknown = item;
|
|
235
|
+
for (const segment of path) {
|
|
236
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return undefined;
|
|
237
|
+
current = (current as Record<string, unknown>)[segment];
|
|
238
|
+
}
|
|
239
|
+
return current;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function comparableScalar(value: unknown, caseInsensitive: boolean): ProviderModelDiscoveryScalar | undefined {
|
|
243
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined;
|
|
244
|
+
if (typeof value === "string" && value.length > MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH) return undefined;
|
|
245
|
+
return caseInsensitive && typeof value === "string" ? value.toLowerCase() : value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function comparableNeedles(
|
|
249
|
+
values: readonly ProviderModelDiscoveryScalar[],
|
|
250
|
+
caseInsensitive: boolean,
|
|
251
|
+
): ProviderModelDiscoveryScalar[] {
|
|
252
|
+
return values.map(value => caseInsensitive && typeof value === "string" ? value.toLowerCase() : value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function predicateMatches(item: ProviderModelsApiItem, predicate: ProviderModelDiscoveryPredicate): boolean {
|
|
256
|
+
const caseInsensitive = predicate.caseInsensitive === true;
|
|
257
|
+
const raw = valueAtPath(item, predicate.path);
|
|
258
|
+
if ("equalsAny" in predicate) {
|
|
259
|
+
const value = comparableScalar(raw, caseInsensitive);
|
|
260
|
+
return value !== undefined && comparableNeedles(predicate.equalsAny, caseInsensitive).includes(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const collection = Array.isArray(raw);
|
|
264
|
+
const values: ProviderModelDiscoveryScalar[] = [];
|
|
265
|
+
if (collection) {
|
|
266
|
+
for (let i = 0; i < raw.length && i < MODEL_DISCOVERY_MAX_FILTER_VALUES; i += 1) {
|
|
267
|
+
const value = comparableScalar(raw[i], caseInsensitive);
|
|
268
|
+
if (value !== undefined) values.push(value);
|
|
269
|
+
}
|
|
270
|
+
} else if (typeof raw === "string") {
|
|
271
|
+
values.push(caseInsensitive ? raw.toLowerCase() : raw);
|
|
272
|
+
}
|
|
273
|
+
const needles = comparableNeedles(
|
|
274
|
+
"containsAny" in predicate ? predicate.containsAny : predicate.containsAll,
|
|
275
|
+
caseInsensitive,
|
|
276
|
+
);
|
|
277
|
+
if ("containsAny" in predicate) {
|
|
278
|
+
return needles.some(needle => values.some(value => (
|
|
279
|
+
!collection && typeof value === "string" && typeof needle === "string" ? value.includes(needle) : value === needle
|
|
280
|
+
)));
|
|
281
|
+
}
|
|
282
|
+
return needles.every(needle => values.some(value => (
|
|
283
|
+
!collection && typeof value === "string" && typeof needle === "string" ? value.includes(needle) : value === needle
|
|
284
|
+
)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function providerModelMatchesDiscoveryFilter(
|
|
288
|
+
item: ProviderModelsApiItem,
|
|
289
|
+
filter: ProviderModelDiscoveryFilter | undefined,
|
|
290
|
+
): boolean {
|
|
291
|
+
if (!filter) return true;
|
|
292
|
+
if (filter.allOf && !filter.allOf.every(predicate => predicateMatches(item, predicate))) return false;
|
|
293
|
+
if (filter.anyOf && filter.anyOf.length > 0 && !filter.anyOf.some(predicate => predicateMatches(item, predicate))) return false;
|
|
294
|
+
if (filter.noneOf?.some(predicate => predicateMatches(item, predicate))) return false;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Extract one allowlisted array envelope while enforcing the raw-row ceiling. */
|
|
299
|
+
export function extractModelEnvelopeRows(
|
|
300
|
+
value: unknown,
|
|
301
|
+
maxModels: number,
|
|
302
|
+
envelopeKeys: readonly string[],
|
|
303
|
+
): ModelEnvelopeRowsResult {
|
|
304
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
305
|
+
return { ok: false, reason: "invalid_shape" };
|
|
306
|
+
}
|
|
307
|
+
const record = value as Record<string, unknown>;
|
|
308
|
+
const rows = envelopeKeys.map(key => record[key]).find(Array.isArray);
|
|
309
|
+
if (!rows) return { ok: false, reason: "invalid_shape" };
|
|
310
|
+
const limit = positiveIntegerAtMost(maxModels, MODEL_DISCOVERY_MAX_MODELS);
|
|
311
|
+
if (rows.length > limit) return { ok: false, reason: "too_many_models" };
|
|
312
|
+
return { ok: true, rows };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Validate, bound, deduplicate, and declaratively filter OpenAI `{data:[...]}` or top-level arrays (Together `#617`). */
|
|
316
|
+
export function extractProviderModelItems(
|
|
317
|
+
value: unknown,
|
|
318
|
+
discovery: ResolvedProviderModelDiscovery,
|
|
319
|
+
): ProviderModelItemsResult {
|
|
320
|
+
const limit = positiveIntegerAtMost(discovery.maxModels, MODEL_DISCOVERY_MAX_MODELS);
|
|
321
|
+
let data: unknown[];
|
|
322
|
+
if (Array.isArray(value)) {
|
|
323
|
+
// Together-style top-level /models arrays. Catalog discovery must not treat a stray
|
|
324
|
+
// `models` key on openai-chat responses as valid — only `data` envelopes or top-level arrays.
|
|
325
|
+
if (value.length > limit) return { ok: false, reason: "too_many_models" };
|
|
326
|
+
data = value;
|
|
327
|
+
} else {
|
|
328
|
+
const envelope = extractModelEnvelopeRows(value, discovery.maxModels, ["data"]);
|
|
329
|
+
if (!envelope.ok) return envelope;
|
|
330
|
+
data = envelope.rows;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const items: ProviderModelsApiItem[] = [];
|
|
334
|
+
const seen = new Set<string>();
|
|
335
|
+
for (const raw of data) {
|
|
336
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
337
|
+
return { ok: false, reason: "invalid_shape" };
|
|
338
|
+
}
|
|
339
|
+
const id = (raw as { id?: unknown }).id;
|
|
340
|
+
if (typeof id !== "string") return { ok: false, reason: "invalid_shape" };
|
|
341
|
+
const normalizedId = id.trim();
|
|
342
|
+
if (
|
|
343
|
+
!normalizedId
|
|
344
|
+
|| normalizedId !== id
|
|
345
|
+
|| normalizedId.length > MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH
|
|
346
|
+
|| MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalizedId)
|
|
347
|
+
) {
|
|
348
|
+
return { ok: false, reason: "invalid_shape" };
|
|
349
|
+
}
|
|
350
|
+
const item = raw as ProviderModelsApiItem;
|
|
351
|
+
if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(normalizedId)) continue;
|
|
352
|
+
seen.add(normalizedId);
|
|
353
|
+
items.push(item);
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, items, rawCount: data.length };
|
|
356
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
|
|
2
2
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
3
3
|
import { resolveEnvValue } from "../config";
|
|
4
|
-
import { getValidAccessToken } from "../oauth";
|
|
5
|
-
import { getCredential } from "../oauth/store";
|
|
4
|
+
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
5
|
+
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
6
6
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
7
7
|
import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
|
|
8
8
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
9
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
10
10
|
|
|
11
|
+
/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
|
|
12
|
+
const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
13
|
+
|
|
11
14
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
12
15
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
13
16
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
@@ -188,43 +191,244 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number
|
|
|
188
191
|
return { percent, resetAt };
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
|
|
195
|
+
const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
|
|
196
|
+
|
|
197
|
+
async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
198
|
+
const joinable = anthropicUsageInflight.get(accessToken);
|
|
199
|
+
if (joinable) return joinable;
|
|
200
|
+
|
|
201
|
+
const probe = (async (): Promise<ProviderQuota | null> => {
|
|
202
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
203
|
+
headers: {
|
|
204
|
+
Accept: "application/json, text/plain, */*",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
"User-Agent": "claude-cli/2.1.63 (external, cli)",
|
|
207
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
208
|
+
Authorization: `Bearer ${accessToken}`,
|
|
209
|
+
},
|
|
210
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) return null;
|
|
213
|
+
const body = asRecord(await response.json().catch(() => null));
|
|
214
|
+
if (!body) return null;
|
|
215
|
+
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
216
|
+
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
217
|
+
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
218
|
+
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
219
|
+
const customWindows: ProviderQuotaWindow[] = [];
|
|
220
|
+
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
221
|
+
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
222
|
+
const quota: ProviderQuota = {
|
|
223
|
+
// Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
|
|
224
|
+
// rows: report it in the canonical fields so the dashboard renders it with the standard
|
|
225
|
+
// "5-hour limit" label and ordering instead of as a generic extra window.
|
|
226
|
+
...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
|
|
227
|
+
...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
228
|
+
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
229
|
+
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
230
|
+
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
231
|
+
updatedAt: Date.now(),
|
|
232
|
+
};
|
|
233
|
+
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
234
|
+
return hasQuotaRows(quota) ? quota : null;
|
|
235
|
+
})().finally(() => {
|
|
236
|
+
if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
|
|
237
|
+
});
|
|
238
|
+
anthropicUsageInflight.set(accessToken, probe);
|
|
239
|
+
return probe;
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
243
|
+
// Capture the account we intend to probe before awaiting — a mid-flight active
|
|
244
|
+
// switch must not seed the wrong account's cache with this response.
|
|
245
|
+
const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
|
|
192
246
|
let accessToken: string;
|
|
193
247
|
try {
|
|
194
248
|
accessToken = await getValidAccessToken("anthropic");
|
|
195
249
|
} catch {
|
|
196
250
|
return null;
|
|
197
251
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
if (!response.ok) return null;
|
|
209
|
-
const body = asRecord(await response.json().catch(() => null));
|
|
210
|
-
if (!body) return null;
|
|
211
|
-
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
212
|
-
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
213
|
-
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
214
|
-
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
215
|
-
const customWindows: ProviderQuotaWindow[] = [];
|
|
216
|
-
if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) });
|
|
217
|
-
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
218
|
-
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
219
|
-
const quota: ProviderQuota = {
|
|
220
|
-
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
221
|
-
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
222
|
-
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
223
|
-
updatedAt: Date.now(),
|
|
224
|
-
};
|
|
252
|
+
const quota = await fetchAnthropicUsageQuota(accessToken);
|
|
253
|
+
if (!quota) return null;
|
|
254
|
+
// Share the active-account probe with the per-account cache so Providers-page
|
|
255
|
+
// loads do not double-hit Anthropic's rate-limited usage endpoint.
|
|
256
|
+
if (probedAccountId) {
|
|
257
|
+
const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
|
|
258
|
+
if (stillOwnsToken) {
|
|
259
|
+
accountQuotaCache.set(accountCacheKey("anthropic", probedAccountId), { ts: Date.now(), quota });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
225
262
|
return report(provider, "anthropic:oauth-usage", quota);
|
|
226
263
|
}
|
|
227
264
|
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Per-account quota (multiauth)
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its
|
|
271
|
+
* own bearer token — the active-account selection and the local usage log are irrelevant here.
|
|
272
|
+
* Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a
|
|
273
|
+
* per-account TTL so N accounts cost at most N upstream calls per window.
|
|
274
|
+
*
|
|
275
|
+
* The TTL is deliberately longer than the provider-level one: this path multiplies by account
|
|
276
|
+
* count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing).
|
|
277
|
+
*/
|
|
278
|
+
const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
|
|
279
|
+
type AccountQuotaCacheEntry = {
|
|
280
|
+
ts: number;
|
|
281
|
+
quota: ProviderQuota | null;
|
|
282
|
+
/** Last probe failed (429 / network / expired login); still may hold last-good quota. */
|
|
283
|
+
unavailable?: true;
|
|
284
|
+
};
|
|
285
|
+
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
286
|
+
const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
|
|
287
|
+
|
|
288
|
+
export interface ProviderAccountQuota {
|
|
289
|
+
accountId: string;
|
|
290
|
+
quota: ProviderQuota | null;
|
|
291
|
+
/** Set when the probe could not reach upstream (expired login, 429, network). */
|
|
292
|
+
unavailable?: true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
|
|
296
|
+
export function supportsPerAccountQuota(provider: string): boolean {
|
|
297
|
+
return provider === "anthropic";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function accountCacheKey(provider: string, accountId: string): string {
|
|
301
|
+
return `${provider}\u0000${accountId}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Synchronous last-good per-account quota read for routing. Never probes the network.
|
|
306
|
+
* Returns null when nothing is cached (or the cached row has no bars).
|
|
307
|
+
*/
|
|
308
|
+
export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
|
|
309
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
|
|
310
|
+
return entry?.quota ?? null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Test-only: seed or clear the per-account quota cache without probing upstream. */
|
|
314
|
+
export function setCachedProviderAccountQuotaForTests(
|
|
315
|
+
provider: string,
|
|
316
|
+
accountId: string,
|
|
317
|
+
quota: ProviderQuota | null,
|
|
318
|
+
): void {
|
|
319
|
+
const key = accountCacheKey(provider, accountId);
|
|
320
|
+
if (quota === null) {
|
|
321
|
+
accountQuotaCache.delete(key);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Drop cached per-account rows (all, or just one provider's). */
|
|
328
|
+
export function clearAccountQuotaCache(provider?: string): void {
|
|
329
|
+
if (!provider) {
|
|
330
|
+
accountQuotaCache.clear();
|
|
331
|
+
accountQuotaInflight.clear();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const prefix = `${provider}\u0000`;
|
|
335
|
+
for (const key of [...accountQuotaCache.keys()]) {
|
|
336
|
+
if (key.startsWith(prefix)) accountQuotaCache.delete(key);
|
|
337
|
+
}
|
|
338
|
+
// Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
|
|
339
|
+
for (const key of [...accountQuotaInflight.keys()]) {
|
|
340
|
+
if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a bearer for quota probing without silently adopting a newer global
|
|
346
|
+
* Claude CLI credential into a background multiauth slot.
|
|
347
|
+
*
|
|
348
|
+
* - Fresh stored access → use as-is (no refresh).
|
|
349
|
+
* - Active account with expired access → normal refresh path.
|
|
350
|
+
* - Background `local-cli` with expired access → fail closed (unavailable):
|
|
351
|
+
* `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity.
|
|
352
|
+
* - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh;
|
|
353
|
+
* Anthropic's lock only adopts disk credentials for `local-cli` rows.
|
|
354
|
+
*/
|
|
355
|
+
async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
|
|
356
|
+
const stored = getAccountCredential(provider, accountId);
|
|
357
|
+
if (!stored) throw new Error("account credential missing");
|
|
358
|
+
if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
|
|
359
|
+
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
360
|
+
if (activeId !== accountId && stored.source === "local-cli") {
|
|
361
|
+
throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe");
|
|
362
|
+
}
|
|
363
|
+
return getValidAccessTokenForAccount(provider, accountId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function fetchAccountQuota(
|
|
367
|
+
provider: string,
|
|
368
|
+
accountId: string,
|
|
369
|
+
forceRefresh: boolean,
|
|
370
|
+
): Promise<AccountQuotaCacheEntry> {
|
|
371
|
+
const key = accountCacheKey(provider, accountId);
|
|
372
|
+
const cached = accountQuotaCache.get(key);
|
|
373
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached;
|
|
374
|
+
const joinable = accountQuotaInflight.get(key);
|
|
375
|
+
if (joinable) return joinable;
|
|
376
|
+
|
|
377
|
+
const probe = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
378
|
+
try {
|
|
379
|
+
const token = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
380
|
+
const quota = await fetchAnthropicUsageQuota(token);
|
|
381
|
+
if (!quota) {
|
|
382
|
+
// Preserve last-good bars and mark unavailable; advance TTL so failures
|
|
383
|
+
// negative-cache instead of re-probing on every GUI poll.
|
|
384
|
+
const entry: AccountQuotaCacheEntry = {
|
|
385
|
+
ts: Date.now(),
|
|
386
|
+
quota: cached?.quota ?? null,
|
|
387
|
+
unavailable: true,
|
|
388
|
+
};
|
|
389
|
+
accountQuotaCache.set(key, entry);
|
|
390
|
+
return entry;
|
|
391
|
+
}
|
|
392
|
+
const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota };
|
|
393
|
+
accountQuotaCache.set(key, entry);
|
|
394
|
+
return entry;
|
|
395
|
+
} catch {
|
|
396
|
+
const entry: AccountQuotaCacheEntry = {
|
|
397
|
+
ts: Date.now(),
|
|
398
|
+
quota: cached?.quota ?? null,
|
|
399
|
+
unavailable: true,
|
|
400
|
+
};
|
|
401
|
+
accountQuotaCache.set(key, entry);
|
|
402
|
+
return entry;
|
|
403
|
+
}
|
|
404
|
+
})().finally(() => {
|
|
405
|
+
if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
|
|
406
|
+
});
|
|
407
|
+
accountQuotaInflight.set(key, probe);
|
|
408
|
+
return probe;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
|
|
413
|
+
* single failing account never blocks the others.
|
|
414
|
+
*/
|
|
415
|
+
export async function fetchProviderAccountQuotas(
|
|
416
|
+
provider: string,
|
|
417
|
+
forceRefresh = false,
|
|
418
|
+
): Promise<ProviderAccountQuota[]> {
|
|
419
|
+
if (!supportsPerAccountQuota(provider)) return [];
|
|
420
|
+
const set = getAccountSet(provider);
|
|
421
|
+
if (!set) return [];
|
|
422
|
+
return await Promise.all(set.accounts.map(async account => {
|
|
423
|
+
const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
|
|
424
|
+
return {
|
|
425
|
+
accountId: account.id,
|
|
426
|
+
quota: entry.quota,
|
|
427
|
+
...(entry.unavailable ? { unavailable: true as const } : {}),
|
|
428
|
+
};
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
228
432
|
function normalizedBaseUrl(value: string): string | null {
|
|
229
433
|
try {
|
|
230
434
|
const url = new URL(value);
|