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
56 changes: 56 additions & 0 deletions packages/types/src/__tests__/resolveProviderSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { providerIdentifiers, retiredProviderIdentifiers } from "../provider-identifiers.js"
import {
DEFAULT_API_PROVIDER,
getActiveProvider,
isRetiredProvider,
resolveProviderSettings,
type ProviderSettings,
} from "../provider-settings.js"

describe("resolveProviderSettings", () => {
it("uses the shared default provider when none is set", () => {
expect(DEFAULT_API_PROVIDER).toBe(providerIdentifiers.anthropic)
expect(resolveProviderSettings({}).apiProvider).toBe(DEFAULT_API_PROVIDER)
})

it.each([
{ name: "explicit provider", settings: { apiProvider: providerIdentifiers.openrouter } },
{ name: "retired provider", settings: { apiProvider: retiredProviderIdentifiers.roo } },
] as const)("keeps an $name unchanged", ({ settings }) => {
expect(resolveProviderSettings(settings).apiProvider).toBe(settings.apiProvider)
})

it("keeps retired providers instead of replacing them", () => {
const resolved = resolveProviderSettings({ apiProvider: retiredProviderIdentifiers.groq })

expect(resolved.apiProvider).toBe(retiredProviderIdentifiers.groq)
expect(isRetiredProvider(resolved.apiProvider)).toBe(true)
})

it("preserves the remaining settings", () => {
const resolved = resolveProviderSettings({ apiModelId: "claude-sonnet-4-5", apiKey: "test-key" })

expect(resolved).toEqual({
apiProvider: DEFAULT_API_PROVIDER,
apiModelId: "claude-sonnet-4-5",
apiKey: "test-key",
})
})

it("returns a new object and does not mutate the input", () => {
const settings: ProviderSettings = {}
const resolved = resolveProviderSettings(settings)

expect(resolved).not.toBe(settings)
expect(settings.apiProvider).toBeUndefined()
})
})

describe("getActiveProvider", () => {
it.each([
{ name: "active provider", provider: providerIdentifiers.openrouter, expected: providerIdentifiers.openrouter },
{ name: "retired provider", provider: retiredProviderIdentifiers.roo, expected: undefined },
] as const)("returns $expected for an $name", ({ provider, expected }) => {
expect(getActiveProvider(resolveProviderSettings({ apiProvider: provider }))).toBe(expected)
})
})
50 changes: 50 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,56 @@ export const providerSettingsSchema = z.object({

export type ProviderSettings = z.infer<typeof providerSettingsSchema>

/**
* The provider used when a configuration has no explicit `apiProvider`.
*
* This value is supplied at read time only: `resolveProviderSettings()` fills it
* in for a configuration (or profile) that has no stored `apiProvider`, so an
* unsaved profile follows whatever the default is at that moment.
*
* Resolution does not make the value permanent. Once the resolved configuration
* is saved (for example when the settings view persists the profile it was
* given), `apiProvider` is stored explicitly, and that profile keeps the value
* it was saved with. Later changes to this constant therefore do not affect
* already-saved profiles.
*
* Saves must keep writing `apiProvider` rather than omitting it to "stay on the
* default": the discriminated stored-profile schema requires a provider for
* active providers, and omitting it would silently re-open the profile to future
* default changes.
*/
export const DEFAULT_API_PROVIDER = providerIdentifiers.anthropic satisfies ProviderName

/**
* A `ProviderSettings` whose `apiProvider` is guaranteed to be defined.
*
* The value may still be a retired provider name: resolution only fills in a
* *missing* provider, it does not filter retired ones, because the webview
* needs to see them to show the retired-provider warnings.
*/
export type ResolvedProviderSettings = ProviderSettings & {
apiProvider: NonNullable<ProviderSettings["apiProvider"]> // ProviderName | RetiredProviderName
}

/**
* Returns a new configuration with `apiProvider` filled in when missing.
*
* Does not mutate the input and passes retired provider names through
* unchanged. Consumers that need an *active* provider should narrow the
* result with `isRetiredProvider()` or use `getActiveProvider()`.
*/
export const resolveProviderSettings = (settings: ProviderSettings): ResolvedProviderSettings => ({
...settings,
apiProvider: settings.apiProvider ?? DEFAULT_API_PROVIDER,
})

/**
* Returns the provider when it is still active, or `undefined` for retired
* providers.
*/
export const getActiveProvider = (settings: ResolvedProviderSettings): ProviderName | undefined =>
isRetiredProvider(settings.apiProvider) ? undefined : settings.apiProvider

export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })

