diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index 5eb38fab..e701f395 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin body: JSON.stringify({ prompt: trimmed, system: NOTES_SYSTEM_PROMPT, + sessionID: sessionId || undefined, restrictToPreferredProvider: true, ...(preferredProviderID ? { preferredProviderID } : {}), ...(preferredModelID ? { preferredModelID } : {}), diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts index eb9739c2..616d736b 100644 --- a/packages/vscode/src/opencodeGoQuota.ts +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({ }); export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => { - const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) }); + const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}`, 'x-opencode-session': 'openchamber-usage' }, signal: AbortSignal.timeout(15_000) }); if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed'); if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`); const payload = await response.json().catch(() => null) as { usage?: Record } | null; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 783583a5..62a8f014 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { assert.equal(result.ok, true); assert.equal((request?.headers as Record).Authorization, 'Bearer test-token'); + assert.equal((request?.headers as Record)['x-opencode-session'], 'openchamber-usage'); assert.equal(result.usage!.windows['5h']!.usedPercent, 25); assert.throws(() => fs.statSync(legacyPath)); }); diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 7158d7f4..6cc0b45a 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -52,7 +52,7 @@ All providers should return results via shared helpers to preserve API shape: Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`. `fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data. -exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. +exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token and the stable `x-opencode-session: openchamber-usage` workload id. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. Command Code usage resolves account scope through `GET /alpha/whoami`, then reads server-backed credit balances and five-hour/weekly limits from `GET /alpha/billing/credits?orgId=...`. Personal accounts return `org: null` and use `/alpha/billing/credits` without an `orgId`; organization accounts include their organization id. Web/Electron and VS Code read the standard `command-code` OpenCode auth entry (including OAuth `access`) or `COMMAND_CODE_API_KEY`; credentials remain in the owning runtime and are never returned to shared UI. diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js index 2642cf26..b6b97370 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.js +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, + 'x-opencode-session': 'openchamber-usage', 'User-Agent': 'OpenChamber quota provider', }, signal: AbortSignal.timeout(15_000), diff --git a/packages/web/server/lib/quota/providers/opencode-go.test.js b/packages/web/server/lib/quota/providers/opencode-go.test.js index 1bae1eb0..505cbee5 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.test.js +++ b/packages/web/server/lib/quota/providers/opencode-go.test.js @@ -43,7 +43,11 @@ describe('OpenCode Go quota provider', () => { return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })); }); expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage'); - expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' }); + expect(request.options.headers).toMatchObject({ + Accept: 'application/json', + Authorization: 'Bearer secret', + 'x-opencode-session': 'openchamber-usage', + }); expect(request.options.headers.Cookie).toBeUndefined(); expect(usage['5h'].usedPercent).toBe(25); }); diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js index ab4b1f45..c479011d 100644 --- a/packages/web/server/lib/session-assist/runtime.js +++ b/packages/web/server/lib/session-assist/runtime.js @@ -257,6 +257,7 @@ export const createSessionAssistRuntime = ({ prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`, system: buildAssistSystemPrompt(targets), directory, + sessionID: sessionId, preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/session-goal/create.js b/packages/web/server/lib/session-goal/create.js index 2255f9dc..73fad9c3 100644 --- a/packages/web/server/lib/session-goal/create.js +++ b/packages/web/server/lib/session-goal/create.js @@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => { + '\n'; }; -const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => { +const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => { if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective; let distilled = null; @@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn }) 'Write in the same language as the task text.', ].join('\n'), directory, + sessionID, preferredProviderID: providerID, preferredModelID: modelID, }); @@ -66,6 +67,7 @@ export const createSessionGoal = async ({ const objectiveText = await fitObjective({ objective: String(objective ?? '').trim(), directory, + sessionID, providerID, modelID, warn, diff --git a/packages/web/server/lib/session-goal/runtime.js b/packages/web/server/lib/session-goal/runtime.js index f97af98d..89c21256 100644 --- a/packages/web/server/lib/session-goal/runtime.js +++ b/packages/web/server/lib/session-goal/runtime.js @@ -378,6 +378,7 @@ export const createSessionGoalRuntime = ({ prompt: `The goal objective:\n\n\n${goal.objective}\n\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`, system: buildAuditSystemPrompt(), directory, + sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined, preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index baee6280..cb4a2b07 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -91,6 +91,10 @@ other runtime API. a blocker instead of a raw 500 message. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: + - OpenCode-hosted providers receive `x-opencode-session`. Session-backed + features reuse the real OpenCode session id, walkthrough retries reuse the + walkthrough cache key, and standalone one-shot actions receive a fresh + opaque id for that generation. - **GitHub Copilot**: fetches the requested model's authenticated `/models` metadata from `https://api.githubcopilot.com` (or `copilot-api.`) and honors its advertised endpoint, preferring diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 9a7d4e95..7703aa74 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { randomUUID } from 'node:crypto'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; @@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID || null; } -export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { +export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); const runtimeProvider = await getRuntimeProvider(providerID); @@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider baseURL, // Configured headers last: a gateway that authenticates on its own header // must be able to override the bearer default rather than sit beside it. - headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + headers: mergeHeadersCaseInsensitive( + mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + providerID.startsWith('opencode') + ? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() } + : null, + ), modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 7c2a5315..31456f0d 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => { }); describe('catalog-based base URL (no config override)', () => { + it('identifies OpenCode Go requests with the owning conversation', async () => { + readConfig.mockReturnValue({}); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { 'opencode-go': { type: 'api', key: 'go-key' } }, + catalog: { + 'opencode-go': { + id: 'opencode-go', + api: 'https://opencode.ai/zen/go/v1', + models: { utility: { id: 'utility' } }, + }, + }, + workingDirectory: '/proj', + sessionID: 'ses_conversation', + providerID: 'opencode-go', + modelID: 'utility', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation'); + }); + it('uses the catalog api field when no config baseURL is set', async () => { readConfig.mockReturnValue({}); fetchMock.mockResolvedValue(ok('ok')); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 667eb526..e59c0fbf 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => { * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ -export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } @@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, auth, catalog, workingDirectory: directory, + sessionID, providerID: resolved.providerID, modelID: resolved.modelID, prompt: clamped.prompt, diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index 461e0992..9431ff2f 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { app.post('/api/small-model/generate', async (req, res) => { try { const { generateSmallModelText } = await getSmallModelService(); - const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; + const { prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; const result = await generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, + sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider: restrictToPreferredProvider === true, diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 601ca0dc..38ac5a56 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -474,6 +474,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit prompt: options.prompt, system: options.system, directory, + sessionID: `openchamber-walkthrough-${cacheKey}`, model: `${model.providerID}/${model.modelID}`, responseSchema: options.responseSchema, onOverflow: 'error',