mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
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:
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user