fix: identify OpenCode Go requests by session
This commit is contained in:
@@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: trimmed,
|
prompt: trimmed,
|
||||||
system: NOTES_SYSTEM_PROMPT,
|
system: NOTES_SYSTEM_PROMPT,
|
||||||
|
sessionID: sessionId || undefined,
|
||||||
restrictToPreferredProvider: true,
|
restrictToPreferredProvider: true,
|
||||||
...(preferredProviderID ? { preferredProviderID } : {}),
|
...(preferredProviderID ? { preferredProviderID } : {}),
|
||||||
...(preferredModelID ? { preferredModelID } : {}),
|
...(preferredModelID ? { preferredModelID } : {}),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
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.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}`);
|
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
||||||
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
|||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||||
|
assert.equal((request?.headers as Record<string, string>)['x-opencode-session'], 'openchamber-usage');
|
||||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||||
assert.throws(() => fs.statSync(legacyPath));
|
assert.throws(() => fs.statSync(legacyPath));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()`.
|
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.
|
`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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'x-opencode-session': 'openchamber-usage',
|
||||||
'User-Agent': 'OpenChamber quota provider',
|
'User-Agent': 'OpenChamber quota provider',
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
|||||||
@@ -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' } } }));
|
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.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(request.options.headers.Cookie).toBeUndefined();
|
||||||
expect(usage['5h'].usedPercent).toBe(25);
|
expect(usage['5h'].usedPercent).toBe(25);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}"`,
|
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),
|
system: buildAssistSystemPrompt(targets),
|
||||||
directory,
|
directory,
|
||||||
|
sessionID: sessionId,
|
||||||
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
||||||
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => {
|
|||||||
+ '\n</system-reminder>';
|
+ '\n</system-reminder>';
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
|
||||||
|
|
||||||
let distilled = null;
|
let distilled = null;
|
||||||
@@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn })
|
|||||||
'Write in the same language as the task text.',
|
'Write in the same language as the task text.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
directory,
|
directory,
|
||||||
|
sessionID,
|
||||||
preferredProviderID: providerID,
|
preferredProviderID: providerID,
|
||||||
preferredModelID: modelID,
|
preferredModelID: modelID,
|
||||||
});
|
});
|
||||||
@@ -66,6 +67,7 @@ export const createSessionGoal = async ({
|
|||||||
const objectiveText = await fitObjective({
|
const objectiveText = await fitObjective({
|
||||||
objective: String(objective ?? '').trim(),
|
objective: String(objective ?? '').trim(),
|
||||||
directory,
|
directory,
|
||||||
|
sessionID,
|
||||||
providerID,
|
providerID,
|
||||||
modelID,
|
modelID,
|
||||||
warn,
|
warn,
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export const createSessionGoalRuntime = ({
|
|||||||
prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\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()}"`,
|
prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\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(),
|
system: buildAuditSystemPrompt(),
|
||||||
directory,
|
directory,
|
||||||
|
sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined,
|
||||||
preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
||||||
preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ other runtime API.
|
|||||||
a blocker instead of a raw 500 message.
|
a blocker instead of a raw 500 message.
|
||||||
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
|
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
|
||||||
plugin auth loaders:
|
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`
|
- **GitHub Copilot**: fetches the requested model's authenticated `/models`
|
||||||
metadata from `https://api.githubcopilot.com` (or
|
metadata from `https://api.githubcopilot.com` (or
|
||||||
`copilot-api.<enterprise>`) and honors its advertised endpoint, preferring
|
`copilot-api.<enterprise>`) and honors its advertised endpoint, preferring
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
||||||
import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
|
import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
|
||||||
import { getCatalogProvider } from './catalog.js';
|
import { getCatalogProvider } from './catalog.js';
|
||||||
@@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID
|
|||||||
|| null;
|
|| 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 tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
||||||
const runtimeProvider = await getRuntimeProvider(providerID);
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
||||||
@@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|||||||
baseURL,
|
baseURL,
|
||||||
// Configured headers last: a gateway that authenticates on its own header
|
// Configured headers last: a gateway that authenticates on its own header
|
||||||
// must be able to override the bearer default rather than sit beside it.
|
// 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,
|
modelID,
|
||||||
prompt,
|
prompt,
|
||||||
system,
|
system,
|
||||||
|
|||||||
@@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('catalog-based base URL (no config override)', () => {
|
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 () => {
|
it('uses the catalog api field when no config baseURL is set', async () => {
|
||||||
readConfig.mockReturnValue({});
|
readConfig.mockReturnValue({});
|
||||||
fetchMock.mockResolvedValue(ok('ok'));
|
fetchMock.mockResolvedValue(ok('ok'));
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
|
|||||||
* Generates text with the user's small model, resolved and authenticated
|
* Generates text with the user's small model, resolved and authenticated
|
||||||
* entirely server-side from the OpenCode config and auth store.
|
* 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()) {
|
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||||
throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
|
throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
|
||||||
}
|
}
|
||||||
@@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|||||||
auth,
|
auth,
|
||||||
catalog,
|
catalog,
|
||||||
workingDirectory: directory,
|
workingDirectory: directory,
|
||||||
|
sessionID,
|
||||||
providerID: resolved.providerID,
|
providerID: resolved.providerID,
|
||||||
modelID: resolved.modelID,
|
modelID: resolved.modelID,
|
||||||
prompt: clamped.prompt,
|
prompt: clamped.prompt,
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
|
|||||||
app.post('/api/small-model/generate', async (req, res) => {
|
app.post('/api/small-model/generate', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { generateSmallModelText } = await getSmallModelService();
|
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({
|
const result = await generateSmallModelText({
|
||||||
prompt,
|
prompt,
|
||||||
system,
|
system,
|
||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
model,
|
model,
|
||||||
directory,
|
directory,
|
||||||
|
sessionID,
|
||||||
preferredProviderID,
|
preferredProviderID,
|
||||||
preferredModelID,
|
preferredModelID,
|
||||||
restrictToPreferredProvider: restrictToPreferredProvider === true,
|
restrictToPreferredProvider: restrictToPreferredProvider === true,
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit
|
|||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
system: options.system,
|
system: options.system,
|
||||||
directory,
|
directory,
|
||||||
|
sessionID: `openchamber-walkthrough-${cacheKey}`,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
responseSchema: options.responseSchema,
|
responseSchema: options.responseSchema,
|
||||||
onOverflow: 'error',
|
onOverflow: 'error',
|
||||||
|
|||||||
Reference in New Issue
Block a user