Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ The SSO credentials remain in CodeMie and are never added to VS Code. The connec
For a new provider or an invalid existing key, the command prints this required one-time action:

```text
Press ⇧⌘P (macOS) or Ctrl+Shift+P (Windows/Linux)
Run Chat: Manage Language Models
Right-click CodeMie Profile Model → Update API Key
Enter API key: codemie-proxy
One-time VS Code secret setup required:
1. Open VS Code and Press ⇧⌘P (macOS) or Ctrl+Shift+P (Windows/Linux).
2. Find Chat: Manage Language Models
3. In opened dialog Right-click any CodeMie model → Update API Key
4. Enter API key: codemie-proxy
Reload VS Code, then select a CodeMie model from the model picker
```

VS Code stores that local key in its secret storage and adds the reference to the configuration. The CLI cannot inspect whether an existing secret reference still resolves; if VS Code reports a missing or invalid key, use **Update API Key** again.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project cli",
"test:integration:cli": "vitest run --project cli",
"test:integration:vscode-models": "vitest run --project cli tests/integration/vscode-byok.test.ts tests/integration/vscode-models.live.test.ts",
"test:integration:agent": "vitest run --project agent",
"test:run": "vitest run --project unit --project cli",
"test:all": "vitest run --project unit && vitest run --project cli && vitest run --project agent",
Expand Down
7 changes: 2 additions & 5 deletions src/cli/commands/proxy/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ describe('proxy connect vscode', () => {
exitSpy.mockRestore();
});

