Compare commits

...

2 Commits

Author SHA1 Message Date
Arafatkatze eaf999f800 Fix onboarding model CI tests 2026-06-08 09:28:50 -07:00
Arafatkatze 9286c94752 Fetch onboarding models from Cline API 2026-06-08 09:28:49 -07:00
19 changed files with 372 additions and 311 deletions
@@ -110,6 +110,54 @@ describe("ClineHandler", () => {
])
})
it("should use selected model pricing metadata when free model IDs are unavailable", async () => {
const handler = createHandler({})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
cost: 1.25,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(null)
sinon.stub(handler, "getModel").returns({
id: "free/model",
info: {
...openRouterDefaultModelInfo,
inputPrice: 0,
outputPrice: 0,
},
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
},
])
})
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
const handler = createHandler({ enableParallelToolCalling: true })
const createStub = sinon.stub().resolves(createAsyncIterable([]))
+18 -9
View File
@@ -9,7 +9,6 @@ import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -37,8 +36,6 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
}
@@ -47,6 +44,8 @@ function getCacheWriteTokens(usage: any): number {
return usage?.prompt_tokens_details?.cache_write_tokens || usage?.cache_creation_input_tokens || 0
}
type FreeModelIdSet = Set<string> | null
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
private clineAccountService = ClineAccountService.getInstance()
@@ -64,18 +63,28 @@ export class ClineHandler implements ApiHandler {
this._authService = AuthService.getInstance()
}
private async getFreeModelIdSet(): Promise<Set<string>> {
private async getFreeModelIdSet(): Promise<FreeModelIdSet> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
Logger.warn("Cline free model list unavailable; falling back to selected model pricing metadata")
} catch (error) {
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
}
return CLINE_FREE_MODEL_IDS
return null
}
private isFreeModel(modelId: string, freeModelIds: FreeModelIdSet): boolean {
if (freeModelIds) {
return freeModelIds.has(normalizeModelId(modelId))
}
const modelInfo = this.getModel().info
return modelInfo.inputPrice === 0 && modelInfo.outputPrice === 0
}
private async ensureClient(): Promise<OpenAI> {
@@ -237,7 +246,7 @@ export class ClineHandler implements ApiHandler {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
const isFreeModel = this.isFreeModel(modelId, freeModelIds)
const cacheReadTokens = getCacheReadTokens(chunk.usage)
const cacheWriteTokens = getCacheWriteTokens(chunk.usage)
@@ -271,10 +280,10 @@ export class ClineHandler implements ApiHandler {
}
}
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
async getApiStreamUsage(freeModelIds?: FreeModelIdSet): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
const resolvedFreeModelIds = freeModelIds === undefined ? await this.getFreeModelIdSet() : freeModelIds
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
@@ -294,7 +303,7 @@ export class ClineHandler implements ApiHandler {
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
const isFreeModel = this.isFreeModel(modelId, resolvedFreeModelIds)
if (isFreeModel) {
totalCost = 0
+21 -2
View File
@@ -54,7 +54,7 @@ import { clearRemoteConfig } from "../storage/remote-config/utils"
import { type PersistenceErrorEvent, StateManager } from "../storage/StateManager"
import { Task } from "../task"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { getClineOnboardingModels } from "./models/getClineOnboardingModels"
import { getCachedClineOnboardingModels, getClineOnboardingModels } from "./models/getClineOnboardingModels"
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
import { checkCliInstallation } from "./state/checkCliInstallation"
import { sendStateUpdate } from "./state/subscribeToState"
@@ -85,6 +85,7 @@ export class Controller {
// Timer for periodic remote config fetching
private remoteConfigTimer?: NodeJS.Timeout
private onboardingModelsRefreshPromise?: Promise<void>
// Public getter for workspace manager with lazy initialization - To get workspaces when task isn't initialized (Used by file mentions)
async ensureWorkspaceManager(): Promise<WorkspaceRootManager | undefined> {
@@ -842,9 +843,23 @@ export class Controller {
await sendStateUpdate(state)
}
private refreshOnboardingModelsInBackground() {
if (this.onboardingModelsRefreshPromise) {
return
}
this.onboardingModelsRefreshPromise = getClineOnboardingModels(this)
.then(() => this.postStateToWebview())
.catch((error) => {
Logger.error("Error refreshing onboarding models:", error)
})
.finally(() => {
this.onboardingModelsRefreshPromise = undefined
})
}
async getStateToPostToWebview(): Promise<ExtensionState> {
// Get API configuration from cache for immediate access
const onboardingModels = getClineOnboardingModels()
const apiConfiguration = this.stateManager.getApiConfiguration()
const lastShownAnnouncementId = this.stateManager.getGlobalStateKey("lastShownAnnouncementId")
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
@@ -876,6 +891,10 @@ export class Controller {
const isNewUser = this.stateManager.getGlobalStateKey("isNewUser")
// Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
const welcomeViewCompleted = !!this.stateManager.getGlobalStateKey("welcomeViewCompleted")
const onboardingModels = welcomeViewCompleted ? undefined : getCachedClineOnboardingModels()
if (!welcomeViewCompleted && !onboardingModels) {
this.refreshOnboardingModelsInBackground()
}
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt")
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
@@ -0,0 +1,44 @@
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import type { Controller } from "../../index"
import { clearOnboardingModelsCache, getClineOnboardingModels } from "../getClineOnboardingModels"
import * as refreshClineModelsModule from "../refreshClineModels"
import * as refreshClineRecommendedModelsModule from "../refreshClineRecommendedModels"
describe("getClineOnboardingModels", () => {
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
clearOnboardingModelsCache()
})
afterEach(() => {
clearOnboardingModelsCache()
sandbox.restore()
})
it("caches empty onboarding model results for a short TTL", async () => {
const clock = sandbox.useFakeTimers({ now: Date.now(), toFake: ["Date"] })
const refreshRecommendedModelsStub = sandbox
.stub(refreshClineRecommendedModelsModule, "refreshClineRecommendedModels")
.resolves({ recommended: [], free: [] })
const refreshClineModelsStub = sandbox.stub(refreshClineModelsModule, "refreshClineModels").resolves({})
const controller = {} as Controller
const firstResult = await getClineOnboardingModels(controller)
const secondResult = await getClineOnboardingModels(controller)
expect(firstResult).to.deep.equal({ models: [] })
expect(secondResult).to.deep.equal({ models: [] })
expect(refreshRecommendedModelsStub.calledOnce).to.equal(true)
expect(refreshClineModelsStub.calledOnce).to.equal(true)
clock.tick(30_001)
await getClineOnboardingModels(controller)
expect(refreshRecommendedModelsStub.calledTwice).to.equal(true)
expect(refreshClineModelsStub.calledTwice).to.equal(true)
})
})
@@ -73,8 +73,8 @@ describe("refreshClineRecommendedModels", () => {
sandbox.stub(fs, "writeFile").resolves()
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "google/gemini-3.1-pro-preview", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "minimax/minimax-m2.5", description: "Remote free", tags: ["FREE"] }],
recommended: [{ id: "anthropic/claude-sonnet-4.6", description: "Remote recommended" }],
free: [{ id: "z-ai/glm-5", description: "Remote free", tags: ["FREE"] }],
},
})
@@ -83,5 +83,64 @@ describe("refreshClineRecommendedModels", () => {
expect(axiosGetStub.calledOnce).to.equal(true)
expect(secondResult).to.deep.equal(firstResult)
expect(secondResult).to.deep.equal({
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
description: "Remote recommended",
tags: [],
},
],
free: [
{
id: "z-ai/glm-5",
name: "z-ai/glm-5",
description: "Remote free",
tags: ["FREE"],
},
],
})
})
it("loads recommended models from disk cache when upstream fetch fails", async () => {
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(axios, "get").rejects(new Error("network unavailable"))
sandbox.stub(fs, "access").resolves()
sandbox.stub(fs, "readFile").resolves(
JSON.stringify({
recommended: [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini Pro", description: "Cached recommended", tags: ["NEW"] },
],
free: [{ id: "minimax/minimax-m2.5", name: "MiniMax", description: "Cached free", tags: ["FREE"] }],
}),
)
const result = await refreshClineRecommendedModels()
expect(result).to.deep.equal({
recommended: [
{
id: "google/gemini-3.1-pro-preview",
name: "Gemini Pro",
description: "Cached recommended",
tags: ["NEW"],
},
],
free: [
{
id: "minimax/minimax-m2.5",
name: "MiniMax",
description: "Cached free",
tags: ["FREE"],
},
],
})
})
})
@@ -1,18 +1,75 @@
import { featureFlagsService } from "@/services/feature-flags"
import { CLINE_ONBOARDING_MODELS } from "@/shared/cline/onboarding"
import { OnboardingModel, OnboardingModelGroup } from "@/shared/proto/cline/state"
import type { ModelInfo } from "@/shared/api"
import type { OnboardingModel, OnboardingModelGroup } from "@/shared/proto/cline/state"
import type { Controller } from "../index"
import { refreshClineModels } from "./refreshClineModels"
import {
type ClineRecommendedModelData,
type ClineRecommendedModelsData,
refreshClineRecommendedModels,
} from "./refreshClineRecommendedModels"
type OnboardingModelOverride = OnboardingModel & { hidden?: boolean }
type TimedOnboardingModelGroup = { models: OnboardingModelGroup; cachedAt: number }
const EMPTY_ONBOARDING_MODELS_CACHE_TTL_MS = 30 * 1000
let cached: OnboardingModelGroup | null = null
let emptyCached: TimedOnboardingModelGroup | null = null
let pendingRefresh: Promise<OnboardingModelGroup> | null = null
let cacheGeneration = 0
export function getClineOnboardingModels(): OnboardingModelGroup {
export function getCachedClineOnboardingModels(): OnboardingModelGroup | undefined {
if (cached) {
return cached
}
if (emptyCached && Date.now() - emptyCached.cachedAt <= EMPTY_ONBOARDING_MODELS_CACHE_TTL_MS) {
return emptyCached.models
}
emptyCached = null
return undefined
}
export async function getClineOnboardingModels(controller: Controller): Promise<OnboardingModelGroup> {
const cachedModels = getCachedClineOnboardingModels()
if (cachedModels) {
return cachedModels
}
if (pendingRefresh) {
return pendingRefresh
}
const refreshGeneration = cacheGeneration
pendingRefresh = (async () => {
try {
const models = await fetchClineOnboardingModels(controller)
if (refreshGeneration === cacheGeneration) {
if (models.models.length > 0) {
cached = models
emptyCached = null
} else {
emptyCached = { models, cachedAt: Date.now() }
}
}
return models
} finally {
if (refreshGeneration === cacheGeneration) {
pendingRefresh = null
}
}
})()
return pendingRefresh
}
async function fetchClineOnboardingModels(controller: Controller): Promise<OnboardingModelGroup> {
const [recommendedModels, modelCatalog] = await Promise.all([refreshClineRecommendedModels(), refreshClineModels(controller)])
const models = toOnboardingModels(recommendedModels, modelCatalog)
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
const models = [...CLINE_ONBOARDING_MODELS]
// Apply remote overrides if available
if (remoteOverrides) {
@@ -39,8 +96,47 @@ export function getClineOnboardingModels(): OnboardingModelGroup {
}
}
cached = { models }
return cached
return { models }
}
function toOnboardingModels(
recommendedModels: ClineRecommendedModelsData,
modelCatalog: Record<string, ModelInfo>,
): OnboardingModel[] {
return [
...recommendedModels.free.map((model) => toOnboardingModel(model, "free", "Free", modelCatalog)),
...recommendedModels.recommended.map((model) => toOnboardingModel(model, "frontier", "", modelCatalog)),
]
}
function toOnboardingModel(
model: ClineRecommendedModelData,
group: string,
fallbackBadge: string,
modelCatalog: Record<string, ModelInfo>,
): OnboardingModel {
const catalogInfo = modelCatalog[model.id]
const tag = model.tags[0] ?? ""
const badge = tag || fallbackBadge
return {
id: model.id,
name: model.name || model.id,
group,
badge,
score: 0,
latency: 0,
info: catalogInfo
? {
contextWindow: catalogInfo.contextWindow ?? 0,
supportsImages: catalogInfo.supportsImages ?? false,
supportsPromptCache: catalogInfo.supportsPromptCache ?? false,
inputPrice: catalogInfo.inputPrice ?? 0,
outputPrice: catalogInfo.outputPrice ?? 0,
tiers: catalogInfo.tiers ?? [],
}
: undefined,
}
}
function mergeModelWithOverride(baseModel: OnboardingModel | undefined, override: OnboardingModelOverride): OnboardingModel {
@@ -60,5 +156,8 @@ function mergeModelWithOverride(baseModel: OnboardingModel | undefined, override
}
export function clearOnboardingModelsCache(): void {
cacheGeneration++
cached = null
emptyCached = null
pendingRefresh = null
}
@@ -1,4 +1,4 @@
import { clearOnboardingModelsCache, getClineOnboardingModels } from "@/core/controller/models/getClineOnboardingModels"
import { clearOnboardingModelsCache } from "@/core/controller/models/getClineOnboardingModels"
import type { OnboardingModel } from "@/shared/proto/cline/state"
import { FEATURE_FLAGS, FeatureFlag, FeatureFlagDefaultValue } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
@@ -65,7 +65,7 @@ export class FeatureFlagsService {
throw error
}
getClineOnboardingModels() // Refresh onboarding models cache if relevant flag changed
clearOnboardingModelsCache()
}
private async getFeatureFlag(flagName: FeatureFlag): Promise<FeatureFlagPayload | undefined> {
@@ -123,7 +123,6 @@ export class FeatureFlagsService {
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
return payload.models as unknown as Record<string, OnboardingModel & { hidden?: boolean }>
}
clearOnboardingModelsCache()
return undefined
}
-1
View File
@@ -1,6 +1,5 @@
export * from "./api"
export * from "./context"
export * from "./onboarding"
export enum ClineClient {
VSCode = "VSCode Extension",
-104
View File
@@ -1,104 +0,0 @@
import type { OnboardingModel } from "../proto/cline/state"
/**
* The list of models available to new users during the onboarding flow.
* NOTE: Can be overridden by feature flag onboarding models payload.
*/
export const CLINE_ONBOARDING_MODELS: OnboardingModel[] = [
{
group: "free",
id: "kwaipilot/kat-coder-pro",
name: "KwaiKAT: Kat Coder Pro",
score: 88,
latency: 2,
badge: "Best",
info: {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
tiers: [],
},
},
{
group: "free",
id: "minimax/minimax-m2.5",
name: "MiniMax: MiniMax M2.5",
score: 90,
latency: 2,
badge: "New",
info: {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
tiers: [],
},
},
{
group: "free",
id: "arcee-ai/trinity-large-preview:free",
name: "Arcee AI: Trinity Large Preview",
score: 88,
latency: 2,
badge: "New",
info: {
contextWindow: 131_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
tiers: [],
},
},
{
group: "frontier",
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
latency: 3,
info: {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
tiers: [],
},
},
{
group: "frontier",
id: "google/gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
badge: "Preview",
score: 97,
latency: 3,
info: {
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 4.0,
outputPrice: 18.0,
tiers: [],
},
},
{
group: "frontier",
id: "openai/gpt-5-codex",
name: "OpenAI: GPT-5 Codex",
badge: "Best",
score: 97,
latency: 7,
info: {
contextWindow: 400_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
tiers: [],
},
},
]
@@ -1,57 +0,0 @@
export interface ClineRecommendedModel {
id: string
name: string
description: string
tags: string[]
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
}
/**
* Hardcoded fallback shown when upstream recommended models are not enabled or unavailable.
*/
export const CLINE_RECOMMENDED_MODELS_FALLBACK: ClineRecommendedModelsData = {
recommended: [
{
id: "google/gemini-3.1-pro-preview",
name: "Google Gemini 3.1 Pro Preview",
description: "Latest Gemini release with 1m ctx window and strong coding performance",
tags: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Anthropic Claude Sonnet 4.6",
description: "Latest Sonnet release with strong coding and agent performance",
tags: ["NEW"],
},
{
id: "anthropic/claude-opus-4.6",
name: "Anthropic Claude Opus 4.6",
description: "Most intelligent model for agents and coding",
tags: ["BEST"],
},
{
id: "openai/gpt-5.3-codex",
name: "OpenAI GPT-5.3 Codex",
description: "OpenAI's latest with strong coding abilities",
tags: ["NEW"],
},
],
free: [
{
id: "kwaipilot/kat-coder-pro",
name: "KwaiKAT Kat Coder Pro",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
tags: ["FREE"],
},
{
id: "arcee-ai/trinity-large-preview:free",
name: "Arcee AI Trinity Large Preview",
description: "Arcee AI's advanced large preview model in the Trinity series",
tags: ["FREE"],
},
],
}
@@ -26,7 +26,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.REMOTE_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.EXTENSION_REMOTE_BANNERS_TTL]: 24 * 60 * 60 * 1000,
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT]: false,
[FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT]: process.env.E2E_TEST === "true",
[FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE]: false,
}
@@ -272,8 +272,15 @@ describe("Controller Marketplace Filtering", () => {
await controller.refreshMcpMarketplace(false)
sinon.assert.calledOnce(axiosGetStub)
const callArgs = axiosGetStub.firstCall.args
const marketplaceCall = axiosGetStub
.getCalls()
.find((call) => call.args[0] === `${ClineEnv.config().mcpBaseUrl}/marketplace`)
;(marketplaceCall !== undefined).should.be.true()
if (!marketplaceCall) {
throw new Error("Expected marketplace API request")
}
const callArgs = marketplaceCall.args
callArgs[0].should.equal(`${ClineEnv.config().mcpBaseUrl}/marketplace`)
})
@@ -651,7 +651,9 @@ export class ClineApiServerMock {
const server = ClineApiServerMock.globalSharedServer.server
// Clean shutdown - destroy all socket connections first
ClineApiServerMock.globalSockets.forEach((socket) => socket.destroy())
ClineApiServerMock.globalSockets.forEach((socket) => {
socket.destroy()
})
ClineApiServerMock.globalSockets.clear()
await new Promise<void>((resolve, reject) => {
@@ -10,6 +10,7 @@ import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle }
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import { formatContextWindow } from "@/utils/format"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
@@ -97,7 +98,9 @@ const ModelSelection = ({
<div className="inline-flex gap-1 [&_svg]:stroke-foreground [&_svg]:size-3 items-center text-sm">
<ListIcon />
<span>Context: </span>
<span className="text-foreground/70">{(model?.info.contextWindow || 0) / 1000}k</span>
<span className="text-foreground/70">
{formatContextWindow(model.info.contextWindow)}
</span>
</div>
<Badge>{getPriceRange(model.info)}</Badge>
</div>
@@ -281,9 +284,8 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
useEffect(() => {
setSearchTerm("")
const userGroup = userType === NEW_USER_TYPE.POWER ? NEW_USER_TYPE.POWER : NEW_USER_TYPE.FREE
const modelGroup = models[userGroup][0]
const userGroupInitModel = modelGroup.models[0]
setSelectedModelId(userGroupInitModel.id)
const userGroupInitModel = models[userGroup][0]?.models[0]
setSelectedModelId(userGroupInitModel?.id ?? "")
}, [userType, models])
const onUserTypeClick = useCallback((userType: NEW_USER_TYPE) => {
@@ -1,11 +1,6 @@
import type { ModelInfo } from "@shared/api"
import { CLINE_ONBOARDING_MODELS } from "@shared/cline/onboarding"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo, useState } from "react"
import type { OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
export type OnboardingModelsStatus = "loading" | "success" | "empty"
@@ -14,94 +9,22 @@ export interface UseOnboardingModelsResult {
models: OnboardingModelGroup
}
function toOnboardingModel(
rec: ClineRecommendedModel,
group: string,
fallbackBadge: string,
modelCatalog: Record<string, ModelInfo>,
): OnboardingModel {
const catalogInfo = modelCatalog[rec.id]
const tag = rec.tags?.[0] ?? ""
const badge = tag || fallbackBadge
return {
id: rec.id,
name: rec.name || rec.id,
group,
badge,
score: 0,
latency: 0,
info: catalogInfo
? {
contextWindow: catalogInfo.contextWindow ?? 0,
supportsImages: catalogInfo.supportsImages ?? false,
supportsPromptCache: catalogInfo.supportsPromptCache ?? false,
inputPrice: catalogInfo.inputPrice ?? 0,
outputPrice: catalogInfo.outputPrice ?? 0,
tiers: catalogInfo.tiers ?? [],
}
: undefined,
}
}
interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
}
type FetchState = { status: "loading" } | { status: "success"; data: RecommendedModelsData } | { status: "empty" }
export function useOnboardingModels(): UseOnboardingModelsResult {
const { openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const [fetchState, setFetchState] = useState<FetchState>({ status: "loading" })
useEffect(() => {
let cancelled = false
const refreshRecommendedModels = async () => {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
if (!cancelled) {
const recommended = response.recommended ?? []
const free = response.free ?? []
if (recommended.length === 0 && free.length === 0) {
setFetchState({ status: "empty" })
} else {
setFetchState({ status: "success", data: { recommended, free } })
}
}
} catch {
if (!cancelled) {
setFetchState({ status: "empty" })
}
}
}
refreshRecommendedModels()
return () => {
cancelled = true
}
}, [])
const { onboardingModels, refreshClineModels } = useExtensionState()
useEffect(() => {
refreshClineModels()
}, [refreshClineModels])
// Merge openRouter and cline models into a single catalog for lookups
const modelCatalog = useMemo<Record<string, ModelInfo>>(() => {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
return useMemo<UseOnboardingModelsResult>(() => {
if (fetchState.status !== "success") {
return { status: fetchState.status, models: { models: CLINE_ONBOARDING_MODELS } }
if (!onboardingModels) {
return { status: "loading", models: { models: [] } }
}
const { data } = fetchState
const freeModels = data.free.map((rec) => toOnboardingModel(rec, "free", "Free", modelCatalog))
const frontierModels = data.recommended.map((rec) => toOnboardingModel(rec, "frontier", "", modelCatalog))
if (onboardingModels.models.length === 0) {
return { status: "empty", models: onboardingModels }
}
return { status: "success", models: { models: [...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog])
return { status: "success", models: onboardingModels }
}, [onboardingModels])
}
@@ -1,5 +1,4 @@
import { CLAUDE_SONNET_1M_SUFFIX, openRouterDefaultModelId } from "@shared/api"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { type ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
import type { Mode } from "@shared/storage/types"
@@ -84,14 +83,6 @@ function toFeaturedModelCardEntry(
}
}
const RECOMMENDED_MODELS_FALLBACK: FeaturedModelCardEntry[] = CLINE_RECOMMENDED_MODELS_FALLBACK.recommended
.map((model) => toFeaturedModelCardEntry(model, "RECOMMENDED"))
.filter((model): model is FeaturedModelCardEntry => model !== null)
const FREE_MODELS_FALLBACK: FeaturedModelCardEntry[] = CLINE_RECOMMENDED_MODELS_FALLBACK.free
.map((model) => toFeaturedModelCardEntry(model, "FREE"))
.filter((model): model is FeaturedModelCardEntry => model !== null)
const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMode, showProviderRouting, initialTab }) => {
const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
const { apiConfiguration, favoritedModelIds, clineModels, refreshClineModels } = useExtensionState()
@@ -102,8 +93,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
const [clineRecommendedModels, setClineRecommendedModels] = useState<FeaturedModelCardEntry[]>([])
const [clineFreeModels, setClineFreeModels] = useState<FeaturedModelCardEntry[]>([])
const freeClineModelIds = useMemo(() => {
const freeModelIds =
clineFreeModels.length > 0 ? clineFreeModels.map((model) => model.id) : FREE_MODELS_FALLBACK.map((model) => model.id)
const freeModelIds = clineFreeModels.map((model) => model.id)
return [...new Set(freeModelIds)]
}, [clineFreeModels])
const freeClineModelIdSet = useMemo(
@@ -111,11 +101,9 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
[freeClineModelIds],
)
const [activeTab, setActiveTab] = useState<"recommended" | "free">(initialTab ?? "recommended")
const recommendedModels = useMemo(
() => (clineRecommendedModels.length > 0 ? clineRecommendedModels : RECOMMENDED_MODELS_FALLBACK),
[clineRecommendedModels],
)
const freeModels = useMemo(() => (clineFreeModels.length > 0 ? clineFreeModels : FREE_MODELS_FALLBACK), [clineFreeModels])
const recommendedModels = clineRecommendedModels
const freeModels = clineFreeModels
const [hasLoadedClineRecommendedModels, setHasLoadedClineRecommendedModels] = useState(false)
const hasSuccessfulClineRecommendedModelsFetchRef = useRef(false)
const isFetchingClineRecommendedModelsRef = useRef(false)
const clineRecommendedModelsRetryTimeoutRef = useRef<number | null>(null)
@@ -136,7 +124,11 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
.filter((model): model is FeaturedModelCardEntry => model !== null)
setClineRecommendedModels(recommended)
setClineFreeModels(free)
return true
const hasModels = recommended.length > 0 || free.length > 0
if (hasModels) {
setHasLoadedClineRecommendedModels(true)
}
return hasModels
} catch (error) {
console.error("Failed to refresh Cline recommended models:", error)
return false
@@ -188,9 +180,12 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
if (initialTab) {
return
}
if (!hasLoadedClineRecommendedModels) {
return
}
const currentModelId = modeFields.clineModelId || openRouterDefaultModelId
setActiveTab(freeClineModelIdSet.has(normalizeModelId(currentModelId)) ? "free" : "recommended")
}, [modeFields.clineModelId, freeClineModelIdSet, initialTab])
}, [hasLoadedClineRecommendedModels, modeFields.clineModelId, freeClineModelIdSet, initialTab])
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
@@ -2,6 +2,7 @@ import { geminiModels, ModelInfo } from "@shared/api"
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import styled from "styled-components"
import { formatContextWindow } from "@/utils/format"
import { ModelDescriptionMarkdown } from "../ModelDescriptionMarkdown"
import { formatPrice, hasThinkingBudget, supportsBrowserUse, supportsImages, supportsPromptCache } from "../utils/pricingUtils"
@@ -111,19 +112,6 @@ const formatCompactPrice = (price: number | undefined): string => {
return `$${price % 1 === 0 ? price : price.toFixed(2)}/M`
}
/**
* Format context window for compact display (e.g., "200K")
*/
const formatCompactContext = (contextWindow: number | undefined): string => {
if (!contextWindow) {
return "N/A"
}
if (contextWindow >= 1_000_000) {
return `${(contextWindow / 1_000_000).toFixed(contextWindow % 1_000_000 === 0 ? 0 : 1)}M`
}
return `${Math.round(contextWindow / 1000)}K`
}
/**
* Returns an array of formatted tier strings
*/
@@ -212,7 +200,7 @@ export const ModelInfoView = ({
{modelInfo.contextWindow !== undefined && modelInfo.contextWindow > 0 && (
<InfoItem>
<InfoLabel>Context: </InfoLabel>
<InfoValue>{formatCompactContext(modelInfo.contextWindow)}</InfoValue>
<InfoValue>{formatContextWindow(modelInfo.contextWindow)}</InfoValue>
</InfoItem>
)}
{modelInfo.inputPrice !== undefined && (
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest"
import { formatContextWindow } from "../format"
describe("formatContextWindow", () => {
it("formats token context windows as compact labels", () => {
expect(formatContextWindow(200_000)).toBe("200K")
expect(formatContextWindow(1_000_000)).toBe("1M")
expect(formatContextWindow(1_048_576)).toBe("1M")
expect(formatContextWindow(1_500_000)).toBe("1.5M")
})
it("handles missing or invalid context windows", () => {
expect(formatContextWindow()).toBe("N/A")
expect(formatContextWindow(0)).toBe("N/A")
})
})
@@ -13,6 +13,19 @@ export function formatLargeNumber(num: number): string {
return num.toString()
}
export function formatContextWindow(contextWindow?: number): string {
if (!contextWindow || contextWindow <= 0) {
return "N/A"
}
return new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: contextWindow >= 1_000_000 ? 1 : 0,
})
.format(contextWindow)
.toUpperCase()
}
// Helper to format cents as dollars with 2 decimal places
export function formatDollars(cents?: number): string {
if (cents === undefined) {