Merge origin/main into deferred OpenCode restart branch.
Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API key hiding, always-load auth methods) while keeping deferred Apply & Restart for provider mutations. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
## [Unreleased]
|
||||
## [1.18.0] - 2026-08-04
|
||||
|
||||
- **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech).
|
||||
- UI/Localization: added German interface translations (thanks to @SGD-DEV).
|
||||
- Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271).
|
||||
- Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update.
|
||||
- Settings/Skills: repository-local `.agents/skills` now appear for the active workspace (thanks to @makeittech).
|
||||
- Settings/Skills: renaming a skill now preserves its instructions and supporting files; only skills in locations OpenChamber can safely rename show the action (thanks to @makeittech).
|
||||
- Usage: added DeepSeek quota tracking (thanks to @airtaxi).
|
||||
- Usage: Kimi for Coding now calculates usage correctly when the provider reports either used or remaining quota (thanks to @makeittech).
|
||||
- Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui).
|
||||
- Chat: assistant messages no longer render active HTML.
|
||||
- Sidebar: a worktree shared by more than one project no longer appears twice.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "openchamber",
|
||||
"displayName": "OpenChamber",
|
||||
"description": "%extension.description%",
|
||||
"version": "1.17.2",
|
||||
"version": "1.18.0",
|
||||
"publisher": "fedaykindev",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -1385,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectStoragePath = (projectID: string) => {
|
||||
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
||||
};
|
||||
|
||||
const updateProjectSandboxes = async (
|
||||
projectID: string,
|
||||
primaryWorktree: string,
|
||||
updater: (project: {
|
||||
id: string;
|
||||
worktree: string;
|
||||
vcs: string;
|
||||
sandboxes: string[];
|
||||
time: { created: number; updated: number };
|
||||
}) => void
|
||||
) => {
|
||||
const storagePath = getProjectStoragePath(projectID);
|
||||
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
id: projectID,
|
||||
worktree: primaryWorktree,
|
||||
vcs: 'git',
|
||||
sandboxes: [] as string[],
|
||||
time: { created: now, updated: now },
|
||||
};
|
||||
|
||||
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
|
||||
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
|
||||
current.id = String(current.id || projectID);
|
||||
current.worktree = String(current.worktree || primaryWorktree);
|
||||
current.vcs = current.vcs || 'git';
|
||||
current.sandboxes = Array.isArray(current.sandboxes)
|
||||
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const createdAt = Number(current?.time?.created);
|
||||
current.time = {
|
||||
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
updater(current);
|
||||
|
||||
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
|
||||
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
if (!project.sandboxes.includes(sandbox)) {
|
||||
project.sandboxes.push(sandbox);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
|
||||
});
|
||||
};
|
||||
// OpenCode owns its own project/sandbox registry and records a worktree as a
|
||||
// sandbox itself when an instance boots for that directory. OpenChamber used to
|
||||
// write that state into OpenCode's storage JSON directly, behind the back of the
|
||||
// running process — and since OpenCode v2 reads sandboxes from its database, the
|
||||
// JSON write did not even reach it. Registration is not ours to perform.
|
||||
|
||||
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
|
||||
const relative = path.relative(root, target);
|
||||
@@ -1477,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async (
|
||||
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
||||
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
||||
|
||||
if (!isAttached) {
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInsideWorktreeRoot || isAttached) {
|
||||
return;
|
||||
}
|
||||
@@ -1963,12 +1892,6 @@ async function attachGitWorktreeToCandidate(
|
||||
|
||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
@@ -2033,12 +1956,6 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await fs.promises.mkdir(candidate.directory, { recursive: false });
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
@@ -2129,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(targetDirectory);
|
||||
|
||||
return true;
|
||||
@@ -2157,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(matchedEntry.worktree);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -11,6 +11,7 @@ const AUTH = JSON.stringify({
|
||||
crof: { key: 'test-token' },
|
||||
neuralwatt: { key: 'test-token' },
|
||||
'zai-coding-plan': { key: 'test-token' },
|
||||
deepseek: { key: 'test-token' },
|
||||
});
|
||||
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
|
||||
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
|
||||
@@ -419,3 +420,93 @@ describe('NeuralWatt quota provider (VS Code parity)', () => {
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek quota provider (VS Code parity)', () => {
|
||||
beforeEach(() => {
|
||||
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
|
||||
fsMock.existsSync = () => true;
|
||||
fsMock.readFileSync = () => AUTH;
|
||||
});
|
||||
|
||||
test('builds credits_balance window from documented USD payload (string balance)', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{ currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' },
|
||||
],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.providerId, 'deepseek');
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54');
|
||||
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
|
||||
assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null);
|
||||
assert.equal(result.usage!.windows.credits_balance!.resetAt, null);
|
||||
});
|
||||
|
||||
test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' },
|
||||
],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00');
|
||||
});
|
||||
|
||||
test('maps 401 to session-expired', async () => {
|
||||
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek');
|
||||
});
|
||||
|
||||
test('reports a normalized timeout error', async () => {
|
||||
stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Request timed out');
|
||||
});
|
||||
|
||||
test('returns no-quota-data on a 200 payload with no usable balance', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.error, 'No quota data in response');
|
||||
assert.equal(result.usage, null);
|
||||
});
|
||||
|
||||
test('keeps a literal zero balance as a valid valueLabel', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00');
|
||||
});
|
||||
|
||||
test('teardown: restore fs', () => {
|
||||
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
|
||||
fsMock.existsSync = ORIGINAL_FS.existsSync;
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,16 @@ type CrofPayload = {
|
||||
credits?: number | string;
|
||||
};
|
||||
|
||||
type DeepseekPayload = {
|
||||
is_available?: boolean;
|
||||
balance_infos?: Array<{
|
||||
currency?: string;
|
||||
total_balance?: number | string;
|
||||
granted_balance?: number | string;
|
||||
topped_up_balance?: number | string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type NeuralwattPayload = {
|
||||
balance?: {
|
||||
credits_remaining_usd?: number | string;
|
||||
@@ -492,6 +502,11 @@ export const listConfiguredQuotaProviders = () => {
|
||||
configured.add('neuralwatt');
|
||||
}
|
||||
|
||||
const deepseekAuth = normalizeAuthEntry(getAuthEntry(auth, ['deepseek']));
|
||||
if (deepseekAuth && ((deepseekAuth as Record<string, unknown>).key || (deepseekAuth as Record<string, unknown>).token)) {
|
||||
configured.add('deepseek');
|
||||
}
|
||||
|
||||
return Array.from(configured);
|
||||
};
|
||||
|
||||
@@ -1137,6 +1152,24 @@ const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
|
||||
// blocks report `remaining` instead. Neither field is guaranteed present, so
|
||||
// derive usedPercent from whichever one the API actually returned.
|
||||
const computeKimiUsedPercent = (
|
||||
total: number | null,
|
||||
used: number | null,
|
||||
remaining: number | null,
|
||||
): number | null => {
|
||||
if (!total) return null;
|
||||
if (used !== null) {
|
||||
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||
}
|
||||
if (remaining !== null) {
|
||||
return Math.max(0, Math.min(100, 100 - (remaining / total) * 100));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchKimiQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record<string, unknown> | null;
|
||||
@@ -1176,10 +1209,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
|
||||
const usage = payload.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
const limit = toNumber(usage.limit);
|
||||
const used = toNumber(usage.used);
|
||||
const remaining = toNumber(usage.remaining);
|
||||
const usedPercent = limit && remaining !== null
|
||||
? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100))
|
||||
: null;
|
||||
const usedPercent = computeKimiUsedPercent(limit, used, remaining);
|
||||
windows.weekly = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: null,
|
||||
@@ -1195,10 +1227,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
|
||||
const windowSeconds = durationToSeconds(window?.duration as number | undefined, window?.timeUnit as string | undefined);
|
||||
const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel;
|
||||
const total = toNumber(detail?.limit);
|
||||
const used = toNumber(detail?.used);
|
||||
const remaining = toNumber(detail?.remaining);
|
||||
const usedPercent = total && remaining !== null
|
||||
? Math.max(0, Math.min(100, 100 - (remaining / total) * 100))
|
||||
: null;
|
||||
const usedPercent = computeKimiUsedPercent(total, used, remaining);
|
||||
windows[label] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
@@ -2175,6 +2206,103 @@ const fetchCrofQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
|
||||
|
||||
const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(DEEPSEEK_QUOTA_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
signal: timeoutSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: response.status === 401 || response.status === 403
|
||||
? 'Session expired — please re-authenticate with DeepSeek'
|
||||
: `API error: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json() as DeepseekPayload;
|
||||
const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : [];
|
||||
const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD')
|
||||
?? balanceInfos.find((info) => info?.currency === 'CNY')
|
||||
?? null;
|
||||
const rawBalance = balanceInfo?.total_balance;
|
||||
const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
|
||||
? toNumber(rawBalance)
|
||||
: null;
|
||||
|
||||
if (totalBalance === null) {
|
||||
return buildResult({
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response',
|
||||
});
|
||||
}
|
||||
|
||||
const symbol = balanceInfo?.currency === 'CNY' ? '¥' : '$';
|
||||
const windows: Record<string, UsageWindow> = {
|
||||
credits_balance: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `${symbol}${formatMoney(totalBalance)}`,
|
||||
}),
|
||||
};
|
||||
|
||||
return buildResult({
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof DOMException && (
|
||||
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
||||
);
|
||||
const isParseError = error instanceof SyntaxError;
|
||||
return buildResult({
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: isTimeout
|
||||
? 'Request timed out'
|
||||
: isParseError
|
||||
? 'Invalid response from provider'
|
||||
: (error instanceof Error ? error.message : 'Request failed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
|
||||
switch (providerId) {
|
||||
case 'claude':
|
||||
@@ -2218,6 +2346,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
return fetchCursorQuota();
|
||||
case 'crof':
|
||||
return fetchCrofQuota();
|
||||
case 'deepseek':
|
||||
return fetchDeepseekQuota();
|
||||
case 'neuralwatt':
|
||||
return fetchNeuralwattQuota();
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user