export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod"

import type { GlobalSettings, RooCodeSettings } from "./global-settings.js"
import type { ProviderSettings, ProviderSettingsEntry } from "./provider-settings.js"
import type { ProviderSettings, ProviderSettingsEntry, ResolvedProviderSettings } from "./provider-settings.js"
import type { HistoryItem } from "./history.js"
import type { ModeConfig, PromptComponent } from "./mode.js"
import type { Experiments } from "./experiment.js"
Expand Down Expand Up @@ -353,7 +353,7 @@ export type ExtensionState = Pick<
currentTaskId?: string
currentTaskItem?: HistoryItem
currentTaskTodos?: TodoItem[] // Initial todos for the current task
apiConfiguration: ProviderSettings
apiConfiguration: ResolvedProviderSettings
uriScheme?: string
shouldShowAnnouncement: boolean

Expand Down
13 changes: 7 additions & 6 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type HistoryItem,
type ModeConfig,
type ProviderSettings,
type ResolvedProviderSettings,
type ModelInfo,
type TaskLike,
} from "@roo-code/types"
Expand Down Expand Up @@ -76,7 +77,7 @@ type MockedClineProvider = ClineProvider & {
mockResolvedValue: (value: Partial<ProviderState>) => void
}
}
type RateLimitedProviderSettings = ProviderSettings & {
type RateLimitedProviderSettings = ResolvedProviderSettings & {
rateLimitSeconds: number
}

Expand Down Expand Up @@ -321,7 +322,7 @@ const stubModelInfo: ModelInfo = {

describe("Cline", () => {
let mockProvider: ClineProvider
let mockApiConfig: ProviderSettings
let mockApiConfig: ResolvedProviderSettings
let mockOutputChannel: vscode.OutputChannel
let mockExtensionContext: vscode.ExtensionContext

Expand Down Expand Up @@ -3884,7 +3885,7 @@ describe("Cline", () => {

describe("Dynamic Strategy Selection", () => {
let mockProvider: MockedClineProvider
let mockApiConfig: ProviderSettings
let mockApiConfig: ResolvedProviderSettings

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -4093,7 +4094,7 @@ describe("Cline", () => {
})

it("stores a provider profile selected through submitUserMessage", async () => {
const selectedConfiguration: ProviderSettings = {
const selectedConfiguration: ResolvedProviderSettings = {
...mockApiConfig,
apiModelId: "selected-model",
}
Expand Down Expand Up @@ -4825,10 +4826,10 @@ describe("Cline", () => {
})

it("configures tool restrictions for Gemini requests", async () => {
const apiConfiguration = {
const apiConfiguration: ResolvedProviderSettings = {
...mockApiConfig,
apiProvider: providerIdentifiers.gemini,
} as ProviderSettings
}
const task = new Task({
provider: mockProvider,
apiConfiguration,
Expand Down
4 changes: 2 additions & 2 deletions src/core/task/__tests__/Task.streaming-tool-calls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { NativeToolCallParser } from "../../assistant-message/NativeToolCallPars
import { ClineProvider } from "../../webview/ClineProvider"
import { WebviewFocusTracker } from "../../webview/WebviewFocusTracker"
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
import type { ProviderSettings } from "@roo-code/types"
import type { ResolvedProviderSettings } from "@roo-code/types"
import { ApiStreamChunk } from "../../../api/transform/stream"
import { ContextProxy } from "../../config/ContextProxy"
import { TelemetryService } from "@roo-code/telemetry"
Expand Down Expand Up @@ -175,7 +175,7 @@ vi.mock("../../../utils/fs", () => ({

describe("Task - Streaming Tool Call Handling", () => {
let mockProvider: ClineProvider
let mockApiConfig: ProviderSettings
let mockApiConfig: ResolvedProviderSettings
let mockOutputChannel: vscode.OutputChannel
let scope: object
let mockExtensionContext: vscode.ExtensionContext
Expand Down
35 changes: 13 additions & 22 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
type TaskProviderLike,
type TaskProviderEvents,
type GlobalState,
type ProviderName,
type ProviderSettings,
type RooCodeSettings,
type ProviderSettingsEntry,
Expand Down Expand Up @@ -69,10 +68,11 @@ import {
ORGANIZATION_ALLOW_ALL,
DEFAULT_MODES,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
getActiveProvider,
getModelId,
isRetiredProvider,
modelIdKeys,
providerIdentifiers,
resolveProviderSettings,
} from "@roo-code/types"
import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock"
import { TaskRegistry } from "../task/TaskRegistry"
Expand Down Expand Up @@ -2070,8 +2070,9 @@ export class ClineProvider
async (signal) => {
const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name })

// Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState().
const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic
// Unset provider (e.g. the seeded default profile) resolves to the shared default,
// matching getState().
const storedProvider = resolveProviderSettings(stored).apiProvider

if (storedProvider !== expectedProvider) {
throw new Error(
Expand Down Expand Up @@ -3067,7 +3068,11 @@ export class ClineProvider

return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration: currentTaskApiConfiguration ?? apiConfiguration,
// The task configuration is a separate source and needs the same "provider is defined"
// guarantee as the persisted settings.
apiConfiguration: currentTaskApiConfiguration
? resolveProviderSettings(currentTaskApiConfiguration)
: apiConfiguration,
customInstructions,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false,
Expand Down Expand Up @@ -3242,19 +3247,7 @@ export class ClineProvider
const stateValues = this.contextProxy.getValues()
const customModes = await this.customModesManager.getCustomModes()

// Determine apiProvider with the same logic as before, while filtering retired providers.
const apiProvider: ProviderName =
stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider)
? stateValues.apiProvider
: providerIdentifiers.anthropic

// Build the apiConfiguration object combining state values and secrets.
const providerSettings = this.contextProxy.getProviderSettings()

// Ensure apiProvider is set properly if not already in state
if (!providerSettings.apiProvider) {
providerSettings.apiProvider = apiProvider
}
const apiConfiguration = resolveProviderSettings(this.contextProxy.getProviderSettings())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

let organizationAllowList = ORGANIZATION_ALLOW_ALL

Expand Down Expand Up @@ -3307,7 +3300,7 @@ export class ClineProvider

// Return the same structure as before.
return {
apiConfiguration: providerSettings,
apiConfiguration,
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
customInstructions: stateValues.customInstructions,
apiModelId: stateValues.apiModelId,
Expand Down Expand Up @@ -4434,14 +4427,12 @@ export class ClineProvider
}
}

const apiProvider = apiConfiguration?.apiProvider

return {
language,
mode,
taskId: task?.taskId,
parentTaskId: task?.parentTaskId,
apiProvider: apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined,
apiProvider: getActiveProvider(apiConfiguration),
Comment thread
p12tic marked this conversation as resolved.
Resolved
modelId: task?.api?.getModel().id,
diffStrategy: task?.diffStrategy?.getName(),
isSubtask: task ? !!task.parentTaskId : undefined,
Expand Down
52 changes: 52 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
DEFAULT_DIFF_FUZZY_THRESHOLD,
DEFAULT_WRITE_DELAY_MS,
DEFAULT_API_PROVIDER,
providerIdentifiers,
retiredProviderIdentifiers,
openAiModelInfoSaneDefaults,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
Expand Down Expand Up @@ -3960,6 +3962,45 @@ describe("ClineProvider", () => {
expect(state).toHaveProperty("writeDelayMs")
})

describe("resolved apiConfiguration", () => {
test("getState and getStateToPostToWebview resolve a missing provider to the shared default", async () => {
await provider.contextProxy.setProviderSettings({ apiModelId: "claude-sonnet-4-5" })

expect((await provider.getState()).apiConfiguration.apiProvider).toBe(DEFAULT_API_PROVIDER)
expect((await provider.getStateToPostToWebview()).apiConfiguration.apiProvider).toBe(DEFAULT_API_PROVIDER)
})

test("keeps a stored provider", async () => {
await provider.contextProxy.setProviderSettings({ apiProvider: providerIdentifiers.openrouter })

expect((await provider.getState()).apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter)
expect((await provider.getStateToPostToWebview()).apiConfiguration.apiProvider).toBe(
providerIdentifiers.openrouter,
)
})

test("passes a stored retired provider through so the webview can warn", async () => {
// `roo` is stripped by the legacy-router downgrade, so use another retired provider.
await provider.contextProxy.setProviderSettings({ apiProvider: retiredProviderIdentifiers.groq })

expect((await provider.getState()).apiConfiguration.apiProvider).toBe(retiredProviderIdentifiers.groq)
expect((await provider.getStateToPostToWebview()).apiConfiguration.apiProvider).toBe(
retiredProviderIdentifiers.groq,
)
})

test("resolves the task configuration when it has no provider", async () => {
vi.spyOn(provider, "getCurrentTask").mockReturnValue({
apiConfiguration: { apiModelId: "task-local-model" },
} as unknown as Task)

const postedState = await provider.getStateToPostToWebview()

expect(postedState.apiConfiguration.apiProvider).toBe(DEFAULT_API_PROVIDER)
expect(postedState.apiConfiguration.apiModelId).toBe("task-local-model")
})
})

test("getState and getStateToPostToWebview return the complete NanoGPT configuration", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setProviderSettings({
Expand Down Expand Up @@ -6256,6 +6297,17 @@ describe("getTelemetryProperties", () => {
expect(properties).toHaveProperty("modelId", "claude-sonnet-4-20250514")
})

it.each([
{ name: "active provider", apiProvider: providerIdentifiers.openrouter, expected: "openrouter" },
{ name: "retired provider", apiProvider: retiredProviderIdentifiers.groq, expected: undefined },
] as const)("reports $name in telemetry as $expected", async ({ apiProvider, expected }) => {
await provider.contextProxy.setProviderSettings({ apiProvider })

const properties = await provider.getTelemetryProperties()

expect(properties.apiProvider).toBe(expected)
})

describe("cloud authentication telemetry", () => {
beforeEach(() => {
// Reset all mocks before each test
Expand Down
13 changes: 12 additions & 1 deletion src/shared/__tests__/checkExistApiConfig.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// npx vitest run src/shared/__tests__/checkExistApiConfig.spec.ts

import { providerIdentifiers, retiredProviderIdentifiers, type ProviderSettings } from "@roo-code/types"
import {
DEFAULT_API_PROVIDER,
providerIdentifiers,
retiredProviderIdentifiers,
type ProviderSettings,
} from "@roo-code/types"

import { checkExistKey } from "../checkExistApiConfig"

Expand Down Expand Up @@ -162,4 +167,10 @@ describe("checkExistKey", () => {
}
expect(checkExistKey(config, true)).toBe(false)
})

it("should return false for a resolved but keyless default provider", () => {
// The host resolves `apiProvider` before posting state, so the welcome gate must
// decide from keys rather than from provider presence.
expect(checkExistKey({ apiProvider: DEFAULT_API_PROVIDER })).toBe(false)
})
})
Loading
Loading