it('uses an explicit profile for daemon context and writes its model into VS Code', async () => {
it('uses an explicit profile without requiring its model for VS Code', async () => {
const { ConfigLoader } = await import('../../../../utils/config.js');
const { checkStatus, spawnDaemon } = await import('../daemon-manager.js');
const { writeVsCodeLanguageModelsConfig } = await import('../connectors/vscode.js');
Expand All @@ -332,7 +332,6 @@ describe('proxy connect vscode', () => {
name: 'custom',
provider: 'ai-run-sso',
baseUrl: 'https://custom.example.com/api',
model: 'custom-profile-model',
codeMieProject: 'team-project',
codeMieUrl: 'https://custom.example.com',
} as Awaited<ReturnType<typeof ConfigLoader.load>>);
Expand Down Expand Up @@ -365,12 +364,11 @@ describe('proxy connect vscode', () => {
expect(daemonOptions).not.toHaveProperty('model');
expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith(
'http://127.0.0.1:4001',
'custom-profile-model',
false
);
});

it('reuses a matching daemon when only the profile model changes', async () => {
it('reuses a matching daemon independently of the profile model', async () => {
const { ConfigLoader } = await import('../../../../utils/config.js');
const { checkStatus, spawnDaemon, stopDaemon } = await import('../daemon-manager.js');
const { checkProxyHealth } = await import('../health-check.js');
Expand Down Expand Up @@ -407,7 +405,6 @@ describe('proxy connect vscode', () => {
expect(spawnDaemon).not.toHaveBeenCalled();
expect(writeVsCodeLanguageModelsConfig).toHaveBeenCalledWith(
'http://127.0.0.1:4001',
'new-profile-model',
false
);
});
Expand Down
210 changes: 159 additions & 51 deletions src/cli/commands/proxy/connectors/__tests__/vscode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,44 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { VS_CODE_SUPPORTED_MODELS, type VsCodeApiType } from '../vscode-models.js';
import { writeVsCodeLanguageModelsConfigAtPath } from '../vscode.js';

const EXPECTED_MODEL_IDS = [
'claude-sonnet-4-5-20250929',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-5-2025-08-07',
'gpt-5-mini-2025-08-07',
'gpt-5-nano-2025-08-07',
'gpt-5-1-codex-2025-11-13',
'gpt-5-2-2025-12-11',
'gpt-5.4-2026-03-05',
'gpt-5.5-2026-04-24',
'gpt-5.6-luna-2026-07-09',
'gpt-5.6-terra-2026-07-09',
'gemini-3-flash',
'gemini-3.1-pro',
'gemini-3.5-flash',
'claude-4-5-sonnet',
'claude-sonnet-4-6',
'claude-sonnet-5',
'claude-opus-4-5-20251101',
'claude-opus-4-6-20260205',
'claude-opus-4-7',
'claude-opus-4-8',
'claude-haiku-4-5-20251001',
'qwen.qwen3-coder-30b-a3b-v1',
'qwen.qwen3-coder-480b-a35b-v1',
'moonshotai.kimi-k2.5',
] as const;

function getApiPath(apiType: VsCodeApiType): string {
if (apiType === 'responses') return '/v1/responses';
if (apiType === 'messages') return '/v1/messages';
return '/v1/chat/completions';
}

describe('writeVsCodeLanguageModelsConfigAtPath', () => {
let testDir: string;
let configPath: string;
Expand All @@ -27,26 +63,107 @@ describe('writeVsCodeLanguageModelsConfigAtPath', () => {
return JSON.parse(await readFile(configPath, 'utf-8')) as Array<Record<string, unknown>>;
}

it('writes the selected profile model as the model ID', async () => {
it('writes the exact supported model allowlist under the CodeMie provider', async () => {
const result = await writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4001',
'gpt-profile-model'
'http://127.0.0.1:4001'
);

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;
const provider = providers[0];
const models = provider.models as Array<Record<string, unknown>>;

expect(result).toEqual({ configPath, requiresSecretConfiguration: true });
expect(models).toEqual([
expect.objectContaining({
id: 'gpt-profile-model',
name: 'CodeMie Profile Model',
url: 'http://127.0.0.1:4001/v1/chat/completions',
}),
]);
expect(provider).toMatchObject({
name: 'CodeMie',
vendor: 'customendpoint',
apiType: 'chat-completions',
});
expect(models.map(model => model.id)).toEqual(EXPECTED_MODEL_IDS);
expect(models.map(model => model.name)).toEqual(EXPECTED_MODEL_IDS);
});

it('renders every catalog capability and endpoint without internal metadata', async () => {
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4001');

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;

for (const definition of VS_CODE_SUPPORTED_MODELS) {
const model = models.find(candidate => candidate.id === definition.id);
const expected: Record<string, unknown> = {
id: definition.id,
name: definition.id,
url: `http://127.0.0.1:4001${getApiPath(definition.apiType)}`,
apiType: definition.apiType,
toolCalling: true,
vision: definition.vision,
streaming: true,
thinking: definition.thinking,
maxInputTokens: definition.maxInputTokens,
maxOutputTokens: definition.maxOutputTokens,
};
if (definition.adaptiveThinking) expected.adaptiveThinking = true;
if (definition.modelOptions) expected.modelOptions = definition.modelOptions;
if (definition.requestHeaders) expected.requestHeaders = definition.requestHeaders;
if (definition.supportsReasoningEffort) {
expected.supportsReasoningEffort = definition.supportsReasoningEffort;
}
if (definition.reasoningEffortFormat) {
expected.reasoningEffortFormat = definition.reasoningEffortFormat;
}

expect(model).toEqual(expected);
}
});

it('omits top_p for Claude 4.5 models that reject dual sampling parameters', async () => {
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4001');

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;
const affectedIds = [
'claude-sonnet-4-5-20250929',
'claude-4-5-sonnet',
'claude-haiku-4-5-20251001',
];

for (const id of affectedIds) {
expect(models.find(model => model.id === id)).toMatchObject({
modelOptions: { top_p: null },
});
}
});

it('does not advertise the rejected none effort for GPT-5.1 Codex', async () => {
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4001');

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;
const codex = models.find(model => model.id === 'gpt-5-1-codex-2025-11-13');

expect(codex?.supportsReasoningEffort).toEqual(['low', 'medium', 'high']);
});

it('forces bearer authentication for Messages models only', async () => {
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4001');

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;

for (const definition of VS_CODE_SUPPORTED_MODELS) {
const model = models.find(candidate => candidate.id === definition.id);
if (definition.apiType === 'messages') {
expect(model?.requestHeaders).toEqual({
Authorization: 'Bearer ${apiKey}',
});
} else {
expect(model).not.toHaveProperty('requestHeaders');
}
}
});

it('updates the managed model and preserves unrelated configuration', async () => {
it('overrides the CodeMie model catalog while preserving the secret and saved settings', async () => {
const secretReference = '${input:chat.lm.secret.codemie}';
await writeFile(configPath, JSON.stringify([
{
Expand All @@ -57,66 +174,66 @@ describe('writeVsCodeLanguageModelsConfigAtPath', () => {
{
name: 'CodeMie',
vendor: 'customendpoint',
apiType: 'chat-completions',
apiType: 'messages',
apiKey: secretReference,
customProperty: 'preserved',
settings: {
'old-profile-model': { reasoningEffort: 'medium' },
'gpt-5.5-2026-04-24': { reasoningEffort: 'high' },
'custom-setting': { enabled: true },
},
models: [
{ id: 'old-profile-model', name: 'CodeMie Profile Model', stale: true },
{ id: 'stale-catalog-model', name: 'Stale catalog model', stale: true },
{ id: 'user-managed-model', name: 'User model', custom: true },
],
},
], null, 2), 'utf-8');

const result = await writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4010',
'claude-profile-model'
'http://127.0.0.1:4010'
);

const providers = await readProviders();
const codemie = providers[1];
const codeMie = providers[1];
const models = codeMie.models as Array<Record<string, unknown>>;

expect(result.requiresSecretConfiguration).toBe(false);
expect(providers[0]).toMatchObject({ name: 'Other' });
expect(codemie).toMatchObject({
expect(providers[0]).toEqual({
name: 'Other',
vendor: 'customendpoint',
models: [{ id: 'other-model', name: 'Other model' }],
});
expect(codeMie).toMatchObject({
name: 'CodeMie',
vendor: 'customendpoint',
apiType: 'chat-completions',
apiKey: secretReference,
customProperty: 'preserved',
settings: { 'custom-setting': { enabled: true } },
settings: {
'gpt-5.5-2026-04-24': { reasoningEffort: 'high' },
'custom-setting': { enabled: true },
},
});
expect(codemie.models).toEqual([
expect.objectContaining({ id: 'claude-profile-model', name: 'CodeMie Profile Model' }),
{ id: 'user-managed-model', name: 'User model', custom: true },
]);
expect(models.map(model => model.id)).toEqual(EXPECTED_MODEL_IDS);
expect(models.some(model => model.id === 'user-managed-model')).toBe(false);
});

it('replaces the previous profile model when the selected profile changes', async () => {
await writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4001',
'first-profile-model'
);
it('updates every managed endpoint without changing saved model settings', async () => {
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4001');
const firstProviders = await readProviders();
firstProviders[0].settings = {
'first-profile-model': { reasoningEffort: 'high' },
unrelated: { enabled: true },
'claude-opus-4-8': { reasoningEffort: 'xhigh' },
};
await writeFile(configPath, JSON.stringify(firstProviders, null, 2), 'utf-8');

await writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4001',
'second-profile-model'
);
await writeVsCodeLanguageModelsConfigAtPath(configPath, 'http://127.0.0.1:4010');

const providers = await readProviders();
const models = providers[0].models as Array<Record<string, unknown>>;
expect(models).toHaveLength(1);
expect(models[0].id).toBe('second-profile-model');
expect(providers[0].settings).toEqual({ unrelated: { enabled: true } });
expect(models.every(model => String(model.url).startsWith('http://127.0.0.1:4010/'))).toBe(true);
expect(providers[0].settings).toEqual({
'claude-opus-4-8': { reasoningEffort: 'xhigh' },
});
});

it.each([
Expand All @@ -127,18 +244,9 @@ describe('writeVsCodeLanguageModelsConfigAtPath', () => {

await expect(writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4001',
'profile-model'
'http://127.0.0.1:4001'
)).rejects.toThrow();

expect(await readFile(configPath, 'utf-8')).toBe(original);
});

it('rejects an empty profile model before writing configuration', async () => {
await expect(writeVsCodeLanguageModelsConfigAtPath(
configPath,
'http://127.0.0.1:4001',
' '
)).rejects.toThrow('requires a profile model');
});
});
Loading
Loading