Skip to content
Open
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
42 changes: 28 additions & 14 deletions src/cli/commands/proxy/connectors/__tests__/desktop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,13 @@ describe('fetchClaudeModels', () => {
json: async () => [
{ base_name: 'claude-sonnet-4-5-20250929' },
{ base_name: 'claude-4-5-sonnet' },
{ base_name: 'claude-sonnet-5' },
{ base_name: 'claude-sonnet-4-6' },
{ base_name: 'claude-opus-4-5-20251101' },
{ base_name: 'claude-opus-4-6-20260205' },
{ base_name: 'claude-opus-4-7' },
{ base_name: 'claude-opus-4-8' },
{ base_name: 'claude-opus-5' },
{ base_name: 'claude-haiku-4-5-20251001' },
{ base_name: 'claude-opus-4-6-vertex' },
{ base_name: 'gpt-5.5-2026-04-24' },
Expand All @@ -219,10 +222,13 @@ describe('fetchClaudeModels', () => {
expect(models).toEqual([
'claude-sonnet-4-5-20250929',
'claude-4-5-sonnet',
'claude-sonnet-5',
'claude-sonnet-4-6',
'claude-opus-4-5-20251101',
'claude-opus-4-6-20260205',
'claude-opus-4-7',
'claude-opus-4-8',
'claude-opus-5',
'claude-haiku-4-5-20251001',
]);
});
Expand Down Expand Up @@ -252,7 +258,15 @@ describe('fetchClaudeModels', () => {
}) as unknown as typeof globalThis.fetch;

const models = await fetchClaudeModels('http://127.0.0.1:4001', 'codemie-proxy');
expect(models).toEqual(['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-haiku-4-5']);
expect(models).toEqual([
'claude-opus-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-5',
'claude-sonnet-4-6',
'claude-haiku-4-5',
]);
});

it('throws when response is not ok', async () => {
Expand Down Expand Up @@ -298,7 +312,7 @@ describe('fetchClaudeModels', () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: mkHeaders('text/html; charset=utf-8'),
json: async () => { throw new SyntaxError("Unexpected token '<'"); },
json: async () => { throw new SyntaxError('Unexpected token '<''); },
}) as unknown as typeof globalThis.fetch;

await expect(fetchClaudeModels('http://127.0.0.1:4001', 'codemie-proxy'))
Expand Down Expand Up @@ -341,11 +355,11 @@ describe('selectPreferredClaudeModels', () => {

it('returns exact matches when present and dated fallbacks otherwise', () => {
expect(selectPreferredClaudeModels(available)).toEqual([
'claude-sonnet-4-6', // exact
'claude-opus-4-7', // exact
'claude-opus-4-7', // exact
'claude-opus-4-6-20260205', // dated fallback
'claude-haiku-4-5-20251001',// dated fallback
]);
'claude-sonnet-4-6', // exact
'claude-haiku-4-5-20251001', // dated fallback
]);
});

it('preserves the order of the preferred list', () => {
Expand Down Expand Up @@ -383,10 +397,10 @@ describe('selectDesktopClaudeModels', () => {
'claude-haiku-4-5-20251001',
]);
expect(result).toEqual([
'claude-sonnet-4-6',
'claude-opus-4-8',
'claude-sonnet-4-6',
'claude-haiku-4-5-20251001',
]);
]);
});

it('falls back to the highest available opus when 4.8 is absent', () => {
Expand All @@ -397,10 +411,10 @@ describe('selectDesktopClaudeModels', () => {
'claude-haiku-4-5-20251001',
]);
expect(result).toEqual([
'claude-sonnet-4-6',
'claude-opus-4-7',
'claude-sonnet-4-6',
'claude-haiku-4-5-20251001',
]);
]);
});

