Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions core/util/GlobalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type GlobalContextType = {
selectedModelsByProfileId: {
[profileId: string]: GlobalContextModelSelections;
};
cliSelectedModel?: string; // CLI-specific model selection for unauthenticated users

/**
* This is needed to handle the case where a JetBrains user has created
Expand Down
29 changes: 25 additions & 4 deletions extensions/cli/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import nodeFetch from "node-fetch";
import open from "open";

import { getApiClient } from "../config.js";
// eslint-disable-next-line import/order
import { env } from "../env.js";
if (!globalThis.fetch) {
globalThis.fetch = nodeFetch as unknown as typeof globalThis.fetch;
Expand Down Expand Up @@ -96,13 +97,24 @@ export function getConfigUri(config: AuthConfig): string | null {

/**
* Gets the model name from any auth config type
* For unauthenticated users or when auth config has no modelName, checks GlobalContext
*/
export function getModelName(config: AuthConfig): string | null {
if (config === null) return null;
return config.modelName || null;
// Priority 1: Logged-in users with modelName in auth config
if (config !== null && config.modelName) {
return config.modelName;
}

// Priority 2: Fall back to GlobalContext (for logged-out users or logged-in without modelName)
return getPersistedModelName();
}

// URI utility functions have been moved to ./uriUtils.ts
import {
getPersistedModelName,
persistModelName,
} from "../util/modelPersistence.js";

import { autoSelectOrganizationAndConfig } from "./orgSelection.js";
import { pathToUri, slugToUri, uriToPath, uriToSlug } from "./uriUtils.js";
import {
Expand Down Expand Up @@ -214,21 +226,30 @@ export function updateConfigUri(configUri: string | null): void {

/**
* Updates the model name in the authentication configuration
* Returns the updated config so the caller can update in-memory state
* For unauthenticated users, saves to GlobalContext
*/
export function updateModelName(modelName: string | null): void {
export function updateModelName(modelName: string | null): AuthConfig {
// If using CONTINUE_API_KEY environment variable, don't save anything
if (process.env.CONTINUE_API_KEY) {
return;
return loadAuthConfig();
}

const config = loadAuthConfig();

// If logged in, save to auth.json
if (config && isAuthenticatedConfig(config)) {
const updatedConfig: AuthenticatedConfig = {
...config,
modelName: modelName || undefined,
};
saveAuthConfig(updatedConfig);
return updatedConfig;
}

// If logged out, save to GlobalContext
persistModelName(modelName);
return config;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ models:

if (configExists) {
const configContent = await fs.readFile(configPath, "utf-8");
expect(configContent).toContain("anthropic/claude-4-sonnet");
expect(configContent).toContain("anthropic/claude-sonnet-4-5");
expect(configContent).toContain(
"ANTHROPIC_API_KEY: TEST-test-invalid-key-format",
);
Expand Down
205 changes: 205 additions & 0 deletions extensions/cli/src/integration/model-persistence-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { AssistantUnrolled, ModelConfig } from "@continuedev/config-yaml";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import {
AuthenticatedConfig,
getModelName,
loadAuthConfig,
saveAuthConfig,
updateModelName,
} from "../auth/workos.js";
import { persistModelName } from "../util/modelPersistence.js";

Check failure on line 15 in extensions/cli/src/integration/model-persistence-e2e.test.ts

View workflow job for this annotation

GitHub Actions / lint

`../util/modelPersistence.js` import should occur after import of `../services/ModelService.js`
import * as config from "../config.js";
import { ModelService } from "../services/ModelService.js";

// Mock the config module
vi.mock("../config.js");

describe("Model Persistence End-to-End", () => {
let testDir: string;
let originalContinueHome: string | undefined;
let mockAssistant: AssistantUnrolled;
let mockAuthConfig: AuthenticatedConfig;
const mockLlmApi = { complete: vi.fn(), stream: vi.fn() };

beforeEach(() => {
vi.clearAllMocks();

// Create a temporary directory for testing
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-test-"));
originalContinueHome = process.env.CONTINUE_GLOBAL_DIR;
process.env.CONTINUE_GLOBAL_DIR = testDir;

// Clear GlobalContext for clean test state
persistModelName(null);

mockAssistant = {
name: "test-assistant",
version: "1.0.0",
models: [
{
provider: "openai",
model: "gpt-4",
name: "GPT-4",
apiKey: "test-key",
roles: ["chat"],
} as ModelConfig,
{
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
name: "Claude 3.5 Sonnet",
apiKey: "test-key",
roles: ["chat"],
} as ModelConfig,
{
provider: "anthropic",
model: "claude-3-opus-20240229",
name: "Claude 3 Opus",
apiKey: "test-key",
roles: ["chat"],
} as ModelConfig,
],
} as AssistantUnrolled;

mockAuthConfig = {
userId: "test-user",
userEmail: "[email protected]",
accessToken: "test-token",
refreshToken: "test-refresh",
expiresAt: Date.now() + 3600000, // 1 hour from now
organizationId: "test-org",
};

// Setup default mock behavior
vi.mocked(config.getLlmApi).mockReturnValue([
mockLlmApi as any,
mockAssistant.models![0] as ModelConfig,
]);
vi.mocked(config.createLlmApi).mockReturnValue(mockLlmApi as any);
});

afterEach(() => {
// Cleanup
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
if (originalContinueHome) {
process.env.CONTINUE_GLOBAL_DIR = originalContinueHome;
} else {
delete process.env.CONTINUE_GLOBAL_DIR;
}
});

test("should restore model selection after restart", async () => {
// Step 1: Initial session - user starts with default model (GPT-4)
saveAuthConfig(mockAuthConfig);
let service = new ModelService();
let state = await service.initialize(mockAssistant, mockAuthConfig);

console.log("Initial model:", state.model?.name);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule violated: Don't use console.log

New console.log statements were added to this test, but the guideline requires using the structured logger instead of console.* calls. Please swap these console logs out for the approved logging utility (and apply the same change to the other new console.log lines mentioned in the evidence).

Prompt for AI agents
Address the following comment on extensions/cli/src/integration/model-persistence-e2e.test.ts at line 103:

<comment>New console.log statements were added to this test, but the guideline requires using the structured logger instead of console.* calls. Please swap these console logs out for the approved logging utility (and apply the same change to the other new console.log lines mentioned in the evidence).</comment>

<file context>
@@ -0,0 +1,205 @@
+    let service = new ModelService();
+    let state = await service.initialize(mockAssistant, mockAuthConfig);
+
+    console.log(&quot;Initial model:&quot;, state.model?.name);
+    expect(state.model?.name).toBe(&quot;GPT-4&quot;);
+
</file context>
Fix with Cubic

expect(state.model?.name).toBe("GPT-4");

// Step 2: User switches to Claude 3.5 Sonnet (index 1)
await service.switchModel(1);
state = service.getState();

console.log("After switch:", state.model?.name);
expect(state.model?.name).toBe("Claude 3.5 Sonnet");

// Step 3: Persist the model choice (this should happen in useModelSelector)
updateModelName("Claude 3.5 Sonnet");

// Verify it was saved to auth.json
const savedConfig = loadAuthConfig();
console.log("Saved model name:", getModelName(savedConfig));
expect(getModelName(savedConfig)).toBe("Claude 3.5 Sonnet");

// Step 4: Simulate restart - create new service instance
// Load fresh auth config from disk (this is what the real code does)
const freshAuthConfig = loadAuthConfig();
console.log("Fresh auth config model name:", getModelName(freshAuthConfig));

service = new ModelService();
state = await service.initialize(mockAssistant, freshAuthConfig);

// Step 5: Verify the persisted model is restored
console.log("After restart:", state.model?.name);
expect(state.model?.name).toBe("Claude 3.5 Sonnet");
expect(state.model?.provider).toBe("anthropic");
});

test("should handle model name mismatch gracefully", async () => {
// Save auth config with a model that doesn't exist
mockAuthConfig.modelName = "Non-existent Model";
saveAuthConfig(mockAuthConfig);

const service = new ModelService();
const state = await service.initialize(mockAssistant, mockAuthConfig);

// Should fall back to first available model (GPT-4)
expect(state.model?.name).toBe("GPT-4");
});

test("should check both name and model fields when matching", async () => {
// Some configs might have model field instead of name
const assistantWithModelField = {
...mockAssistant,
models: [
{
provider: "openai",
model: "gpt-4",
// No name field, just model
apiKey: "test-key",
roles: ["chat"],
} as ModelConfig,
{
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
// No name field, just model
apiKey: "test-key",
roles: ["chat"],
} as ModelConfig,
],
} as AssistantUnrolled;

mockAuthConfig.modelName = "claude-3-5-sonnet-20241022";
saveAuthConfig(mockAuthConfig);

const service = new ModelService();
const state = await service.initialize(
assistantWithModelField,
mockAuthConfig,
);

// Should match by model field
expect(state.model?.model).toBe("claude-3-5-sonnet-20241022");
});

test("should persist model through multiple switches", async () => {
saveAuthConfig(mockAuthConfig);
const service = new ModelService();
await service.initialize(mockAssistant, mockAuthConfig);

// Switch to Claude 3.5 Sonnet
await service.switchModel(1);
updateModelName("Claude 3.5 Sonnet");
expect(getModelName(loadAuthConfig())).toBe("Claude 3.5 Sonnet");

// Switch to Claude 3 Opus
await service.switchModel(2);
updateModelName("Claude 3 Opus");
expect(getModelName(loadAuthConfig())).toBe("Claude 3 Opus");

// Restart and verify last selection
const freshAuthConfig = loadAuthConfig();
console.log("Fresh auth config model name:", getModelName(freshAuthConfig));

const newService = new ModelService();
const state = await newService.initialize(mockAssistant, freshAuthConfig);
expect(state.model?.name).toBe("Claude 3 Opus");
});
});
Loading
Loading