Merge main and preserve custom provider protocols
This commit is contained in:
@@ -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,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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user