it('uses the next opus down when only 4.6 is available', () => {
Expand Down Expand Up @@ -500,10 +514,10 @@ describe('writeDesktopConfig', () => {
const written = await writeDesktopConfig('http://127.0.0.1:4001', 'codemie-proxy', baseDir, [], statePath);
const config = JSON.parse(await readFile(written, 'utf-8'));
expect(JSON.parse(config.inferenceModels)).toEqual([
{ name: 'claude-sonnet-4-6' },
{ name: 'claude-opus-4-7' },
{ name: 'claude-sonnet-4-6' },
{ name: 'claude-haiku-4-5-20251001' },
]);
]);
});

it('replaces existing inferenceModels entries — does not merge user-added ones', async () => {
Expand All @@ -517,10 +531,10 @@ describe('writeDesktopConfig', () => {
const written = await writeDesktopConfig('http://127.0.0.1:4001', 'codemie-proxy', baseDir, [], statePath);
const config = JSON.parse(await readFile(written, 'utf-8'));
expect(JSON.parse(config.inferenceModels)).toEqual([
{ name: 'claude-sonnet-4-6' },
{ name: 'claude-opus-4-7' },
{ name: 'claude-sonnet-4-6' },
{ name: 'claude-haiku-4-5-20251001' },
]);
]);
});

