Merge main and preserve custom provider protocols

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 01:29:16 +03:00
182 changed files with 7459 additions and 2705 deletions
+13
View File
@@ -1,3 +1,16 @@
## [1.22.1] - 2026-09-04
- **OpenCode Go:** the usage request the extension sends to OpenCode Go now carries the `x-opencode-session` header that OpenCode Go requires from 6 September. Chat traffic already had it, because it goes through OpenCode.
- Message queue: a queued message keeps its attached context, file mentions, and skill; editing it brings them back to the composer.
- Thinking effort: picking Default now sticks after a send and across agent or session switches, and a reopened session restores the effort its last message used (thanks to @yulia-ivashko).
- Worktrees: removing a worktree no longer freezes the interface; it runs in the background with a progress toast (thanks to @yulia-ivashko). A worktree created from a branch behind its upstream now fetches first and branches from the remote (thanks to @jtatum).
- Worktrees: the New Worktree dialog keeps its form when the worktree list changes while open, and a removed worktree leaves the sidebar under every project it was listed in (thanks to @yulia-ivashko).
- Sessions: starting a rename selects the whole title (thanks to @yulia-ivashko).
- Settings: the theme no longer flips when switching sessions across directories, and a theme the extension cannot fully report keeps the current preference (thanks to @kydorn).
- Settings: Fixel Text is available as an interface font.
- Usage: exe.dev usage windows are tracked.
- Settings: the Integrations page, which offered the Claude Code and Cursor plugin installs, is gone.
## [1.22.0] - 2026-08-30
- Switching sessions is now visually stable, without conversation jumps or partial rendering.
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "openchamber",
"displayName": "OpenChamber",
"description": "%extension.description%",
"version": "1.22.0",
"version": "1.22.1",
"publisher": "fedaykindev",
"private": true,
"repository": {
@@ -245,7 +245,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.25",
"@opencode-ai/sdk": "1.18.28",
"adm-zip": "^0.6.0",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
+1 -1
View File
@@ -72,7 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited.
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
- `opencode-upgrade-runtime.ts`
@@ -93,7 +93,10 @@ describe('custom provider config persistence (VS Code parity)', () => {
models: { m: { name: 'M' } },
});
assert.equal(unsupported.ok, false);
assert.match(unsupported.error ?? '', /not supported/);
assert.equal(
unsupported.error,
'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic',
);
});
test('upsertProviderConfig writes and round-trips project config', () => {
@@ -177,6 +180,110 @@ describe('custom provider config persistence (VS Code parity)', () => {
assert.deepEqual(written.disabled_providers, ['other']);
});
test('upsertProviderConfig preserves unmanaged provider and model metadata', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
provider: {
'campus-llm': {
npm: '@ai-sdk/openai-compatible',
name: 'Old',
customProviderField: { owner: 'user' },
env: ['OLD_KEY'],
options: {
baseURL: 'https://old.example.edu/v1',
headers: { 'X-Old': '1' },
timeout: 45_000,
},
models: {
retained: {
name: 'Old retained name',
reasoning: true,
attachment: true,
tool_call: true,
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
options: { instructions: 'Keep this instruction' },
variants: {
low: { reasoningEffort: 'low' },
high: { reasoningEffort: 'high' },
},
customModelField: { source: 'manual' },
},
removed: {
name: 'Remove me',
reasoning: true,
},
},
},
},
});
upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
options: { baseURL: 'https://new.example.edu/v1' },
models: {
retained: { name: 'Retained model' },
added: { name: 'Added model' },
},
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath).provider['campus-llm'];
assert.deepEqual(written, {
npm: '@ai-sdk/openai-compatible',
name: 'Campus LLM',
customProviderField: { owner: 'user' },
options: {
baseURL: 'https://new.example.edu/v1',
timeout: 45_000,
},
models: {
retained: {
name: 'Retained model',
reasoning: true,
attachment: true,
tool_call: true,
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
options: { instructions: 'Keep this instruction' },
variants: {
low: { reasoningEffort: 'low' },
high: { reasoningEffort: 'high' },
},
customModelField: { source: 'manual' },
},
added: { name: 'Added model' },
},
});
});
test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
providers: {
legacy: {
name: 'Legacy provider',
options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 },
models: { model: { name: 'Old model', reasoning: true } },
},
},
});
upsertProviderConfig('legacy', {
name: 'Updated provider',
options: { baseURL: 'https://new.example.com/v1' },
models: { model: { name: 'Updated model' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
assert.equal(written.providers, undefined);
assert.deepEqual(written.provider.legacy, {
npm: '@ai-sdk/openai-compatible',
name: 'Updated provider',
options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 },
models: { model: { name: 'Updated model', reasoning: true } },
});
});
test('upsert then remove restores absence', () => {
upsertProviderConfig('temp-provider', {
name: 'Temp',
+71 -6
View File
@@ -2272,6 +2272,21 @@ const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([
'@ai-sdk/anthropic',
]);
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type NormalizedCustomProviderModel = JsonObject & { name: string };
type NormalizedCustomProviderOptions = JsonObject & {
baseURL: string;
headers?: Record<string, string>;
};
type NormalizedCustomProviderConfig = JsonObject & {
npm: string;
name: string;
options: NormalizedCustomProviderOptions;
models: Record<string, NormalizedCustomProviderModel>;
env?: string[];
};
export const validateCustomProviderConfig = (
providerId: string,
config: unknown,
@@ -2292,7 +2307,10 @@ export const validateCustomProviderConfig = (
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) {
return { ok: false as const, error: 'Custom provider npm package is not supported' };
return {
ok: false as const,
error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic',
};
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
@@ -2313,7 +2331,7 @@ export const validateCustomProviderConfig = (
return { ok: false as const, error: 'At least one model is required' };
}
const normalizedModels: Record<string, { name: string }> = {};
const normalizedModels: Record<string, NormalizedCustomProviderModel> = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
@@ -2329,7 +2347,7 @@ export const validateCustomProviderConfig = (
normalizedModels[trimmedId] = { name: modelName };
}
const normalized: Record<string, unknown> = {
const normalized: NormalizedCustomProviderConfig = {
npm,
name,
options: {
@@ -2364,13 +2382,44 @@ export const validateCustomProviderConfig = (
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
(normalized.options as Record<string, unknown>).headers = headers;
normalized.options.headers = headers;
}
}
return { ok: true as const, value: { providerId, config: normalized } };
};
const mergeCustomProviderConfig = (
existingValue: JsonValue | undefined,
normalizedConfig: NormalizedCustomProviderConfig,
) => {
const existing = isPlainObject(existingValue) ? existingValue : {};
const existingOptions = isPlainObject(existing.options) ? existing.options : {};
const mergedOptions = { ...existingOptions, ...normalizedConfig.options };
if (!Object.prototype.hasOwnProperty.call(normalizedConfig.options, 'headers')) {
delete mergedOptions.headers;
}
const existingModels = isPlainObject(existing.models) ? existing.models : {};
const mergedModels = Object.fromEntries(
Object.entries(normalizedConfig.models).map(([modelId, normalizedModel]) => {
const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {};
return [modelId, { ...existingModel, ...normalizedModel }];
}),
);
const merged = {
...existing,
...normalizedConfig,
options: mergedOptions,
models: mergedModels,
};
if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) {
delete merged.env;
}
return merged;
};
export const upsertProviderConfig = (
providerId: string,
config: unknown,
@@ -2406,8 +2455,24 @@ export const upsertProviderConfig = (
const providerConfig = isPlainObject(targetConfig.provider)
? { ...(targetConfig.provider as Record<string, unknown>) }
: {};
providerConfig[validated.value.providerId] = validated.value.config;
const providersAlias = isPlainObject(targetConfig.providers)
? { ...targetConfig.providers }
: {};
const existingProviderValue = providerConfig[validated.value.providerId]
?? providersAlias[validated.value.providerId];
// SAFETY: config layers come from the JSONC parser, so provider entries are JSON values.
const existingProvider = existingProviderValue as JsonValue | undefined;
const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config);
providerConfig[validated.value.providerId] = mergedConfig;
targetConfig.provider = providerConfig;
if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) {
delete providersAlias[validated.value.providerId];
if (Object.keys(providersAlias).length === 0) {
delete targetConfig.providers;
} else {
targetConfig.providers = providersAlias;
}
}
if (Array.isArray(targetConfig.disabled_providers)) {
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
@@ -2421,7 +2486,7 @@ export const upsertProviderConfig = (
return {
providerId: validated.value.providerId,
path: writePath,
config: validated.value.config,
config: mergedConfig,
};
};
+1 -1
View File
@@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
return sendBridgeMessage<boolean>('api:git/check', { directory });
},
getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise<GitStatus> => {
getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> => {
return sendBridgeMessage<GitStatus>('api:git/status', { directory, mode: options?.mode });
},
+8
View File
@@ -384,6 +384,14 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
return unsupportedWebRouteResponse('Remote tunnel settings');
}
// Archiving a batch of sessions server-side needs an OpenChamber server
// process; the extension host has none. Answering explicitly keeps the
// shared UI on its per-session archive path instead of leaving the request
// to the generic proxy.
if (normalizedPathname === '/api/openchamber/sessions/archive') {
return unsupportedWebRouteResponse('Server-side session archiving');
}
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
return unsupportedWebRouteResponse('Scheduled tasks');
}