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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user