Skip to content
Closed
5 changes: 5 additions & 0 deletions evals/packages/types/src/roo-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ export const providerSettingsSchema = z.object({
lmStudioSpeculativeDecodingEnabled: z.boolean().optional(),
// Gemini
geminiApiKey: z.string().optional(),
geminiFreeTier: z.boolean().optional(),
googleGeminiBaseUrl: z.string().optional(),
// OpenAI Native
openAiNativeApiKey: z.string().optional(),
Expand Down Expand Up @@ -444,6 +445,8 @@ const providerSettingsRecord: ProviderSettingsRecord = {
lmStudioSpeculativeDecodingEnabled: undefined,
// Gemini
geminiApiKey: undefined,
geminiFreeTier: undefined,
geminiModelInfo: undefined, // Keep this uncommented
googleGeminiBaseUrl: undefined,
// OpenAI Native
openAiNativeApiKey: undefined,
Expand Down Expand Up @@ -538,6 +541,7 @@ export const globalSettingsSchema = z.object({
language: languagesSchema.optional(),

telemetrySetting: telemetrySettingsSchema.optional(),
geminiFreeTier: z.boolean().optional(), // Added Gemini Free Tier setting

mcpEnabled: z.boolean().optional(),
enableMcpServerCreation: z.boolean().optional(),
Expand Down Expand Up @@ -613,6 +617,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
language: undefined,

telemetrySetting: undefined,
geminiFreeTier: undefined, // Added Gemini Free Tier setting

mcpEnabled: undefined,
enableMcpServerCreation: undefined,
Expand Down
19 changes: 11 additions & 8 deletions src/core/Cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1835,15 +1835,18 @@ export class Cline extends EventEmitter<ClineEvents> {
tokensOut: outputTokens,
cacheWrites: cacheWriteTokens,
cacheReads: cacheReadTokens,
// Check for Gemini free tier before calculating cost
cost:
totalCost ??
calculateApiCostAnthropic(
this.api.getModel().info,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
),
this.apiConfiguration.apiProvider === "gemini" && this.apiConfiguration.geminiFreeTier === true
? 0
: (totalCost ??
calculateApiCostAnthropic(
this.api.getModel().info,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
)),
cancelReason,
streamingFailedMessage,
} satisfies ClineApiReqInfo)
Expand Down
166 changes: 141 additions & 25 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,45 @@ export class ProviderSettingsManager {
* Preserves the ID from the input 'config' object if it exists,
* otherwise generates a new one (for creation scenarios).
*/
/**
* Save a config with the given name.
* Preserves the ID from the input 'config' object if it exists,
* otherwise generates a new one (for creation scenarios).
*
* Note: Special care is taken to ensure boolean values (including `true`)
* are properly preserved during serialization/deserialization.
*/
public async saveConfig(name: string, config: ProviderSettingsWithId) {
try {
return await this.lock(async () => {
const providerProfiles = await this.load()
// Preserve the existing ID if this is an update to an existing config.
const existingId = providerProfiles.apiConfigs[name]?.id
providerProfiles.apiConfigs[name] = { ...config, id: config.id || existingId || this.generateId() }

// Create a deep copy of the config to ensure all properties (including booleans) are preserved
const configCopy = JSON.parse(JSON.stringify(config))

// Ensure apiConfigs exists
if (!providerProfiles.apiConfigs) {
providerProfiles.apiConfigs = {}
}

// Create a new apiConfigs object with both existing configs and the new/updated one
providerProfiles.apiConfigs = {
...providerProfiles.apiConfigs,
[name]: {
...configCopy,
id: config.id || existingId || this.generateId(),
},
}

// Debug log to inspect providerProfiles before storing
console.log("[saveConfig] providerProfiles before store:", {
currentApiConfigName: providerProfiles.currentApiConfigName,
apiConfigs: providerProfiles.apiConfigs,
modeApiConfigs: providerProfiles.modeApiConfigs,
})

await this.store(providerProfiles)
})
} catch (error) {
Expand Down Expand Up @@ -321,38 +353,122 @@ export class ProviderSettingsManager {
return this.defaultProviderProfiles
}

const providerProfiles = providerProfilesSchema
.extend({
apiConfigs: z.record(z.string(), z.any()),
})
.parse(JSON.parse(content))

const apiConfigs = Object.entries(providerProfiles.apiConfigs).reduce(
(acc, [key, apiConfig]) => {
const result = providerSettingsWithIdSchema.safeParse(apiConfig)
return result.success ? { ...acc, [key]: result.data } : acc
},
{} as Record<string, ProviderSettingsWithId>,
)

return {
...providerProfiles,
apiConfigs: Object.fromEntries(
Object.entries(apiConfigs).filter(([_, apiConfig]) => apiConfig !== null),
),
// Parse the content with a reviver function to ensure boolean values are preserved
const parsedContent = JSON.parse(content, (key, value) => {
// Return the value as is, ensuring booleans are preserved
return value
})

// Validate the parsed content using safeParse to allow for recovery
const validationResult = providerProfilesSchema.safeParse(parsedContent)

if (validationResult.success) {
return validationResult.data
} else {
// Validation failed, attempt recovery if errors are only in apiConfigs
const zodError = validationResult.error
telemetryService.captureSchemaValidationError({ schemaName: "ProviderProfiles", error: zodError })

const nonApiConfigErrors = zodError.issues.filter((issue) => issue.path[0] !== "apiConfigs")

if (nonApiConfigErrors.length === 0 && parsedContent.apiConfigs) {
// Errors are only within apiConfigs, attempt filtering
try {
console.warn(
"ProviderSettingsManager: Invalid entries found in apiConfigs during load. Attempting to filter and recover.",
JSON.stringify(
zodError.issues.filter((issue) => issue.path[0] === "apiConfigs"),
null,
2,
),
)

const filteredApiConfigs: Record<string, ProviderSettingsWithId> = {}
for (const [name, config] of Object.entries(parsedContent.apiConfigs)) {
// Explicitly check if config is a valid object before parsing
if (typeof config !== "object" || config === null) {
console.warn(
`ProviderSettingsManager: Skipping invalid profile '${name}' during load: Expected object, received ${typeof config}.`,
)
continue // Skip this entry entirely
}
// Now safeParse the object
const profileValidation = providerSettingsWithIdSchema.safeParse(config)
if (profileValidation.success) {
filteredApiConfigs[name] = profileValidation.data
} else {
console.warn(
`ProviderSettingsManager: Removing invalid profile '${name}' during load. Issues:`,
JSON.stringify(profileValidation.error.issues, null, 2),
)
}
}

// Ensure the currentApiConfigName still points to a valid config if possible
let currentApiConfigName = parsedContent.currentApiConfigName
// Check if the original current name exists AND is valid after filtering
if (!filteredApiConfigs[currentApiConfigName]) {
const originalName = parsedContent.currentApiConfigName
const availableNames = Object.keys(filteredApiConfigs)
// Fallback logic: try 'default', then first available, then manager's default
currentApiConfigName = availableNames.includes("default")
? "default"
: availableNames[0] || this.defaultProviderProfiles.currentApiConfigName

if (originalName && originalName !== currentApiConfigName) {
console.warn(
`ProviderSettingsManager: Current API config '${originalName}' was invalid or removed. Switched to '${currentApiConfigName}'.`,
)
} else if (!originalName) {
console.warn(
`ProviderSettingsManager: Original currentApiConfigName was missing or invalid. Switched to '${currentApiConfigName}'.`,
)
}
// Persisting this change immediately might be better, but requires storing here.
// Let's defer persistence to the next save/store operation for simplicity.
}

// Return a recovered object
return {
currentApiConfigName: currentApiConfigName,
apiConfigs: filteredApiConfigs,
modeApiConfigs: parsedContent.modeApiConfigs || this.defaultModeApiConfigs,
migrations: parsedContent.migrations || this.defaultProviderProfiles.migrations,
}
} catch (recoveryError) {
console.error("ProviderSettingsManager: Error occurred during recovery logic:", recoveryError)
// Re-throw the recovery error to be caught by the outer catch block
throw recoveryError // Ensures it's caught by the final catch
}
} else {
// Errors exist outside apiConfigs or apiConfigs is missing, cannot recover safely
console.error(
"ProviderSettingsManager: Unrecoverable Zod validation failed during load. Issues:",
JSON.stringify(zodError.issues, null, 2),
)
// Throw a specific error for unrecoverable Zod issues
throw new Error(`Unrecoverable validation errors in provider profiles structure: ${zodError}`)
}
}
} catch (error) {
if (error instanceof ZodError) {
telemetryService.captureSchemaValidationError({ schemaName: "ProviderProfiles", error })
}

// Catch non-Zod errors or errors during recovery logic
console.error("ProviderSettingsManager: Error during load or recovery:", error)
throw new Error(`Failed to read provider profiles from secrets: ${error}`)
}
}

private async store(providerProfiles: ProviderProfiles) {
try {
await this.context.secrets.store(this.secretsKey, JSON.stringify(providerProfiles, null, 2))
// Use a custom replacer function to ensure boolean values are preserved
const replacer = (key: string, value: any) => {
// Explicitly handle boolean values to ensure they're preserved
if (value === true || value === false) {
return value
}
return value
}

await this.context.secrets.store(this.secretsKey, JSON.stringify(providerProfiles, replacer, 2))
} catch (error) {
throw new Error(`Failed to write provider profiles to secrets: ${error}`)
}
Expand Down
59 changes: 59 additions & 0 deletions src/core/config/__tests__/ProviderSettingsManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,65 @@ describe("ProviderSettingsManager", () => {
"Failed to save config: Error: Failed to write provider profiles to secrets: Error: Storage failed",
)
})

it("should save boolean values correctly, even when true", async () => {
// Create a clean initial state
const initialState = {
currentApiConfigName: "default",
apiConfigs: {
default: { id: "default-id" },
},
modeApiConfigs: {
code: "default",
architect: "default",
ask: "default",
},
migrations: {
rateLimitSecondsMigrated: true,
},
}

// Mock the initial state - use mockResolvedValue instead of mockResolvedValueOnce
// to ensure it returns the same value for all calls during the test
mockSecrets.get.mockResolvedValue(JSON.stringify(initialState))

// Create a config with geminiFreeTier set to true
const configWithTrueBoolean: ProviderSettings = {
apiProvider: "gemini",
geminiFreeTier: true,
}

// Clear any previous store calls
mockSecrets.store.mockClear()

// Save the config
await providerSettingsManager.saveConfig("test", configWithTrueBoolean)

// Verify store was called
expect(mockSecrets.store).toHaveBeenCalled()

// Get what was stored
const storeCall = mockSecrets.store.mock.calls[0]
expect(storeCall[0]).toBe("roo_cline_config_api_config")

const storedData = JSON.parse(storeCall[1])

// Verify the structure
expect(storedData).toHaveProperty("apiConfigs.test")
expect(storedData.apiConfigs.test).toHaveProperty("apiProvider", "gemini")
expect(storedData.apiConfigs.test).toHaveProperty("geminiFreeTier", true)

// Now test loading the config
mockSecrets.get.mockReset()
mockSecrets.get.mockResolvedValueOnce(JSON.stringify(storedData))

// Load the config
const loadedConfig = await providerSettingsManager.loadConfig("test")

// Verify the loaded config
expect(loadedConfig).toHaveProperty("apiProvider", "gemini")
expect(loadedConfig).toHaveProperty("geminiFreeTier", true)
})
})

describe("DeleteConfig", () => {
Expand Down
1 change: 1 addition & 0 deletions src/exports/roo-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ type ProviderSettings = {
lmStudioSpeculativeDecodingEnabled?: boolean | undefined
geminiApiKey?: string | undefined
googleGeminiBaseUrl?: string | undefined
geminiFreeTier?: boolean | undefined
openAiNativeApiKey?: string | undefined
mistralApiKey?: string | undefined
mistralCodestralUrl?: string | undefined
Expand Down
1 change: 1 addition & 0 deletions src/exports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ type ProviderSettings = {
lmStudioSpeculativeDecodingEnabled?: boolean | undefined
geminiApiKey?: string | undefined
googleGeminiBaseUrl?: string | undefined
geminiFreeTier?: boolean | undefined
openAiNativeApiKey?: string | undefined
mistralApiKey?: string | undefined
mistralCodestralUrl?: string | undefined
Expand Down
4 changes: 3 additions & 1 deletion src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ export const providerSettingsSchema = z.object({
// Gemini
geminiApiKey: z.string().optional(),
googleGeminiBaseUrl: z.string().optional(),
geminiFreeTier: z.boolean().optional(),
// OpenAI Native
openAiNativeApiKey: z.string().optional(),
// Mistral
Expand Down Expand Up @@ -466,6 +467,7 @@ const providerSettingsRecord: ProviderSettingsRecord = {
// Gemini
geminiApiKey: undefined,
googleGeminiBaseUrl: undefined,
geminiFreeTier: undefined,
// OpenAI Native
openAiNativeApiKey: undefined,
// Mistral
Expand Down Expand Up @@ -494,7 +496,7 @@ const providerSettingsRecord: ProviderSettingsRecord = {
fakeAi: undefined,
}

export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as Keys<ProviderSettings>[]
export const PROVIDER_SETTINGS_KEYS = Object.keys(providerSettingsRecord) as (keyof ProviderSettings)[]

/**
* GlobalSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ describe.each([
[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"],
[RepoPerWorkspaceCheckpointService, "RepoPerWorkspaceCheckpointService"],
])("CheckpointService", (klass, prefix) => {
// Increase timeout for Git operations
jest.setTimeout(20000)

const taskId = "test-task"

let workspaceGit: SimpleGit
Expand Down Expand Up @@ -86,6 +89,7 @@ describe.each([

describe(`${klass.name}#getDiff`, () => {
it("returns the correct diff between commits", async () => {
jest.setTimeout(20000) // Increase timeout for Git operations
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("Ahoy, world!")
expect(commit1?.commit).toBeTruthy()
Expand Down
9 changes: 2 additions & 7 deletions webview-ui/src/components/chat/TaskHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,8 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}, [task.text, windowWidth])

const isCostAvailable = useMemo(() => {
return (
apiConfiguration?.apiProvider !== "openai" &&
apiConfiguration?.apiProvider !== "ollama" &&
apiConfiguration?.apiProvider !== "lmstudio" &&
apiConfiguration?.apiProvider !== "gemini"
)
}, [apiConfiguration?.apiProvider])
return totalCost !== null && totalCost !== undefined && totalCost > 0 && !isNaN(totalCost)
}, [totalCost])

const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"

Expand Down
Loading