fix onboarding models

This commit is contained in:
Max Paulus 🥪
2026-06-23 17:04:55 +09:00
committed by Dominic Cooney
parent 6dde25f2b0
commit e19abb90b7
3 changed files with 59 additions and 48 deletions
@@ -1,17 +1,13 @@
import * as sdkCore from "@cline/core"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClineEnv } from "@/config"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
// The HTTP fetch + normalization + offline fallback now lives in the SDK
// The HTTP fetch + normalization + offline fallback lives in the SDK
// (`@cline/core` `fetchClineRecommendedModels`). These tests cover the
// extension-side wrapper: the feature-flag gate, delegation to the SDK, and the
// in-memory cache / flag re-check. This suite is vitest-native (not mocha)
// because it imports the ESM-only `@cline/core`; vitest aliases it to
// src/test/cline-core-vitest-stub.ts, which we spy on here.
// extension-side wrapper: delegation to the SDK and in-memory caching. There is
// intentionally no feature-flag gate here; onboarding must not race against the
// remote-config cache and accidentally keep the hardcoded fallback list.
describe("refreshClineRecommendedModels", () => {
beforeEach(() => {
@@ -28,20 +24,7 @@ describe("refreshClineRecommendedModels", () => {
vi.restoreAllMocks()
})
it("returns the hardcoded fallback list and skips the SDK fetch when the rollout flag is off", async () => {
vi.spyOn(getFeatureFlagsService(), "getBooleanFlagEnabled").mockReturnValue(false)
const sdkSpy = vi.spyOn(sdkCore, "fetchClineRecommendedModels")
const result = await refreshClineRecommendedModels()
expect(result).toEqual(CLINE_RECOMMENDED_MODELS_FALLBACK)
expect(sdkSpy).not.toHaveBeenCalled()
})
it("delegates to the SDK fetch when the rollout flag is on", async () => {
vi.spyOn(getFeatureFlagsService(), "getBooleanFlagEnabled").mockImplementation(
(flag) => flag === FeatureFlag.CLINE_RECOMMENDED_MODELS_UPSTREAM,
)
it("delegates to the SDK fetch", async () => {
const sdkResult = {
recommended: [{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6", description: "Remote", tags: ["NEW"] }],
free: [{ id: "z-ai/glm-5", name: "GLM 5", description: "Remote free", tags: [] }],
@@ -54,18 +37,37 @@ describe("refreshClineRecommendedModels", () => {
expect(result).toEqual(sdkResult)
})
it("re-checks the rollout flag on each call (off after on returns the fallback)", async () => {
const flagSpy = vi.spyOn(getFeatureFlagsService(), "getBooleanFlagEnabled")
flagSpy.mockReturnValueOnce(true).mockReturnValueOnce(false)
vi.spyOn(sdkCore, "fetchClineRecommendedModels").mockResolvedValue({
it("uses the in-memory cache after a populated upstream result", async () => {
const sdkResult = {
recommended: [{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", description: "Remote", tags: ["NEW"] }],
free: [],
})
}
const sdkSpy = vi.spyOn(sdkCore, "fetchClineRecommendedModels").mockResolvedValue(sdkResult)
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
expect(firstResult).not.toEqual(CLINE_RECOMMENDED_MODELS_FALLBACK)
expect(secondResult).toEqual(CLINE_RECOMMENDED_MODELS_FALLBACK)
expect(sdkSpy).toHaveBeenCalledTimes(1)
expect(secondResult).toEqual(firstResult)
})
it("does not cache the SDK fallback result", async () => {
const sdkFallbackClone = structuredClone(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
const sdkSpy = vi
.spyOn(sdkCore, "fetchClineRecommendedModels")
.mockResolvedValueOnce(sdkFallbackClone)
.mockResolvedValueOnce({
recommended: [
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6", description: "Remote", tags: ["NEW"] },
],
free: [],
})
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
expect(sdkSpy).toHaveBeenCalledTimes(2)
expect(firstResult).toEqual(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
expect(secondResult).not.toEqual(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
})
})
@@ -1,9 +1,6 @@
import { fetchClineRecommendedModels } from "@cline/core"
import { FALLBACK_CLINE_RECOMMENDED_MODELS, fetchClineRecommendedModels } from "@cline/core"
import { ClineEnv } from "@/config"
import { featureFlagsService } from "@/services/feature-flags"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import { fetch } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
export interface ClineRecommendedModelData {
id: string
@@ -22,19 +19,7 @@ const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
function getHardcodedRecommendedModels(): ClineRecommendedModelsData {
return CLINE_RECOMMENDED_MODELS_FALLBACK
}
function useUpstreamRecommendedModels(): boolean {
return featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_RECOMMENDED_MODELS_UPSTREAM)
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
if (!useUpstreamRecommendedModels()) {
return getHardcodedRecommendedModels()
}
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
}
@@ -59,20 +44,25 @@ export function resetClineRecommendedModelsCacheForTests(): void {
inMemoryCache = null
}
function isFallbackRecommendedModels(data: ClineRecommendedModelsData): boolean {
return JSON.stringify(data) === JSON.stringify(FALLBACK_CLINE_RECOMMENDED_MODELS)
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
// Delegate the actual HTTP fetch + response normalization + offline fallback
// to the SDK so the CLI/JetBrains and the extension share one implementation.
// We pass the proxy-aware fetch (per .clinerules/network.md) and the
// extension's configured API base URL. On failure the SDK returns its own
// fallback list (identical to CLINE_RECOMMENDED_MODELS_FALLBACK).
// fallback list.
const result = await fetchClineRecommendedModels({
baseUrl: ClineEnv.config().apiBaseUrl,
fetchImpl: fetch,
})
// Only pin a populated, non-fallback result in memory for the full TTL; a
// transient failure (SDK returns the fallback) should be retried next call.
if ((result.recommended.length > 0 || result.free.length > 0) && result !== CLINE_RECOMMENDED_MODELS_FALLBACK) {
// transient failure (SDK returns a clone of its fallback) should be retried
// next call.
if ((result.recommended.length > 0 || result.free.length > 0) && !isFallbackRecommendedModels(result)) {
inMemoryCache = { data: result, timestamp: Date.now() }
}
return result
@@ -187,6 +187,25 @@ export interface ClineRecommendedModelsData {
free: ClineRecommendedModel[]
}
export const FALLBACK_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsData = {
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
description: "Strong coding and agent performance",
tags: ["NEW"],
},
],
free: [
{
id: "z-ai/glm-5",
name: "GLM 5",
description: "Remote free",
tags: [],
},
],
}
export async function fetchClineRecommendedModels(_options?: {
baseUrl?: string
fetchImpl?: typeof fetch