mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaf999f800 | |||
| 9286c94752 |
@@ -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([]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+61
-2
@@ -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,6 +1,5 @@
|
||||
export * from "./api"
|
||||
export * from "./context"
|
||||
export * from "./onboarding"
|
||||
|
||||
export enum ClineClient {
|
||||
VSCode = "VSCode Extension",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user