Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs
# Conflicts: # packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx # packages/ui/src/lib/i18n/messages/de.ts # packages/ui/src/lib/i18n/messages/en.ts # packages/ui/src/lib/i18n/messages/es.ts # packages/ui/src/lib/i18n/messages/fr.ts # packages/ui/src/lib/i18n/messages/ja.ts # packages/ui/src/lib/i18n/messages/ko.ts # packages/ui/src/lib/i18n/messages/pl.ts # packages/ui/src/lib/i18n/messages/pt-BR.ts # packages/ui/src/lib/i18n/messages/uk.ts # packages/ui/src/lib/i18n/messages/zh-CN.ts # packages/ui/src/lib/i18n/messages/zh-TW.ts # packages/web/server/lib/opencode/settings-helpers.js
This commit is contained in:
@@ -40,6 +40,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- active-directory selection across multi-root workspaces
|
||||
- dropped-file parsing and attachment reading
|
||||
- models metadata fetch helper
|
||||
- Read paths are authorized in the requested workspace path space before symlink resolution, matching the web runtime; directly requested outside-workspace paths remain denied.
|
||||
|
||||
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
|
||||
|
||||
@@ -58,6 +59,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
||||
- `bridge-config-runtime.ts`
|
||||
- Config and skills message handlers (`api:config/*`).
|
||||
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
||||
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file.
|
||||
|
||||
- `bridge-settings-runtime.ts`
|
||||
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
||||
|
||||
@@ -583,12 +583,8 @@ export const resolveFileReadPath = async (targetPath: string, requestedRoot?: st
|
||||
}
|
||||
|
||||
try {
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fs.promises.realpath(resolved),
|
||||
fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)),
|
||||
]);
|
||||
|
||||
if (!isPathInside(canonicalPath, canonicalBase)) {
|
||||
const canonicalPath = await fs.promises.realpath(resolved);
|
||||
if (!isPathInside(resolved, path.resolve(baseRoot))) {
|
||||
return { ok: false, status: 403, error: 'Access to file denied' };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
type CommandCodeCredits = {
|
||||
credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number };
|
||||
windowLimits?: {
|
||||
fiveHour?: { used?: number; cap?: number; resetAt?: number };
|
||||
weekly?: { used?: number; cap?: number; resetAt?: number };
|
||||
};
|
||||
};
|
||||
|
||||
type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string };
|
||||
|
||||
const toWindow = (data: WindowData) => ({
|
||||
usedPercent: data.usedPercent,
|
||||
remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent),
|
||||
windowSeconds: data.windowSeconds,
|
||||
resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)),
|
||||
resetAt: data.resetAt,
|
||||
resetAtFormatted: null,
|
||||
resetAfterFormatted: null,
|
||||
valueLabel: data.valueLabel,
|
||||
});
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100);
|
||||
|
||||
const parseCredits = (value: unknown): CommandCodeCredits | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const payload = value as CommandCodeCredits;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const parseOrgId = (value: unknown): string | null | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const org = (value as { org?: { id?: unknown } }).org;
|
||||
return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null;
|
||||
};
|
||||
|
||||
const parseCommandCodeCredits = (payload: CommandCodeCredits) => {
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) {
|
||||
if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) });
|
||||
}
|
||||
for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) {
|
||||
if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue;
|
||||
const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null;
|
||||
windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` });
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
const requestJson = async (path: string, apiKey: string): Promise<unknown> => {
|
||||
const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
|
||||
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
export const fetchCommandCodeUsage = async (apiKey: string) => {
|
||||
const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey));
|
||||
if (orgId === undefined) throw new Error('Command Code account could not be determined');
|
||||
const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits';
|
||||
const payload = parseCredits(await requestJson(creditsPath, apiKey));
|
||||
if (!payload) throw new Error('Command Code usage data could not be parsed');
|
||||
const windows = parseCommandCodeCredits(payload);
|
||||
if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { listPluginEntries, updateMcpConfig } from './opencodeConfig';
|
||||
|
||||
const PARTIAL_PARSE_CONFIG = [
|
||||
'{',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' plugin: ["opencode-see-image"],',
|
||||
' mcp: {',
|
||||
' openproject: {',
|
||||
' type: "remote",',
|
||||
' url: "https://openproject.example.com/mcp",',
|
||||
' enabled: true',
|
||||
' }',
|
||||
' },',
|
||||
' provider: {',
|
||||
' "ollama-cloud": {',
|
||||
' npm: "@ai-sdk/openai-compatible",',
|
||||
' name: "Ollama Cloud"',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const VALID_CONFIG = [
|
||||
'{',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' "plugin": ["opencode-see-image"],',
|
||||
' "mcp": {',
|
||||
' "openproject": {',
|
||||
' "type": "remote",',
|
||||
' "url": "https://openproject.example.com/mcp",',
|
||||
' "enabled": true',
|
||||
' }',
|
||||
' },',
|
||||
' "provider": {',
|
||||
' "ollama-cloud": {',
|
||||
' "npm": "@ai-sdk/openai-compatible",',
|
||||
' "name": "Ollama Cloud"',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const isInvalidJsoncError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error) || !/cannot be loaded safely/.test(error.message)) {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the config layer throws Error instances carrying the coded `code` field.
|
||||
return (error as Error & { code?: string }).code === 'INVALID_JSONC';
|
||||
};
|
||||
|
||||
describe('opencodeConfig JSONC parse safety (issue #2923)', () => {
|
||||
let tempDir: string;
|
||||
let previousOpenCodeConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-config-parse-'));
|
||||
previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
||||
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('refuses MCP updates that would overwrite a partial-parse config', () => {
|
||||
const configPath = path.join(tempDir, 'opencode.jsonc');
|
||||
fs.writeFileSync(configPath, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('preserves unrelated keys when updating a valid MCP config', () => {
|
||||
const configPath = path.join(tempDir, 'opencode.jsonc');
|
||||
fs.writeFileSync(configPath, VALID_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
updateMcpConfig('openproject', { enabled: false });
|
||||
|
||||
const rewritten = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.deepEqual(rewritten.plugin, ['opencode-see-image']);
|
||||
assert.equal(rewritten.provider['ollama-cloud'].name, 'Ollama Cloud');
|
||||
assert.equal(rewritten.mcp.openproject.enabled, false);
|
||||
assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG);
|
||||
});
|
||||
|
||||
test('returns an empty object for a comment-only config file', () => {
|
||||
const configPath = path.join(tempDir, 'comments.jsonc');
|
||||
fs.writeFileSync(configPath, '// placeholder\n/* still empty */\n', 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.deepEqual(listPluginEntries(), []);
|
||||
});
|
||||
|
||||
test('refuses MCP updates against content that yields no JSON value at all', () => {
|
||||
const configPath = path.join(tempDir, 'yamlish.jsonc');
|
||||
const contents = 'mcp:\n openproject:\n type: remote\n';
|
||||
fs.writeFileSync(configPath, contents, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf8'), contents);
|
||||
assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('lists custom-layer plugins when a project layer is unparseable', () => {
|
||||
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||
fs.writeFileSync(customPath, VALID_CONFIG, 'utf8');
|
||||
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||
fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
const specs = listPluginEntries(projectDir).map((entry) => entry.spec);
|
||||
assert.deepEqual(specs, ['opencode-see-image']);
|
||||
assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('keeps a valid custom layer writable when a project layer is unparseable', () => {
|
||||
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||
fs.writeFileSync(customPath, VALID_CONFIG, 'utf8');
|
||||
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||
fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
updateMcpConfig('openproject', { enabled: false }, projectDir);
|
||||
|
||||
const rewritten = JSON.parse(fs.readFileSync(customPath, 'utf8'));
|
||||
assert.deepEqual(rewritten.plugin, ['opencode-see-image']);
|
||||
assert.equal(rewritten.mcp.openproject.enabled, false);
|
||||
assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import yaml from 'yaml';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
|
||||
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||
@@ -554,12 +554,46 @@ const getPrimaryUserConfigPath = (userPaths: string[]): string => {
|
||||
return CONFIG_FILE;
|
||||
};
|
||||
|
||||
const INVALID_JSONC = 'INVALID_JSONC';
|
||||
|
||||
const formatJsoncParseError = (filePath: string, errors: ParseError[]): string => {
|
||||
const first = errors.length > 0 ? errors[0] : null;
|
||||
const location = first && Number.isFinite(first.offset)
|
||||
? ` (${printParseErrorCode(first.error)} at offset ${first.offset})`
|
||||
: '';
|
||||
return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`;
|
||||
};
|
||||
|
||||
const isInvalidJsoncError = (error: unknown): error is Error & { code: string } =>
|
||||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === INVALID_JSONC);
|
||||
|
||||
// Comment-only / whitespace-only files parse to undefined with nothing but
|
||||
// ValueExpected. Any other error means real content we failed to understand
|
||||
// (YAML, plain text, a stray leading token), which must not read as empty.
|
||||
const isCommentOnlyParse = (parsed: unknown, errors: ParseError[]): boolean =>
|
||||
parsed === undefined
|
||||
&& errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected');
|
||||
|
||||
const parseConfigObject = (content: string, filePath: string): Record<string, unknown> => {
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
|
||||
if (isCommentOnlyParse(parsed, errors)) {
|
||||
return {};
|
||||
}
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw codedError(formatJsoncParseError(filePath, errors), INVALID_JSONC);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
|
||||
if (!filePath || !fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const normalized = content.trim();
|
||||
if (!normalized) return {};
|
||||
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
|
||||
// Refuse partial jsonc-parser trees. Ignoring errors previously let mutations
|
||||
// rewrite a truncated object (often only `$schema`) over the full config.
|
||||
return parseConfigObject(normalized, filePath);
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -582,20 +616,50 @@ const mergeConfigs = (base: Record<string, unknown>, override: Record<string, un
|
||||
return result;
|
||||
};
|
||||
|
||||
const readConfigLayer = (filePath?: string | null): {
|
||||
config: Record<string, unknown>;
|
||||
error: (Error & { code: string }) | null;
|
||||
} => {
|
||||
try {
|
||||
return { config: readConfigFile(filePath), error: null };
|
||||
} catch (error) {
|
||||
if (isInvalidJsoncError(error)) {
|
||||
console.error(error.message);
|
||||
return { config: {}, error };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readConfigLayers = (workingDirectory?: string) => {
|
||||
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
|
||||
const userPath = getPrimaryUserConfigPath(userPaths);
|
||||
const userConfig = readConfigFile(userPath);
|
||||
const projectConfig = readConfigFile(projectPath);
|
||||
const customConfig = readConfigFile(customPath);
|
||||
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
|
||||
const userLayer = readConfigLayer(userPath);
|
||||
const projectLayer = readConfigLayer(projectPath);
|
||||
const customLayer = readConfigLayer(customPath);
|
||||
const mergedConfig = mergeConfigs(
|
||||
mergeConfigs(userLayer.config, projectLayer.config),
|
||||
customLayer.config,
|
||||
);
|
||||
|
||||
const layerErrors: Array<{ path: string; code: string; message: string }> = [];
|
||||
if (userLayer.error) {
|
||||
layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
|
||||
}
|
||||
if (projectLayer.error && projectPath) {
|
||||
layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
|
||||
}
|
||||
if (customLayer.error && customPath) {
|
||||
layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message });
|
||||
}
|
||||
|
||||
return {
|
||||
userConfig,
|
||||
projectConfig,
|
||||
customConfig,
|
||||
userConfig: userLayer.config,
|
||||
projectConfig: projectLayer.config,
|
||||
customConfig: customLayer.config,
|
||||
mergedConfig,
|
||||
paths: { userPath, projectPath, customPath }
|
||||
paths: { userPath, projectPath, customPath },
|
||||
layerErrors,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -695,6 +759,11 @@ const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPat
|
||||
|
||||
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Defense in depth: never overwrite a file we cannot fully parse.
|
||||
const existing = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (existing) {
|
||||
parseConfigObject(existing, filePath);
|
||||
}
|
||||
const backupFile = `${filePath}.openchamber.backup`;
|
||||
try {
|
||||
fs.copyFileSync(filePath, backupFile);
|
||||
@@ -862,10 +931,10 @@ const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope
|
||||
const projectPath = getProjectConfigPath(workingDirectory || undefined);
|
||||
return [
|
||||
customPath
|
||||
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
|
||||
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
|
||||
? { scope: 'user', path: customPath, config: readConfigLayer(customPath).config }
|
||||
: { scope: 'user', path: userPath, config: readConfigLayer(userPath).config },
|
||||
...(projectPath
|
||||
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
|
||||
? [{ scope: 'project' as const, path: projectPath, config: readConfigLayer(projectPath).config }]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
@@ -1376,22 +1445,45 @@ export const deleteMcpConfig = (name: string, workingDirectory?: string): void =
|
||||
writeConfig(config, targetPath);
|
||||
};
|
||||
|
||||
const getLayerError = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
filePath?: string | null,
|
||||
) => {
|
||||
if (!filePath) return null;
|
||||
return layers.layerErrors.find((entry) => entry.path === filePath) || null;
|
||||
};
|
||||
|
||||
const throwIfLayerError = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
filePath?: string | null,
|
||||
) => {
|
||||
const failed = getLayerError(layers, filePath);
|
||||
if (!failed) return;
|
||||
throw codedError(failed.message, failed.code);
|
||||
};
|
||||
|
||||
const getJsonEntrySource = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
sectionKey: 'agent' | 'command' | 'mcp',
|
||||
entryName: string
|
||||
) => {
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (customSection?.[entryName] !== undefined) {
|
||||
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
|
||||
if (paths.customPath) {
|
||||
throwIfLayerError(layers, paths.customPath);
|
||||
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (customSection?.[entryName] !== undefined) {
|
||||
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (projectSection?.[entryName] !== undefined) {
|
||||
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
|
||||
if (paths.projectPath && !getLayerError(layers, paths.projectPath)) {
|
||||
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (projectSection?.[entryName] !== undefined) {
|
||||
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
throwIfLayerError(layers, paths.userPath);
|
||||
const userSection = (userConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (userSection?.[entryName] !== undefined) {
|
||||
return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true };
|
||||
@@ -1406,11 +1498,14 @@ const getJsonWriteTarget = (
|
||||
) => {
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
if (paths.customPath) {
|
||||
throwIfLayerError(layers, paths.customPath);
|
||||
return { config: customConfig, path: paths.customPath };
|
||||
}
|
||||
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
||||
throwIfLayerError(layers, paths.projectPath);
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
throwIfLayerError(layers, paths.userPath);
|
||||
return { config: userConfig, path: paths.userPath };
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ const AUTH = JSON.stringify({
|
||||
crof: { key: 'test-token' },
|
||||
neuralwatt: { key: 'test-token' },
|
||||
'opencode-go': { key: 'test-token' },
|
||||
'command-code': { type: 'oauth', access: 'test-token' },
|
||||
'zai-coding-plan': { key: 'test-token' },
|
||||
deepseek: { key: 'test-token' },
|
||||
anthropic: { access: 'test-token', refresh: 'test-refresh' },
|
||||
});
|
||||
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
|
||||
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
|
||||
@@ -102,6 +104,58 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Code quota provider (VS Code parity)', () => {
|
||||
test('uses the OAuth access token and resolves server-backed limits', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
requests.push({ url, init });
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: { id: 'org/a' } }
|
||||
: { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(requests.map(({ url }) => url), [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
|
||||
]);
|
||||
assert.equal((requests[0].init?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120');
|
||||
});
|
||||
|
||||
test('omits orgId for personal accounts', async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (url: string) => {
|
||||
urls.push(url);
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { user: { id: 'user-1' }, org: null }
|
||||
: { credits: { monthlyCredits: 120 } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits',
|
||||
]);
|
||||
});
|
||||
|
||||
test('formats fractional credit values for display', async () => {
|
||||
globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: null }
|
||||
: { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79');
|
||||
assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Crof quota provider (VS Code parity)', () => {
|
||||
test('reports credits balance as valueLabel with null percent', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 })));
|
||||
@@ -149,6 +203,27 @@ describe('Crof quota provider (VS Code parity)', () => {
|
||||
});
|
||||
|
||||
describe('Codex quota provider (VS Code parity)', () => {
|
||||
test('coalesces concurrent refreshes for the same provider', async () => {
|
||||
let resolveResponse: ((response: Response) => void) | undefined;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (() => {
|
||||
requestCount += 1;
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const first = fetchQuotaForProvider('codex');
|
||||
const second = fetchQuotaForProvider('codex');
|
||||
resolveResponse?.(mockResponse({ rate_limit: null }));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
assert.equal(firstResult.ok, true);
|
||||
assert.equal(secondResult.ok, true);
|
||||
assert.equal(requestCount, 1);
|
||||
});
|
||||
|
||||
test('surfaces spend_control individual limit for business accounts', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
plan_type: 'business',
|
||||
@@ -173,6 +248,60 @@ describe('Codex quota provider (VS Code parity)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude quota provider (VS Code parity)', () => {
|
||||
test('parses current limits, model-scoped limits, and extra usage', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
limits: [
|
||||
{ kind: 'session', percent: 12, resets_at: '2026-08-20T12:00:00Z', scope: null },
|
||||
{ kind: 'weekly_all', percent: 34, resets_at: '2026-08-24T12:00:00Z', scope: null },
|
||||
{ kind: 'weekly_scoped', percent: 56, resets_at: '2026-08-24T12:00:00Z', scope: { model: { display_name: 'Sonnet' } } },
|
||||
],
|
||||
spend: {
|
||||
enabled: true,
|
||||
percent: 25,
|
||||
used: { amount_minor: 2500, exponent: 2, currency: 'USD' },
|
||||
limit: { amount_minor: 10000, exponent: 2, currency: 'USD' },
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('claude');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage?.windows['5h']?.usedPercent, 12);
|
||||
assert.equal(result.usage?.windows['7d']?.usedPercent, 34);
|
||||
assert.equal(result.usage?.models?.Sonnet?.windows['7d']?.usedPercent, 56);
|
||||
assert.equal(result.usage?.windows.extra_usage?.valueLabel, '$25.00 / $100.00');
|
||||
});
|
||||
|
||||
test('keeps serving the last good values while Anthropic rate limits', async () => {
|
||||
const responses = [
|
||||
mockResponse({ five_hour: { utilization: 12, resets_at: '2026-08-20T12:00:00Z' } }),
|
||||
{
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '120' }),
|
||||
json: async () => ({}),
|
||||
} as Response,
|
||||
];
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
const response = responses[requestCount];
|
||||
requestCount += 1;
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const initial = await fetchQuotaForProvider('claude');
|
||||
const rateLimited = await fetchQuotaForProvider('claude');
|
||||
const duringCooldown = await fetchQuotaForProvider('claude');
|
||||
|
||||
assert.equal(initial.ok, true);
|
||||
assert.equal(rateLimited.ok, true);
|
||||
assert.equal(duringCooldown.ok, true);
|
||||
assert.equal(duringCooldown.usage?.windows['5h']?.usedPercent, 12);
|
||||
assert.equal(requestCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Z.ai quota provider (VS Code parity)', () => {
|
||||
test('surfaces 5-hour, weekly, and MCP quota windows', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { fetchCommandCodeUsage } from './commandCodeQuota';
|
||||
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
|
||||
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
|
||||
|
||||
@@ -169,6 +170,7 @@ export type ProviderResult = {
|
||||
usage: ProviderUsage | null;
|
||||
fetchedAt: number;
|
||||
error?: string;
|
||||
planLabel?: string | null;
|
||||
};
|
||||
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
@@ -748,6 +750,9 @@ export const listConfiguredQuotaProviders = () => {
|
||||
const configured = new Set<string>();
|
||||
const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go']));
|
||||
if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go');
|
||||
const commandCodeAuth = normalizeAuthEntry(getAuthEntry(auth, ['command-code']));
|
||||
if (commandCodeAuth && (typeof commandCodeAuth.key === 'string' || typeof commandCodeAuth.access === 'string' || typeof commandCodeAuth.token === 'string')) configured.add('command-code');
|
||||
if (process.env.COMMAND_CODE_API_KEY?.trim()) configured.add('command-code');
|
||||
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
|
||||
if (readCredential('cursor')) configured.add('cursor');
|
||||
|
||||
@@ -1251,6 +1256,112 @@ const fetchGoogleQuota = async (): Promise<ProviderResult> => {
|
||||
});
|
||||
};
|
||||
|
||||
const CLAUDE_DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
|
||||
const CLAUDE_MAX_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
let claudeCredentialFingerprint: string | null = null;
|
||||
let claudeCachedUsage: ProviderUsage | null = null;
|
||||
let claudeCooldownUntil = 0;
|
||||
|
||||
const claudeCooldownFromResponse = (response: Response): number => {
|
||||
const raw = response.headers.get('retry-after');
|
||||
const seconds = raw ? Number(raw) : Number.NaN;
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return Math.min(seconds * 1000, CLAUDE_MAX_COOLDOWN_MS);
|
||||
}
|
||||
if (raw) {
|
||||
const retryAt = Date.parse(raw);
|
||||
if (Number.isFinite(retryAt) && retryAt > Date.now()) {
|
||||
return Math.min(retryAt - Date.now(), CLAUDE_MAX_COOLDOWN_MS);
|
||||
}
|
||||
}
|
||||
return CLAUDE_DEFAULT_COOLDOWN_MS;
|
||||
};
|
||||
|
||||
const buildClaudeRateLimitResult = (): ProviderResult => (
|
||||
claudeCachedUsage
|
||||
? buildResult({
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: claudeCachedUsage,
|
||||
})
|
||||
: buildResult({
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Rate limited by Anthropic. Retrying shortly.',
|
||||
})
|
||||
);
|
||||
|
||||
const buildClaudeUsage = (payload: Record<string, unknown>): ProviderUsage => {
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
const models: Record<string, ProviderUsage> = {};
|
||||
const limits = Array.isArray(payload.limits) ? payload.limits : [];
|
||||
|
||||
for (const entry of limits) {
|
||||
const limit = asObject(entry);
|
||||
if (!limit) continue;
|
||||
const usedPercent = toNumber(limit.percent);
|
||||
const resetAt = toTimestamp(limit.resets_at);
|
||||
if (limit.kind === 'session') {
|
||||
windows['5h'] = toUsageWindow({ usedPercent, windowSeconds: 5 * 60 * 60, resetAt });
|
||||
} else if (limit.kind === 'weekly_all') {
|
||||
windows['7d'] = toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt });
|
||||
} else if (limit.kind === 'weekly_scoped') {
|
||||
const modelName = asNonEmptyString(asObject(asObject(limit.scope)?.model)?.display_name);
|
||||
if (modelName) {
|
||||
models[modelName] = {
|
||||
windows: {
|
||||
'7d': toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt }),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!limits.length) {
|
||||
const fiveHour = asObject(payload.five_hour);
|
||||
const sevenDay = asObject(payload.seven_day);
|
||||
if (fiveHour) {
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent: toNumber(fiveHour.utilization),
|
||||
windowSeconds: 5 * 60 * 60,
|
||||
resetAt: toTimestamp(fiveHour.resets_at),
|
||||
});
|
||||
}
|
||||
if (sevenDay) {
|
||||
windows['7d'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDay.utilization),
|
||||
windowSeconds: 7 * 24 * 60 * 60,
|
||||
resetAt: toTimestamp(sevenDay.resets_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const spend = asObject(payload.spend);
|
||||
if (spend?.enabled === true) {
|
||||
const usedMoney = asObject(spend.used);
|
||||
const limitMoney = asObject(spend.limit);
|
||||
const usedMinor = toNumber(usedMoney?.amount_minor);
|
||||
const limitMinor = toNumber(limitMoney?.amount_minor);
|
||||
const exponent = toNumber(usedMoney?.exponent) ?? 2;
|
||||
const currency = asNonEmptyString(usedMoney?.currency);
|
||||
const prefix = currency === 'USD' || !currency ? '$' : `${currency} `;
|
||||
const used = usedMinor === null ? null : usedMinor / 10 ** exponent;
|
||||
const limit = limitMinor === null ? null : limitMinor / 10 ** (toNumber(limitMoney?.exponent) ?? 2);
|
||||
windows.extra_usage = toUsageWindow({
|
||||
usedPercent: toNumber(spend.percent),
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: used === null ? null : `${prefix}${formatMoney(used)}${limit === null ? '' : ` / ${prefix}${formatMoney(limit)}`}`,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(models).length ? { windows, models } : { windows };
|
||||
};
|
||||
|
||||
const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record<string, unknown> | null;
|
||||
@@ -1266,6 +1377,15 @@ const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
});
|
||||
}
|
||||
|
||||
const refreshToken = typeof entry?.refresh === 'string' ? entry.refresh : '';
|
||||
const fingerprint = `${accessToken}\0${refreshToken}`;
|
||||
if (claudeCredentialFingerprint !== fingerprint) {
|
||||
claudeCredentialFingerprint = fingerprint;
|
||||
claudeCachedUsage = null;
|
||||
claudeCooldownUntil = 0;
|
||||
}
|
||||
if (Date.now() < claudeCooldownUntil) return buildClaudeRateLimitResult();
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
||||
method: 'GET',
|
||||
@@ -1275,6 +1395,21 @@ const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
claudeCooldownUntil = Date.now() + claudeCooldownFromResponse(response);
|
||||
return buildClaudeRateLimitResult();
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return buildResult({
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Claude session expired. Open Claude Code to sign in again.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId: 'claude',
|
||||
@@ -1286,47 +1421,14 @@ const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
|
||||
const payload = await response.json() as Record<string, unknown>;
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
const fiveHour = (payload as Record<string, unknown>).five_hour as Record<string, unknown> | undefined;
|
||||
const sevenDay = (payload as Record<string, unknown>).seven_day as Record<string, unknown> | undefined;
|
||||
const sevenDaySonnet = (payload as Record<string, unknown>).seven_day_sonnet as Record<string, unknown> | undefined;
|
||||
const sevenDayOpus = (payload as Record<string, unknown>).seven_day_opus as Record<string, unknown> | undefined;
|
||||
|
||||
if (fiveHour) {
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent: toNumber(fiveHour.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(fiveHour.resets_at),
|
||||
});
|
||||
}
|
||||
if (sevenDay) {
|
||||
windows['7d'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDay.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDay.resets_at),
|
||||
});
|
||||
}
|
||||
if (sevenDaySonnet) {
|
||||
windows['7d-sonnet'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDaySonnet.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDaySonnet.resets_at),
|
||||
});
|
||||
}
|
||||
if (sevenDayOpus) {
|
||||
windows['7d-opus'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDayOpus.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDayOpus.resets_at),
|
||||
});
|
||||
}
|
||||
|
||||
const usage = buildClaudeUsage(payload);
|
||||
claudeCachedUsage = usage;
|
||||
return buildResult({
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
usage,
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
@@ -2705,7 +2807,7 @@ const fetchXaiQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
|
||||
const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<ProviderResult> => {
|
||||
switch (providerId) {
|
||||
case 'claude':
|
||||
return fetchClaudeQuota();
|
||||
@@ -2746,6 +2848,18 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
}
|
||||
case 'command-code': {
|
||||
try {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), ['command-code']));
|
||||
const stored = typeof entry?.key === 'string' ? entry.key : typeof entry?.access === 'string' ? entry.access : typeof entry?.token === 'string' ? entry.token : null;
|
||||
const environment = process.env.COMMAND_CODE_API_KEY?.trim() || null;
|
||||
const apiKey = stored?.trim() || environment;
|
||||
if (!apiKey) return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: false, error: 'Not configured' });
|
||||
return buildResult({ providerId, providerName: 'Command Code', ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
}
|
||||
case 'cursor':
|
||||
return fetchCursorQuota();
|
||||
case 'crof':
|
||||
@@ -2766,3 +2880,16 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pendingQuotaFetches = new Map<string, Promise<ProviderResult>>();
|
||||
|
||||
export const fetchQuotaForProvider = (providerId: string): Promise<ProviderResult> => {
|
||||
const existing = pendingQuotaFetches.get(providerId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => {
|
||||
if (pendingQuotaFetches.get(providerId) === pending) pendingQuotaFetches.delete(providerId);
|
||||
});
|
||||
pendingQuotaFetches.set(providerId, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user