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:
Cursor Agent
2026-08-04 13:29:46 +00:00
co-authored by Serhii Dziupin
133 changed files with 6361 additions and 714 deletions
+7 -120
View File
@@ -1644,94 +1644,13 @@ const loadProjectStartCommand = async (projectID) => {
}
};
const getProjectStoragePath = (projectID) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
try {
const Database = require('better-sqlite3');
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
if (!fs.existsSync(dbPath)) return;
const db = new Database(dbPath);
try {
const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
if (!row) return;
const json = JSON.stringify(sandboxes);
db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
} finally {
db.close();
}
} catch (error) {
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
}
};
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
const storagePath = getProjectStoragePath(projectID);
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [],
time: {
created: now,
updated: now,
},
};
const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).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(
(Array.isArray(current.sandboxes) ? current.sandboxes : [])
.map((entry) => String(entry || '').trim())
.filter(Boolean)
)];
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
};
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
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, primaryWorktree, sandboxPath) => {
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. It records a worktree as a
// sandbox itself when an instance boots for that directory, and filters entries
// whose directory no longer exists when reading them back. OpenChamber used to
// write that state directly into OpenCode's storage JSON and SQLite database,
// behind the back of the running process: the row changed but the server was
// never told, so a worktree created while OpenCode was running stayed unknown
// to it until a restart. Registration is not ours to perform.
const isAttachedGitWorktreeDirectory = async (directory) => {
try {
@@ -1748,14 +1667,6 @@ const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
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('Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
@@ -3940,12 +3851,6 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('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();
@@ -4005,12 +3910,6 @@ export async function createWorktree(directory, input = {}) {
if (input?.returnAfterDirectoryCreated === true) {
await fsp.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
@@ -4103,12 +4002,6 @@ export async function removeWorktree(directory, input = {}) {
await fsp.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
@@ -4131,12 +4024,6 @@ export async function removeWorktree(directory, input = {}) {
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
@@ -32,7 +32,15 @@ other.
requires observed activity or a newly completed assistant message.
- Timeout and cancellation are failures, never authoritative idle results.
- Validation that protects side effects runs before session creation or
dispatch.
dispatch. An explicitly requested model, agent, or variant is checked against
the directory's own OpenCode agent and provider lists before any session,
worktree, or goal is created, because `prompt_async` accepts an unusable
selection and then fails only on the event stream. A failed or empty lookup
never turns a valid selection into a rejection.
- `promptDispatched` reports an observed dispatch, never an accepted request.
After `prompt_async` the service confirms a new user message reached the
session; when it does not, the result reports `promptDispatched: false` with
`promptError` instead of claiming success.
- Send and fork dispatches without an explicit model/agent/variant reuse the
target session's last user-message selection before falling back to the
configured defaults; only session creation resolves defaults directly.
@@ -272,6 +272,41 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated
: { ok: false, status: 400, error: validated.error || 'Invalid directory' };
};
const PROMPT_LANDED_TIMEOUT_MS = 5_000;
const PROMPT_LANDED_POLL_MS = 150;
const latestUserMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
response = await client.session.messages({ sessionID, directory, limit: 100 });
} catch {
return { ok: false, messageID: null };
}
const messages = Array.isArray(response?.data) ? response.data : [];
let latest = null;
for (const message of messages) {
const info = message?.info;
if (info?.role !== 'user') continue;
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return { ok: true, messageID: asNonEmptyString(latest?.id) };
};
// `prompt_async` answers 204 as soon as OpenCode forks the run, and every later
// failure is reported only on the session event stream. Confirm the prompt was
// actually recorded so `promptDispatched` never claims a dispatch that vanished.
const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => {
const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS;
for (;;) {
const latest = await latestUserMessageID({ client, sessionID, directory });
// A failed lookup is not authoritative evidence that the prompt was lost.
if (!latest.ok) return true;
if (latest.messageID && latest.messageID !== baselineUserMessageID) return true;
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS));
}
};
const resolveWorktreeInput = (payload) => {
if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
const name = asNonEmptyString(payload.worktree.name);
@@ -322,6 +357,48 @@ export const createOpenChamberSessionService = (dependencies) => {
return null;
};
// Explicit model/agent/variant are never checked by `prompt_async`: an unknown
// agent makes the forked run fail silently, leaving a session with no message.
// Reject them before any session, worktree, or goal side effect happens.
const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => {
if (!requestedModel && !requestedAgent && !requestedVariant) return;
const authHeaders = getOpenCodeAuthHeaders();
const { providers, agents } = await fetchSelectionInputs({
buildOpenCodeUrl,
authHeaders,
directory,
readSettingsFromDiskMigrated,
});
// An empty list means the lookup failed or returned nothing authoritative;
// it must not turn a valid selection into a rejection.
if (requestedAgent && agents.length > 0) {
const agent = agents.find((entry) => entry?.name === requestedAgent) || null;
if (!agent) {
throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400);
}
if (!isPrimaryAgentMode(agent.mode)) {
throw new OpenChamberControlError(`Agent '${requestedAgent}' is a subagent and cannot receive a prompt directly`, 400);
}
}
if (requestedModel && providers.length > 0) {
if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) {
throw new OpenChamberControlError(
`Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`,
400,
);
}
if (requestedVariant
&& !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
throw new OpenChamberControlError(
`Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`,
400,
);
}
}
};
const dispatchPrompt = async ({
client,
baseUrl,
@@ -417,6 +494,7 @@ export const createOpenChamberSessionService = (dependencies) => {
throw markGoalPartial(error);
}
} else {
const baseline = await latestUserMessageID({ client, sessionID, directory });
try {
await runPromptAsync({
baseUrl,
@@ -438,6 +516,22 @@ export const createOpenChamberSessionService = (dependencies) => {
} catch (error) {
throw markGoalPartial(error);
}
const landed = await waitForPromptLanded({
client,
sessionID,
directory,
baselineUserMessageID: baseline.messageID,
});
if (!landed) {
return {
model,
agent,
variant,
promptDispatched: false,
dispatchedAsCommand: false,
promptError: 'OpenCode accepted the prompt but it never appeared in the session',
};
}
}
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
@@ -470,13 +564,23 @@ export const createOpenChamberSessionService = (dependencies) => {
if (payload?.worktree && !worktreeInput) {
throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400);
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
if (prompt) {
await validateRequestedSelection({
directory: resolvedDirectory.directory,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
});
}
if (worktreeInput) {
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
sessionDirectory = worktree.path;
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
@@ -514,6 +618,7 @@ export const createOpenChamberSessionService = (dependencies) => {
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -566,6 +671,13 @@ export const createOpenChamberSessionService = (dependencies) => {
directory = resolvedDirectory.directory;
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
await validateRequestedSelection({
directory,
requestedModel,
requestedAgent: asNonEmptyString(payload.agent),
requestedVariant: asNonEmptyString(payload.variant),
});
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
@@ -608,7 +720,8 @@ export const createOpenChamberSessionService = (dependencies) => {
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -624,7 +737,7 @@ export const createOpenChamberSessionService = (dependencies) => {
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -11,6 +11,53 @@ const createWorktreeMock = vi.fn(async () => ({
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
let existingSessionMessages = [];
let dispatchedUserMessageSeq = 0;
// The service confirms a prompt landed by watching for a new user message, so
// the default mock behaves like OpenCode recording each dispatched prompt.
const setSessionMessages = (messages) => {
existingSessionMessages = messages;
};
const recordedSessionMessages = async () => {
dispatchedUserMessageSeq += 1;
return {
data: [
...existingSessionMessages,
{
info: {
id: `msg_dispatched_${dispatchedUserMessageSeq}`,
role: 'user',
time: { created: 1000 + dispatchedUserMessageSeq },
},
},
],
};
};
// Selection inputs are fetched whenever a request names a model, agent, or
// variant, so every prompt-dispatching fetch mock must answer them.
const selectionInputResponse = (url) => {
const text = String(url);
if (text.includes('/config/providers')) {
return {
ok: true,
json: async () => ({
providers: [
{ id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] },
{ id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] },
],
}),
};
}
if (text.includes('/agent')) {
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
}
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
return null;
};
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
const commandListMock = vi.fn(async () => ({ data: [] }));
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
@@ -62,8 +109,10 @@ describe('openchamber session routes', () => {
createWorktreeMock.mockClear();
sessionCreateMock.mockClear();
sessionForkMock.mockClear();
existingSessionMessages = [];
dispatchedUserMessageSeq = 0;
sessionMessagesMock.mockReset();
sessionMessagesMock.mockResolvedValue({ data: [] });
sessionMessagesMock.mockImplementation(recordedSessionMessages);
sessionCommandMock.mockReset();
sessionCommandMock.mockResolvedValue({ data: {} });
commandListMock.mockReset();
@@ -319,13 +368,11 @@ describe('openchamber session routes', () => {
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }],
});
setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
@@ -370,7 +417,7 @@ describe('openchamber session routes', () => {
template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
}],
});
globalThis.fetch = vi.fn();
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
try {
const { app } = createApp({ createSessionGoal });
const response = await request(app)
@@ -393,7 +440,7 @@ describe('openchamber session routes', () => {
}));
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
@@ -401,11 +448,10 @@ describe('openchamber session routes', () => {
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [
setSessionMessages([
{
info: {
id: 'msg_user',
@@ -416,8 +462,7 @@ describe('openchamber session routes', () => {
},
},
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
],
});
]);
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
@@ -449,7 +494,7 @@ describe('openchamber session routes', () => {
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => '' }));
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
try {
const { app } = createApp({ emitSessionCreatedEvent });
const response = await request(app)
@@ -519,7 +564,7 @@ describe('openchamber session routes', () => {
it('reports the forked session when prompt dispatch fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'dispatch failed' }));
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' });
try {
const { app } = createApp();
const response = await request(app)
@@ -584,9 +629,77 @@ describe('openchamber session routes', () => {
}
});
it('rejects an unknown agent before creating a session or worktree', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
prompt: 'Run this',
agent: 'not-an-agent',
worktree: { name: 'side-task' },
})
.expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
expect(createWorktreeMock).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects an unknown model and an unknown variant before dispatching', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
.expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reports promptDispatched false when the accepted prompt never reaches the session', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
sessionMessagesMock.mockResolvedValue({ data: [] });
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.promptDispatched).toBe(false);
expect(response.body.promptError).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
}
}, 20_000);
it('does not retry a failed slash command as a normal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
globalThis.fetch = fetchMock;
@@ -604,7 +717,7 @@ describe('openchamber session routes', () => {
.expect(500);
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
@@ -20,6 +20,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
@@ -70,6 +71,14 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
## Kimi for Coding field semantics
`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption:
- The weekly `usage` block returns `used` (consumed) with no `remaining` field.
- Each `limits[].detail` rate-limit block returns `remaining` (available) with no `used` field.
The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it.
## Notes for contributors
- Keep provider IDs stable; clients use them directly.
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
+1
View File
@@ -13,6 +13,7 @@ export {
fetchGoogleQuota,
fetchCodexQuota,
fetchCursorQuota,
fetchDeepseekQuota,
fetchCopilotQuota,
fetchCopilotAddonQuota,
fetchKimiQuota,
@@ -0,0 +1,118 @@
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
formatMoney
} from '../utils/index.js';
export const providerId = 'deepseek';
export const providerName = 'DeepSeek';
const aliases = ['deepseek'];
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
export const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
return Boolean(entry?.key || entry?.token);
};
export const fetchQuota = async () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
const apiKey = entry?.key ?? entry?.token;
if (!apiKey) {
return buildResult({
providerId,
providerName,
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,
providerName,
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();
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,
providerName,
ok: false,
configured: true,
error: 'No quota data in response'
});
}
const isCny = balanceInfo?.currency === 'CNY';
const symbol = isCny ? '¥' : '$';
const valueLabel = `${symbol}${formatMoney(totalBalance)}`;
const windows = {
credits_balance: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel
})
};
return buildResult({
providerId,
providerName,
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,
providerName,
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed')
});
}
};
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../opencode/auth.js', () => ({
readAuthFile: () => ({ deepseek: { key: 'test-token' } }),
}));
import { fetchQuota } from './deepseek.js';
afterEach(() => {
vi.unstubAllGlobals();
});
const mockResponse = (body, init = {}) => ({
ok: true,
status: 200,
json: async () => body,
...init,
});
// Documented payload shape from https://api.deepseek.com/user/balance
const DOCUMENTED_PAYLOAD = {
is_available: true,
balance_infos: [
{
currency: 'USD',
total_balance: '7.54',
granted_balance: '0.00',
topped_up_balance: '7.54'
}
]
};
describe('DeepSeek quota provider', () => {
it('builds credits_balance window from documented USD payload (string balance)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD)));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.providerId).toBe('deepseek');
const window = result.usage.windows.credits_balance;
expect(window).toBeDefined();
expect(window.valueLabel).toBe('$7.54');
expect(window.usedPercent).toBeNull();
expect(window.windowSeconds).toBeNull();
expect(window.resetAt).toBeNull();
});
it('falls back to CNY entry when no USD entry is present', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' }
]
})));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.credits_balance.valueLabel).toBe('¥100.00');
});
it('prefers the USD entry when both USD and CNY are present', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' },
{ currency: 'USD', total_balance: '3.55', granted_balance: '0.00', topped_up_balance: '3.55' }
]
})));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.credits_balance.valueLabel).toBe('$3.55');
});
it('tolerates a numeric total_balance', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: 12.5, granted_balance: 0, topped_up_balance: 12.5 }]
})));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.credits_balance.valueLabel).toBe('$12.50');
});
it('maps 401 to session-expired error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.error).toBe('Session expired — please re-authenticate with DeepSeek');
});
it('maps 403 to session-expired error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) }));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.error).toBe('Session expired — please re-authenticate with DeepSeek');
});
it('reports invalid-response on JSON parse failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('Unexpected token'); },
}));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.error).toBe('Invalid response from provider');
});
it('reports a normalized timeout error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.error).toBe('Request timed out');
});
it('returns no-quota-data on a 200 payload with no usable balance', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }]
})));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.configured).toBe(true);
expect(result.error).toBe('No quota data in response');
expect(result.usage).toBeNull();
});
it('keeps a literal zero balance as a valid valueLabel', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }]
})));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00');
});
});
@@ -12,6 +12,7 @@ import * as codex from './codex.js';
import * as copilot from './copilot.js';
import * as crof from './crof.js';
import * as cursor from './cursor.js';
import * as deepseek from './deepseek.js';
import * as google from './google/index.js';
import * as kimi from './kimi.js';
import * as nanogpt from './nanogpt.js';
@@ -51,6 +52,12 @@ const registry = {
isConfigured: cursor.isConfigured,
fetchQuota: cursor.fetchQuota
},
deepseek: {
providerId: deepseek.providerId,
providerName: deepseek.providerName,
isConfigured: deepseek.isConfigured,
fetchQuota: deepseek.fetchQuota
},
google: {
providerId: google.providerId,
providerName: google.providerName,
@@ -184,6 +191,7 @@ export const fetchOpenaiQuota = openai.fetchQuota;
export const fetchGoogleQuota = google.fetchGoogleQuota;
export const fetchCodexQuota = codex.fetchQuota;
export const fetchCursorQuota = cursor.fetchQuota;
export const fetchDeepseekQuota = deepseek.fetchQuota;
export const fetchCopilotQuota = copilot.fetchQuota;
export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon;
export const fetchKimiQuota = kimi.fetchQuota;
@@ -14,6 +14,20 @@ export const providerId = 'kimi-for-coding';
export const providerName = 'Kimi for Coding';
const aliases = ['kimi-for-coding', 'kimi'];
// 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 computeUsedPercent = (total, used, remaining) => {
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;
};
export const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
@@ -59,10 +73,9 @@ export const fetchQuota = async () => {
const usage = payload?.usage ?? null;
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 = computeUsedPercent(limit, used, remaining);
windows.weekly = toUsageWindow({
usedPercent,
windowSeconds: null,
@@ -78,10 +91,9 @@ export const fetchQuota = async () => {
const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit);
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 = computeUsedPercent(total, used, remaining);
windows[label] = toUsageWindow({
usedPercent,
windowSeconds,
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../opencode/auth.js', () => ({
readAuthFile: () => ({ 'kimi-for-coding': { key: 'test-token' } }),
}));
import { fetchQuota } from './kimi.js';
afterEach(() => {
vi.unstubAllGlobals();
});
const mockResponse = (body, init = {}) => ({
ok: true,
status: 200,
json: async () => body,
...init,
});
describe('Kimi for Coding quota provider', () => {
it('computes weekly usedPercent from the used field (live API shape, no remaining field)', async () => {
// Captured from GET https://api.kimi.com/coding/v1/usages — the weekly
// `usage` block only ever includes `used`, never `remaining`.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({
usage: { limit: '100', used: '100', resetTime: '2026-08-04T06:21:48.514003Z' },
limits: [{
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
detail: { limit: '100', remaining: '100', resetTime: '2026-08-03T07:21:48.514003Z' },
}],
}),
));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.weekly.usedPercent).toBe(100);
expect(result.usage.windows['Rate Limit (300m)'].usedPercent).toBe(0);
});
it('falls back to computing usedPercent from remaining when used is absent', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({
usage: { limit: '2048', remaining: '512', resetTime: '2026-08-04T06:21:48.514003Z' },
limits: [],
}),
));
const result = await fetchQuota();
expect(result.usage.windows.weekly.usedPercent).toBe(75);
});
it('prefers used over remaining when both fields are present', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({
usage: { limit: '100', used: '30', remaining: '999', resetTime: null },
limits: [],
}),
));
const result = await fetchQuota();
expect(result.usage.windows.weekly.usedPercent).toBe(30);
});
it('reports null usedPercent when neither used nor remaining is present', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({
usage: { limit: '100', resetTime: null },
limits: [],
}),
));
const result = await fetchQuota();
expect(result.usage.windows.weekly.usedPercent).toBeNull();
});
it('reports not configured when no credentials are stored', async () => {
vi.doMock('../../opencode/auth.js', () => ({ readAuthFile: () => ({}) }));
vi.resetModules();
const { fetchQuota: fetchQuotaFresh } = await import('./kimi.js');
const result = await fetchQuotaFresh();
expect(result.ok).toBe(false);
expect(result.configured).toBe(false);
expect(result.error).toBe('Not configured');
vi.doUnmock('../../opencode/auth.js');
vi.resetModules();
});
it('surfaces API errors with status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
}));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.configured).toBe(true);
expect(result.error).toBe('API error: 401');
});
});