feat(vscode): explain when a free model promotion ends (#12970)

* feat(vscode): explain when a free model promotion ends

Once a free promotion ends, the cline-free/ model is removed from the
catalog and the backend answers 'model not found' to requests against it.
The CLI has shown a dedicated 'Free model promotion ended' banner for this
since #12593; the extension instead rewrote the answer into generic
model-not-found guidance with no model-picker offramp.

Detect the case in the host where the active model id is known
(reshapeErrorForWebview, fed by a new MessageTranslatorState model-id
source), stamp the payload with a cline_free_promotion_ended code, and
render a dedicated card in the webview with a button into the model
picker. Classification is gated on the cline-free/ prefix so ordinary
model-not-found errors keep their generic path, and it runs before the
auth branch since the 404 status falls inside the generic auth range.

* fix(vscode): prefer the live task model over session-start metadata

A mid-task model-only switch updates the running session's model in place
(updateActiveSessionModel) and refreshes the task API shim, but never
touches the session's startConfig/manifest. Preferring the session-start
snapshot could therefore misclassify after such a switch: a genuine
retired-model 404 would miss the promotion-ended card, and the reverse
switch could show it for the wrong model. Provider switches restart the
session, so both sources agree there; the shim starts as "unknown"
(filtered out), so fresh sessions still resolve through start metadata.
This commit is contained in:
Saoud Rizwan
2026-08-05 14:00:09 -07:00
committed by GitHub
parent 1d7d9ce5e2
commit b1a89156d6
8 changed files with 225 additions and 2 deletions
+9
View File
@@ -298,6 +298,15 @@ export class Controller {
() => this.getActiveProviderId(),
() => (this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"),
() => this.lastKnownWorkspaceRoot,
// Model backing the active turn — lets error reshaping recognize
// retired cline-free/ models (the error payload itself never names one).
// The task shim is preferred over session-start metadata: a mid-task
// model-only switch updates the running session's model in place
// (updateActiveSessionModel) and refreshes the shim, but never touches
// startConfig/manifest, which would otherwise report the stale model.
// The shim starts as "unknown" (filtered out by getTaskModelId), so
// fresh sessions still resolve through their start metadata.
() => this.getTaskModelId() ?? this.getSessionModelId(),
)
// Warm the synchronous workspace-root snapshot used for display-path
// relativization (getWorkspaceRoot never rejects — it falls back internally).
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest"
import { ClineError, ClineErrorType } from "../services/error/ClineError"
import { reshapeErrorForWebview } from "./message-translator"
// Once a free promotion ends the cline-free/ model is removed from the catalog
// and the backend answers "model not found". These tests pin the host-side
// detection that turns that answer into the webview's promotion-ended card.
describe("reshapeErrorForWebview - free promotion ended", () => {
it("stamps the promotion-ended code when a cline-free model answers model-not-found", () => {
const payload = reshapeErrorForWebview({ message: "Error 404: Model not found" }, "cline", "cline-free/glm-5")
const parsed = JSON.parse(payload)
expect(parsed.code).toBe("cline_free_promotion_ended")
expect(parsed.modelId).toBe("cline-free/glm-5")
expect(parsed.providerId).toBe("cline")
expect(parsed.details?.code).toBe("cline_free_promotion_ended")
})
it("keeps the selected provider id, so cline-pass selections stay attributed", () => {
const payload = reshapeErrorForWebview({ message: "Model not found" }, "cline-pass", "cline-free/glm-5")
expect(JSON.parse(payload).providerId).toBe("cline-pass")
})
it("round-trips into the webview's ClineFreePromotionEnded classification", () => {
const payload = reshapeErrorForWebview({ message: "Error 404: Model not found" }, "cline", "cline-free/glm-5")
const clineError = ClineError.parse(payload)
expect(clineError && ClineError.getErrorType(clineError)).toBe(ClineErrorType.ClineFreePromotionEnded)
})
it("leaves model-not-found for a paid model on the generic guidance path", () => {
const payload = reshapeErrorForWebview({ message: "Model not found" }, "cline", "deepseek/deepseek-v4-flash")
expect(payload).toBe(
"Model not found This model may be retired or unavailable on your account. Switch to a different model in API Configuration settings, then retry.",
)
})
it("leaves model-not-found on the generic guidance path when the model id is unknown", () => {
const payload = reshapeErrorForWebview({ message: "Model not found" }, "cline")
expect(payload).toContain("This model may be retired or unavailable")
})
it("does not touch unrelated errors from a cline-free model", () => {
expect(reshapeErrorForWebview({ message: "socket hang up" }, "cline", "cline-free/glm-5")).toBe("socket hang up")
})
})
+30 -2
View File
@@ -46,6 +46,7 @@ import type {
import { Logger } from "@shared/services/Logger"
import * as path from "path"
import { arePathsEqual, getDesktopDir } from "@/utils/path"
import { CLINE_FREE_PROMOTION_ENDED_ERROR_CODE, isClineFreePromotionEndedMessage } from "../services/error/ClineError"
import { MessageIdMinter } from "./message-id-minter"
import { describeMissingCredentialError } from "./provider-credential-error"
import { isSyntheticSdkUserMessage } from "./sdk-user-message-mapping"
@@ -152,6 +153,7 @@ export class MessageTranslatorState {
private readonly getActiveProviderId?: () => string | undefined,
private readonly getUiMode?: () => "plan" | "act" | "yolo" | undefined,
private readonly getCwd?: () => string | undefined,
private readonly getActiveModelId?: () => string | undefined,
) {
this.minter = minter
}
@@ -161,6 +163,11 @@ export class MessageTranslatorState {
return this.getActiveProviderId?.()
}
/** Model backing the active turn, if the host can supply it. */
activeModelId(): string | undefined {
return this.getActiveModelId?.()
}
/**
* The task's working directory, used to relativize the absolute filesystem
* paths in tool inputs before they reach the webview. Undefined when the
@@ -1909,7 +1916,7 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
// `code: "insufficient_credits"`). We try to reshape it into the
// ClineError-serialized format the webview expects so that ErrorRow
// can render the correct UI (Buy Credits button, etc.).
const errorPayload = reshapeErrorForWebview(event.error, state.activeProviderId())
const errorPayload = reshapeErrorForWebview(event.error, state.activeProviderId(), state.activeModelId())
// Emit an api_req_started with streamingFailedMessage so the
// RequestStartRow renders the error via ErrorRow. This replaces
@@ -2530,7 +2537,11 @@ function describeModelNotFoundError(rawMessage: string): string | undefined {
* ErrorRow expects (`code`, `providerId`, `details`), extracting structured
* info from the error message when present and falling back to raw text.
*/
export function reshapeErrorForWebview(error: { message?: string; status?: number; code?: string }, providerId?: string): string {
export function reshapeErrorForWebview(
error: { message?: string; status?: number; code?: string },
providerId?: string,
modelId?: string,
): string {
// The ClineError-JSON branches below are cline-provider flows (balance,
// spend limit), so "cline" stays their fallback id. The missing-credential
// message instead gets the raw value: defaulting there would name the wrong
@@ -2538,6 +2549,23 @@ export function reshapeErrorForWebview(error: { message?: string; status?: numbe
const clineErrorProviderId = providerId ?? "cline"
const rawMessage = error.message ?? "Unknown error"
// A retired cline-free/ model answers "model not found" once its free
// promotion ends and the id is removed from the catalog. Stamp the payload
// with a dedicated code so the webview renders the promotion-ended card
// instead of the generic model-not-found guidance below.
if (isClineFreePromotionEndedMessage(rawMessage, modelId)) {
return JSON.stringify({
message: rawMessage,
code: CLINE_FREE_PROMOTION_ENDED_ERROR_CODE,
providerId: clineErrorProviderId,
modelId,
details: {
code: CLINE_FREE_PROMOTION_ENDED_ERROR_CODE,
message: rawMessage,
},
})
}
// Try to extract structured error info from the error message.
// The SDK often wraps API error JSON in the Error.message field.
let parsed: Record<string, unknown> | undefined
@@ -1,6 +1,7 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
isClineFreeModelLimitMessage,
isClineModelNotFoundMessage,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitMessage,
@@ -18,6 +19,24 @@ export enum ClineErrorType {
OrgClinePassRestriction = "orgClinePassRestriction",
ClinePassLimit = "clinePassLimit",
ClineFreeModelLimit = "clineFreeModelLimit",
ClineFreePromotionEnded = "clineFreePromotionEnded",
}
export const CLINE_FREE_MODEL_ID_PREFIX = "cline-free/"
/** Error code stamped by the host when it detects a retired free model (see message-translator). */
export const CLINE_FREE_PROMOTION_ENDED_ERROR_CODE = "cline_free_promotion_ended"
/**
* Detects a request against a retired free model: once a promotion ends the
* cline-free/ model is removed from the catalog and the backend answers "model
* not found". The modelId gate keeps ordinary model-not-found errors on their
* generic path. Mirrors the CLI's detection in apps/cli/src/utils/cline-pass-errors.ts.
*/
export function isClineFreePromotionEndedMessage(message: string, modelId?: string): boolean {
if (!modelId?.toLowerCase().startsWith(CLINE_FREE_MODEL_ID_PREFIX)) {
return false
}
return isClineModelNotFoundMessage(message)
}
interface ErrorDetails {
@@ -201,6 +220,18 @@ export class ClineError extends Error {
return ClineErrorType.ClinePassLimit
}
// Retired free models must be classified before the auth branch: the
// backend's model-not-found answer is a 404, which falls inside the
// generic 401-428 auth-status range below.
if (
code === CLINE_FREE_PROMOTION_ENDED_ERROR_CODE ||
details?.code === CLINE_FREE_PROMOTION_ENDED_ERROR_CODE ||
(detailMessage ? isClineFreePromotionEndedMessage(detailMessage, err.modelId) : false) ||
(rawMessage ? isClineFreePromotionEndedMessage(rawMessage, err.modelId) : false)
) {
return ClineErrorType.ClineFreePromotionEnded
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
@@ -71,5 +71,45 @@ describe("ClineError", () => {
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClineFreeModelLimit)
})
it("should classify the host-stamped promotion-ended code as ClineFreePromotionEnded", () => {
// reshapeErrorForWebview stamps this code when the active model is a
// retired cline-free/ id (see message-translator).
const err = new ClineError({
message: "Model not found",
code: "cline_free_promotion_ended",
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClineFreePromotionEnded)
})
it("should classify model-not-found for a cline-free model as ClineFreePromotionEnded", () => {
const err = new ClineError({ message: "Error 404: Model not found" }, "cline-free/glm-5")
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClineFreePromotionEnded)
})
it("should prefer ClineFreePromotionEnded over Auth for a 404 with a cline-free model", () => {
// A 404 falls inside the generic 401-428 auth-status range; the
// promotion-ended classification must win.
const err = new ClineError({ message: "Error 404: Model not found", status: 404 }, "cline-free/glm-5")
const result = ClineError.getErrorType(err)
result!.should.equal(ClineErrorType.ClineFreePromotionEnded)
})
it("should keep model-not-found for a non-free model on the generic path", () => {
const err = new ClineError({ message: "Error 404: Model not found", status: 404 }, "deepseek/deepseek-v4-flash")
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.ClineFreePromotionEnded).should.be.true()
})
it("should not classify unrelated cline-free errors as ClineFreePromotionEnded", () => {
const err = new ClineError({ message: "Network error: socket hang up" }, "cline-free/glm-5")
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.ClineFreePromotionEnded).should.be.true()
})
})
})
@@ -0,0 +1,30 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
// Shown when a request targets a retired cline-free/ model: once its free
// promotion ends the model is removed from the catalog and the backend answers
// "model not found". Mirrors the CLI's "Free model promotion ended" banner,
// with the CLI's "/model" hint replaced by a button into the model picker.
const ClineFreePromotionEndedError = () => {
const { navigateToSettingsModelPicker } = useExtensionState()
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="cline-free-promotion-ended-error">
<div className="text-error mb-2">Free model promotion ended</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
The free promotion for this model has ended and it is no longer available.
</div>
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">Select another model to continue.</div>
<VSCodeButton
appearance="primary"
className="w-full mt-3"
onClick={() => navigateToSettingsModelPicker({ targetSection: "api-config" })}>
Select a Model
</VSCodeButton>
</div>
)
}
export default ClineFreePromotionEndedError
@@ -5,6 +5,7 @@ import ErrorRow from "./ErrorRow"
const mockSetUserOrganization = vi.hoisted(() => vi.fn())
const mockUpdateApiConfigurationProto = vi.hoisted(() => vi.fn())
const mockNavigateToSettingsModelPicker = vi.hoisted(() => vi.fn())
const mockApiConfiguration = vi.hoisted(() => ({
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
@@ -30,6 +31,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({
providerModelsByProvider: {},
startProviderModelsRequest: vi.fn(),
applyProviderModelsResponse: vi.fn(),
navigateToSettingsModelPicker: mockNavigateToSettingsModelPicker,
}),
}))
@@ -67,6 +69,7 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
OrgClinePassRestriction: "orgClinePassRestriction",
ClinePassLimit: "clinePassLimit",
ClineFreeModelLimit: "clineFreeModelLimit",
ClineFreePromotionEnded: "clineFreePromotionEnded",
QuotaExceeded: "quotaExceeded",
},
}))
@@ -335,6 +338,31 @@ describe("ErrorRow", () => {
expect(screen.queryByText(/Switch to Usage-Based billing/i)).not.toBeInTheDocument()
})
it("renders the promotion-ended card with a route into the model picker", async () => {
const rawMessage = "Error 404: Model not found"
const mockClineError = {
message: rawMessage,
isErrorType: vi.fn((type) => type === "clineFreePromotionEnded"),
providerId: "cline",
modelId: "cline-free/glm-5",
_error: { message: rawMessage },
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow apiRequestFailedMessage={rawMessage} errorType="error" message={mockMessage} />)
expect(screen.getByTestId("cline-free-promotion-ended-error")).toBeInTheDocument()
expect(screen.getByText("Free model promotion ended")).toBeInTheDocument()
expect(screen.getByText(/no longer available/)).toBeInTheDocument()
// The raw backend message is replaced by the dedicated copy.
expect(screen.queryByText(rawMessage)).not.toBeInTheDocument()
fireEvent.click(screen.getByText("Select a Model"))
expect(mockNavigateToSettingsModelPicker).toHaveBeenCalledWith({ targetSection: "api-config" })
})
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
@@ -2,6 +2,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
import { memo } from "react"
import { ClineAuthStatus } from "@/components/account/ClineAuthStatus"
import ClineFreeModelLimitError from "@/components/chat/ClineFreeModelLimitError"
import ClineFreePromotionEndedError from "@/components/chat/ClineFreePromotionEndedError"
import ClinePassLimitError from "@/components/chat/ClinePassLimitError"
import CreditLimitError from "@/components/chat/CreditLimitError"
import EntitlementError from "@/components/chat/EntitlementError"
@@ -90,6 +91,13 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
return <ClineFreeModelLimitError message={detailMessage} />
}
// A retired free model answers model-not-found once its promotion
// ends — dedicated copy plus a route into the model picker,
// since retrying the deleted model can never succeed.
if (clineError?.isErrorType(ClineErrorType.ClineFreePromotionEnded)) {
return <ClineFreePromotionEndedError />
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">