fix: Model switch re-applies selected profile (sync task.apiConfiguration) (#9179) (#9181)

This commit is contained in:
Hannes Rudolph 2025-11-11 18:11:16 -07:00 committed by GitHub
parent 6e6341346e
commit 2c917243d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 84 additions and 18 deletions

View file

@ -1298,27 +1298,28 @@ export class ClineProvider
/**
* Updates the current task's API handler if the provider or model has changed.
* This prevents unnecessary context condensing when only non-model settings change.
* Also synchronizes the task.apiConfiguration so subsequent comparisons and logic
* (protocol selection, reasoning display, model metadata) use the latest profile.
* @param providerSettings The new provider settings to apply
*/
private updateTaskApiHandlerIfNeeded(providerSettings: ProviderSettings): void {
const task = this.getCurrentTask()
if (!task) return
if (task && task.apiConfiguration) {
// Only rebuild API handler if provider or model actually changed
// to avoid triggering unnecessary context condensing
const currentProvider = task.apiConfiguration.apiProvider
const newProvider = providerSettings.apiProvider
const currentModelId = getModelId(task.apiConfiguration)
const newModelId = getModelId(providerSettings)
// Determine if we need to rebuild using the previous configuration snapshot
const prevConfig = task.apiConfiguration
const prevProvider = prevConfig?.apiProvider
const prevModelId = prevConfig ? getModelId(prevConfig) : undefined
const newProvider = providerSettings.apiProvider
const newModelId = getModelId(providerSettings)
if (currentProvider !== newProvider || currentModelId !== newModelId) {
task.api = buildApiHandler(providerSettings)
}
} else if (task) {
// Fallback: rebuild if apiConfiguration is not available
if (prevProvider !== newProvider || prevModelId !== newModelId) {
task.api = buildApiHandler(providerSettings)
}
// Always sync the task's apiConfiguration with the latest provider settings.
// Note: Task.apiConfiguration is declared readonly in types, so we cast to any for runtime update.
;(task as any).apiConfiguration = providerSettings
}
getProviderProfileEntries(): ProviderSettingsEntry[] {

View file

@ -250,7 +250,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
})
describe("upsertProviderProfile", () => {
test("does NOT rebuild API handler when provider and model unchanged", async () => {
test("does NOT rebuild API handler when provider and model unchanged, but task.apiConfiguration is synced", async () => {
// Create a task with the current config
const mockTask = new Task({
...defaultTaskOptions,
@ -289,6 +289,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
expect(buildApiHandlerMock).not.toHaveBeenCalled()
// Verify the task's api property was NOT reassigned (still same reference)
expect(mockTask.api).toBe(originalApi)
// Verify task.apiConfiguration was synchronized with non-model fields
expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(5)
expect((mockTask as any).apiConfiguration.modelTemperature).toBe(0.7)
})
test("rebuilds API handler when provider changes", async () => {
@ -386,12 +390,13 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
})
describe("activateProviderProfile", () => {
test("does NOT rebuild API handler when provider and model unchanged", async () => {
test("does NOT rebuild API handler when provider and model unchanged, but task.apiConfiguration is synced", async () => {
const mockTask = new Task({
...defaultTaskOptions,
apiConfiguration: {
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
modelTemperature: 0.3,
},
})
const originalApi = {
@ -406,12 +411,14 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
buildApiHandlerMock.mockClear()
// Mock activateProfile to return same provider/model
// Mock activateProfile to return same provider/model but different non-model setting
;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
name: "test-config",
id: "test-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
modelTemperature: 0.9,
rateLimitSeconds: 7,
})
await provider.activateProviderProfile({ name: "test-config" })
@ -420,9 +427,13 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
expect(buildApiHandlerMock).not.toHaveBeenCalled()
// Verify the API reference wasn't changed
expect(mockTask.api).toBe(originalApi)
// Verify task.apiConfiguration was synchronized
expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
expect((mockTask as any).apiConfiguration.modelTemperature).toBe(0.9)
expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(7)
})
test("rebuilds API handler when provider changes", async () => {
test("rebuilds API handler when provider changes and syncs task.apiConfiguration", async () => {
const mockTask = new Task({
...defaultTaskOptions,
apiConfiguration: {
@ -458,9 +469,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
apiModelId: "claude-3-5-sonnet-20241022",
}),
)
// And task.apiConfiguration synced
expect((mockTask as any).apiConfiguration.apiProvider).toBe("anthropic")
expect((mockTask as any).apiConfiguration.apiModelId).toBe("claude-3-5-sonnet-20241022")
})
test("rebuilds API handler when model changes", async () => {
test("rebuilds API handler when model changes and syncs task.apiConfiguration", async () => {
const mockTask = new Task({
...defaultTaskOptions,
apiConfiguration: {
@ -496,6 +510,57 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
openRouterModelId: "anthropic/claude-3-5-sonnet-20241022",
}),
)
// And task.apiConfiguration synced
expect((mockTask as any).apiConfiguration.apiProvider).toBe("openrouter")
expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("anthropic/claude-3-5-sonnet-20241022")
})
})
describe("profile switching sequence", () => {
test("A -> B -> A updates task.apiConfiguration each time", async () => {
const mockTask = new Task({
...defaultTaskOptions,
apiConfiguration: {
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
},
})
mockTask.api = {
getModel: vi.fn().mockReturnValue({
id: "openai/gpt-4",
info: { contextWindow: 128000 },
}),
} as any
await provider.addClineToStack(mockTask)
// First switch: A -> B (openrouter -> anthropic)
buildApiHandlerMock.mockClear()
;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
name: "anthropic-config",
id: "anthropic-id",
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
})
await provider.activateProviderProfile({ name: "anthropic-config" })
expect(buildApiHandlerMock).toHaveBeenCalled()
expect((mockTask as any).apiConfiguration.apiProvider).toBe("anthropic")
expect((mockTask as any).apiConfiguration.apiModelId).toBe("claude-3-5-sonnet-20241022")
// Second switch: B -> A (anthropic -> openrouter gpt-4)
buildApiHandlerMock.mockClear()
;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
name: "test-config",
id: "test-id",
apiProvider: "openrouter",
openRouterModelId: "openai/gpt-4",
})
await provider.activateProviderProfile({ name: "test-config" })
// API handler may or may not rebuild depending on mock model id, but apiConfiguration must be updated
expect((mockTask as any).apiConfiguration.apiProvider).toBe("openrouter")
expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
})
})