it('fails fast when discovery returns nothing', async () => {
Expand Down
38 changes: 26 additions & 12 deletions src/cli/commands/proxy/connectors/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,19 @@ interface CodeMieLlmModel {
* resolved ID is what gets written to the Desktop config so the gateway
* receives a model name it has registered.
*
* The opus entries are listed in descending preference (`4-8 → 4-7 → 4-6`):
* {@link selectDesktopClaudeModels} collapses them to the single highest-priority
* opus the gateway actually serves, so Desktop never shows more than one Opus.
* Opus and Sonnet entries are listed in descending preference
* (`opus: 5 → 4-8 → 4-7 → 4-6 → 3`, `sonnet: 5 → 4-6`):
* {@link selectDesktopClaudeModels} collapses each family to the single
* highest-priority ID the gateway actually serves, so Desktop never shows more
* than one Opus or one Sonnet (matching the latest cloud Desktop lineup).
*/
export const PREFERRED_CLAUDE_MODELS = [
'claude-sonnet-4-6',
'claude-opus-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-5',
'claude-sonnet-4-6',
'claude-haiku-4-5',
] as const;

Expand Down Expand Up @@ -225,22 +229,32 @@ export function selectPreferredClaudeModels(
* Build the exact model set Claude Desktop should be offered.
*
* Resolves the curated preferred list via {@link selectPreferredClaudeModels},
* then collapses the opus family to a single entry: the first (highest-priority)
* opus that resolved. With opus ids ordered `4-8 → 4-7 → 4-6` in
* {@link PREFERRED_CLAUDE_MODELS}, this exposes Opus 4.8 when the gateway serves
* it and otherwise falls back to the next-best available opus. Non-opus models
* are passed through untouched and order is preserved.
* then collapses the opus and sonnet families to a single entry each: the first
* (highest-priority) ID that resolved. With preferred order
* `opus: 5 → 4-8 → 4-7 → 4-6 → 3` and `sonnet: 5 → 4-6` in
* {@link PREFERRED_CLAUDE_MODELS}, this exposes Opus 5 / Sonnet 5 when the
* gateway serves them and otherwise falls back to the next-best available ID
* in each family. Haiku and any other non-opus/non-sonnet models are passed
* through untouched; order is preserved.
*/
export function selectDesktopClaudeModels(
available: string[],
preferred: readonly string[] = PREFERRED_CLAUDE_MODELS
): string[] {
const resolved = selectPreferredClaudeModels(available, preferred);
let opusKept = false;
let sonnetKept = false;
return resolved.filter((id) => {
if (!/^claude-opus-/i.test(id)) return true;
if (opusKept) return false;
opusKept = true;
if (/^claude-opus-/i.test(id)) {
if (opusKept) return false;
opusKept = true;
return true;
}
if (/^claude-sonnet-/i.test(id)) {
if (sonnetKept) return false;
sonnetKept = true;
return true;
}
return true;
});
}
Expand Down
61 changes: 46 additions & 15 deletions tests/integration/analytics-auth-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const BIN = join(REPO_ROOT, 'bin', 'codemie.js');

const BLOCK_HEADER =
'CodeMie analytics authentication is invalid — session metrics are NOT being uploaded.';
/** Hard cap per hook invocation; must stay well under vitest's 30s testTimeout. */
const HOOK_TIMEOUT_MS = 20_000;

interface HookResult {
code: number | null;
Expand All @@ -46,7 +48,12 @@ describe('analytics auth gate (codemie hook UserPromptSubmit)', () => {

afterEach(() => {
try {
rmSync(home, { recursive: true, force: true });
rmSync(home, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
} catch {
/* ignore cleanup errors */
}
Expand All @@ -65,8 +72,11 @@ describe('analytics auth gate (codemie hook UserPromptSubmit)', () => {

/**
* Drive `codemie hook` with a UserPromptSubmit event on stdin.
* `extraEnv` supplies the analytics-relevant configuration; the child gets a
* deliberately minimal environment so nothing leaks from the test runner.
*
* The child inherits the runner environment (required on Windows — a stripped
* env without SystemRoot/COMSPEC/TEMP makes Node children hang or fail),
* but every CODEMIE_* var is stripped first, then the isolated temp home and
* the case-specific config are applied.
*/
function runHook(extraEnv: Record<string, string>): HookResult {
const transcript = join(home, 'transcript.jsonl');
Expand All @@ -77,21 +87,42 @@ describe('analytics auth gate (codemie hook UserPromptSubmit)', () => {
transcript_path: transcript,
};

const result = spawnSync('node', [BIN, 'hook'], {
const env: NodeJS.ProcessEnv = { ...process.env };
// Remove any analytics config leaking in from the runner.
for (const key of Object.keys(env)) {
if (key.startsWith("CODEMIE_")) delete env[key];
}
// Isolated home for both POSIX and Windows homedir resolution.
env.HOME = home;
env.USERPROFILE = home;
env.APPDATA = join(home, "AppData", "Roaming");
env.LOCALAPPDATA = join(home, "AppData", "Local");
env.DO_NOT_TRACK = "1";
// Baseline hook config, then the case-specific overrides.
env.CODEMIE_HOME = home;
env.CODEMIE_AGENT = "claude";
env.CODEMIE_SESSION_ID = "codemie-session-under-test";
env.CODEMIE_SKIP_UPDATE_CHECK = "true";
Object.assign(env, extraEnv);

const result = spawnSync(process.execPath, [BIN, "hook"], {
input: JSON.stringify(event),
encoding: 'utf-8',
encoding: "utf-8",
cwd: REPO_ROOT,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
CODEMIE_HOME: home,
CODEMIE_AGENT: 'claude',
CODEMIE_SESSION_ID: 'codemie-session-under-test',
CODEMIE_SKIP_UPDATE_CHECK: 'true',
...extraEnv,
},
env,
timeout: HOOK_TIMEOUT_MS,
killSignal: "SIGKILL",
windowsHide: true,
});

if (result.error) {
throw new Error(
`codemie hook failed to run within ${HOOK_TIMEOUT_MS}ms` +
(result.signal ? ` (killed via ${result.signal})` : "") +
`\nstderr: ${result.stderr ?? ""}`,
);
}

return {
code: result.status,
stderr: result.stderr ?? '',
Expand Down Expand Up @@ -173,4 +204,4 @@ describe('analytics auth gate (codemie hook UserPromptSubmit)', () => {
expect(res.code).toBe(0);
expect(res.stderr).not.toContain(BLOCK_HEADER);
});
});
});
Loading