Compare commits

...
11 changed files with 487 additions and 1 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.2",
"version": "4.0.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+2
View File
@@ -921,6 +921,7 @@ export class Controller {
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
const modelsDevProviderModels = this.stateManager.getModelsDevProviderModelsCache() ?? undefined
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -969,6 +970,7 @@ export class Controller {
isNewUser,
welcomeViewCompleted,
onboardingModels,
modelsDevProviderModels,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -0,0 +1,84 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { applyModelsDevProviderModels, type ModelsDevProviderModels, normalizeModelsDevProviderModels } from "@shared/models-dev"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { StateManager } from "@/core/storage/StateManager"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
export const MODELS_DEV_CATALOG_URL = "https://models.dev/api.json"
let pendingRefresh: Promise<ModelsDevProviderModels> | null = null
export async function refreshModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const cache = StateManager.get().getModelsDevProviderModelsCache()
if (cache) {
applyModelsDevProviderModels(cache)
return cache
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheModelsDevProviderModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const modelsDevProviderModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.modelsDevProviderModels)
let providerModels: ModelsDevProviderModels = {}
try {
const response = await axios.get(MODELS_DEV_CATALOG_URL, getAxiosSettings())
providerModels = normalizeModelsDevProviderModels(response.data)
if (Object.keys(providerModels).length === 0) {
throw new Error("No supported models.dev provider models found")
}
await fs.writeFile(modelsDevProviderModelsFilePath, JSON.stringify(providerModels))
Logger.log("models.dev provider models fetched and saved")
} catch (error) {
Logger.error("Error fetching models.dev provider models:", error)
const cachedModels = await readModelsDevProviderModelsFromCache()
if (cachedModels && Object.keys(cachedModels).length > 0) {
providerModels = cachedModels
Logger.log("Loaded models.dev provider models from cache")
}
}
if (Object.keys(providerModels).length > 0) {
applyModelsDevProviderModels(providerModels)
StateManager.get().setModelsDevProviderModelsCache(providerModels)
}
return providerModels
}
export async function readModelsDevProviderModelsFromCache(): Promise<ModelsDevProviderModels | undefined> {
try {
const modelsDevProviderModelsFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.modelsDevProviderModels,
)
const fileExists = await fileExistsAtPath(modelsDevProviderModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(modelsDevProviderModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
} catch (error) {
Logger.error("Error reading cached models.dev provider models:", error)
}
return undefined
}
@@ -11,6 +11,7 @@ import { refreshClineModels } from "../models/refreshClineModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshHicapModels } from "../models/refreshHicapModels"
import { refreshLiteLlmModels } from "../models/refreshLiteLlmModels"
import { refreshModelsDevProviderModels } from "../models/refreshModelsDevProviderModels"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
@@ -28,6 +29,14 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
}
refreshModelsDevProviderModels()
.then(async (models) => {
if (models && Object.keys(models).length > 0) {
await controller.postStateToWebview()
}
})
.catch((error) => Logger.error("Failed to refresh models.dev provider models:", error))
// Refresh OpenRouter models from API
refreshOpenRouterModels(controller).then(async (models) => {
if (models && Object.keys(models).length > 0) {
@@ -1,4 +1,5 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import type { ModelsDevProviderModels } from "@shared/models-dev"
import {
ApiHandlerSettingsKeys,
type GlobalState,
@@ -102,6 +103,7 @@ export class StateManager {
liteLlmModels: null,
vercelModels: null,
}
private modelsDevProviderModelsCache: { data: ModelsDevProviderModels; timestamp: number } | null = null
// Debounced persistence state
private pendingGlobalState = new Set<GlobalStateAndSettingsKey>()
@@ -502,6 +504,24 @@ export class StateManager {
return cached.data
}
setModelsDevProviderModelsCache(models: ModelsDevProviderModels): void {
this.modelsDevProviderModelsCache = { data: models, timestamp: Date.now() }
}
getModelsDevProviderModelsCache(): ModelsDevProviderModels | null {
const cached = this.modelsDevProviderModelsCache
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > this.MODEL_CACHE_TTL_MS) {
this.modelsDevProviderModelsCache = null
return null
}
return cached.data
}
/**
* Get model info by provider and model ID (from in-memory cache)
*/
+1
View File
@@ -51,6 +51,7 @@ export const GlobalFileNames = {
uiMessages: "ui_messages.json",
clineRecommendedModels: "cline_recommended_models.json",
clineModels: "cline_models.json",
modelsDevProviderModels: "models_dev_provider_models.json",
openRouterModels: "openrouter_models.json",
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
groqModels: "groq_models.json",
@@ -13,6 +13,7 @@ import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { ClineMessageModelInfo } from "./messages"
import type { ModelsDevProviderModels } from "./models-dev"
import { OnboardingModelGroup } from "./proto/cline/state"
import { Mode } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
@@ -40,6 +41,7 @@ export interface ExtensionState {
isNewUser: boolean
welcomeViewCompleted: boolean
onboardingModels: OnboardingModelGroup | undefined
modelsDevProviderModels?: ModelsDevProviderModels
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
+124
View File
@@ -0,0 +1,124 @@
import { expect } from "chai"
import { afterEach, describe, it } from "mocha"
import { anthropicModels, type ModelInfo } from "./api"
import {
applyModelsDevProviderModels,
type ModelsDevPayload,
mergeModelsDevModels,
normalizeModelsDevProviderModels,
} from "./models-dev"
describe("models.dev static provider augmentation", () => {
const augmentedAnthropicModelId = "claude-test-model-from-models-dev"
afterEach(() => {
delete (anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]
})
it("normalizes supported models.dev models and filters unsupported entries", () => {
const payload: ModelsDevPayload = {
anthropic: {
models: {
[augmentedAnthropicModelId]: {
name: "Claude Test",
tool_call: true,
reasoning: true,
reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
release_date: "2026-01-01",
limit: {
context: 200_000,
output: 64_000,
},
cost: {
input: 3,
output: 15,
cache_read: 0.3,
cache_write: 3.75,
},
modalities: {
input: ["text", "image"],
},
},
"claude-deprecated": {
tool_call: true,
status: "deprecated",
},
"claude-no-tools": {
tool_call: false,
},
},
},
}
const providerModels = normalizeModelsDevProviderModels(payload)
const model = providerModels.anthropic?.[augmentedAnthropicModelId]
expect(model).to.not.equal(undefined)
expect(model?.name).to.equal("Claude Test")
expect(model?.contextWindow).to.equal(200_000)
expect(model?.maxTokens).to.equal(64_000)
expect(model?.supportsImages).to.equal(true)
expect(model?.supportsPromptCache).to.equal(true)
expect(model?.supportsReasoning).to.equal(true)
expect(model?.supportsReasoningEffort).to.equal(true)
expect(model?.inputPrice).to.equal(3)
expect(providerModels.anthropic?.["claude-deprecated"]).to.equal(undefined)
expect(providerModels.anthropic?.["claude-no-tools"]).to.equal(undefined)
})
it("keeps hardcoded model info while appending missing models.dev ids", () => {
const staticModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 1,
contextWindow: 1,
supportsPromptCache: false,
inputPrice: 1,
outputPrice: 1,
},
}
const modelsDevModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 2,
contextWindow: 2,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 2,
},
added: {
maxTokens: 3,
contextWindow: 3,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 3,
},
}
const merged = mergeModelsDevModels(staticModels, modelsDevModels)
expect(merged.existing.maxTokens).to.equal(1)
expect(merged.added.maxTokens).to.equal(3)
expect(Object.keys(merged)).to.deep.equal(["existing", "added"])
})
it("applies missing models.dev ids to existing static provider maps", () => {
const modelInfo: ModelInfo = {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3,
outputPrice: 15,
cacheReadsPrice: 0.3,
cacheWritesPrice: 3.75,
}
applyModelsDevProviderModels({
anthropic: {
[augmentedAnthropicModelId]: modelInfo,
},
})
expect((anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]).to.deep.equal(modelInfo)
})
})
+236
View File
@@ -0,0 +1,236 @@
import {
type ApiProvider,
anthropicModels,
bedrockModels,
cerebrasModels,
deepSeekModels,
fireworksModels,
geminiModels,
huggingFaceModels,
internationalZAiModels,
type ModelInfo,
mainlandZAiModels,
minimaxModels,
mistralModels,
moonshotModels,
nebiusModels,
nousResearchModels,
openAiNativeModels,
sambanovaModels,
vertexModels,
wandbModels,
xaiModels,
} from "./api"
export type ModelsDevModelInfo = ModelInfo & {
releaseDate?: string
family?: string
supportsReasoningEffort?: boolean
supportsTools?: boolean
}
export type ModelsDevProviderModels = Partial<Record<ApiProvider, Record<string, ModelsDevModelInfo>>>
export interface ModelsDevModel {
name?: string
tool_call?: boolean
reasoning?: boolean
structured_output?: boolean
temperature?: boolean
reasoning_options?: {
type?: string
values?: string[]
min?: number
}[]
release_date?: string
family?: string
limit?: {
context?: number
input?: number
output?: number
}
cost?: {
input?: number
output?: number
cache_read?: number
cache_write?: number
}
modalities?: {
input?: string[]
}
status?: string
}
export type ModelsDevPayload = Record<string, { models?: Record<string, ModelsDevModel> }>
const DEFAULT_MAX_TOKENS = 4096
const MODELS_DEV_PROVIDER_KEY_MAP: Record<string, ApiProvider> = {
"amazon-bedrock": "bedrock",
anthropic: "anthropic",
cerebras: "cerebras",
deepseek: "deepseek",
"fireworks-ai": "fireworks",
google: "gemini",
"google-vertex": "vertex",
huggingface: "huggingface",
minimax: "minimax",
mistral: "mistral",
moonshotai: "moonshot",
nebius: "nebius",
"nous-research": "nousResearch",
openai: "openai-native",
sambanova: "sambanova",
wandb: "wandb",
xai: "xai",
zai: "zai",
}
const STATIC_MODELS_BY_PROVIDER: Partial<Record<ApiProvider, Record<string, ModelInfo>>> = {
anthropic: anthropicModels as Record<string, ModelInfo>,
bedrock: bedrockModels as Record<string, ModelInfo>,
cerebras: cerebrasModels as Record<string, ModelInfo>,
deepseek: deepSeekModels as Record<string, ModelInfo>,
fireworks: fireworksModels as Record<string, ModelInfo>,
gemini: geminiModels as Record<string, ModelInfo>,
huggingface: huggingFaceModels as Record<string, ModelInfo>,
minimax: minimaxModels as Record<string, ModelInfo>,
mistral: mistralModels as Record<string, ModelInfo>,
moonshot: moonshotModels as Record<string, ModelInfo>,
nebius: nebiusModels as Record<string, ModelInfo>,
nousResearch: nousResearchModels as Record<string, ModelInfo>,
"openai-native": openAiNativeModels as Record<string, ModelInfo>,
sambanova: sambanovaModels as Record<string, ModelInfo>,
vertex: vertexModels as Record<string, ModelInfo>,
wandb: wandbModels as Record<string, ModelInfo>,
xai: xaiModels as Record<string, ModelInfo>,
zai: internationalZAiModels as Record<string, ModelInfo>,
}
function parseReleaseDate(value: string | undefined): number {
if (!value) {
return Number.NEGATIVE_INFINITY
}
const timestamp = Date.parse(value)
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
}
function sortModelsByReleaseDate(models: Record<string, ModelsDevModelInfo>): Record<string, ModelsDevModelInfo> {
return Object.fromEntries(
Object.entries(models).sort(([modelIdA, modelA], [modelIdB, modelB]) => {
const releaseDateA = parseReleaseDate(modelA.releaseDate)
const releaseDateB = parseReleaseDate(modelB.releaseDate)
if (releaseDateA !== releaseDateB) {
return releaseDateB - releaseDateA
}
return modelIdA.localeCompare(modelIdB)
}),
)
}
function hasCachePricing(cost: ModelsDevModel["cost"]): boolean {
return typeof cost?.cache_read === "number" || typeof cost?.cache_write === "number"
}
function supportsReasoningEffort(model: ModelsDevModel): boolean {
return model.reasoning_options?.some((option) => option.type === "effort") ?? false
}
function toModelInfo(modelId: string, model: ModelsDevModel): ModelsDevModelInfo {
const supportsPromptCache = hasCachePricing(model.cost)
const supportsReasoning = model.reasoning === true
const supportsEffort = supportsReasoningEffort(model)
const info: ModelsDevModelInfo = {
name: model.name || modelId,
maxTokens: Math.floor(model.limit?.output ?? DEFAULT_MAX_TOKENS),
contextWindow: model.limit?.context,
supportsImages: model.modalities?.input?.includes("image") ?? false,
supportsPromptCache,
supportsReasoning,
inputPrice: model.cost?.input ?? 0,
outputPrice: model.cost?.output ?? 0,
cacheReadsPrice: model.cost?.cache_read,
cacheWritesPrice: model.cost?.cache_write,
description: "",
thinkingConfig: supportsReasoning ? { maxBudget: model.limit?.output ?? DEFAULT_MAX_TOKENS } : undefined,
releaseDate: model.release_date,
family: model.family,
supportsReasoningEffort: supportsEffort,
supportsTools: model.tool_call === true,
}
return info
}
function isSupportedModelsDevModel(model: ModelsDevModel): boolean {
return model.tool_call === true && model.status !== "deprecated"
}
export function normalizeModelsDevProviderModels(payload: ModelsDevPayload): ModelsDevProviderModels {
const providerModels: ModelsDevProviderModels = {}
for (const [modelsDevProviderKey, providerId] of Object.entries(MODELS_DEV_PROVIDER_KEY_MAP)) {
const sourceModels = payload[modelsDevProviderKey]?.models
if (!sourceModels) {
continue
}
const models: Record<string, ModelsDevModelInfo> = {}
for (const [modelId, model] of Object.entries(sourceModels)) {
if (!isSupportedModelsDevModel(model)) {
continue
}
models[modelId] = toModelInfo(modelId, model)
}
if (Object.keys(models).length > 0) {
providerModels[providerId] = sortModelsByReleaseDate(models)
}
}
return providerModels
}
export function getStaticModelsForModelsDevProvider(providerId: ApiProvider): Record<string, ModelInfo> | undefined {
return STATIC_MODELS_BY_PROVIDER[providerId]
}
export function mergeModelsDevModels(
staticModels: Record<string, ModelInfo>,
modelsDevModels: Record<string, ModelInfo> | undefined,
): Record<string, ModelInfo> {
if (!modelsDevModels || Object.keys(modelsDevModels).length === 0) {
return staticModels
}
const additions = Object.fromEntries(Object.entries(modelsDevModels).filter(([modelId]) => !(modelId in staticModels)))
return {
...staticModels,
...additions,
}
}
export function applyModelsDevProviderModels(providerModels: ModelsDevProviderModels | undefined): void {
if (!providerModels) {
return
}
for (const [providerId, models] of Object.entries(providerModels) as [ApiProvider, Record<string, ModelInfo>][]) {
const staticModels = getStaticModelsForModelsDevProvider(providerId)
if (!staticModels) {
continue
}
for (const [modelId, modelInfo] of Object.entries(models)) {
if (!(modelId in staticModels)) {
staticModels[modelId] = modelInfo
}
if (providerId === "zai") {
const mainlandModels = mainlandZAiModels as Record<string, ModelInfo>
if (!(modelId in mainlandModels)) {
mainlandModels[modelId] = modelInfo
}
}
}
}
}
@@ -4,6 +4,7 @@ import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
import { applyModelsDevProviderModels } from "@shared/models-dev"
import type { UserInfo } from "@shared/proto/cline/account"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
@@ -359,6 +360,7 @@ export const ExtensionStateContextProvider: React.FC<{
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
applyModelsDevProviderModels(stateData.modelsDevProviderModels)
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1