Add Client Type to Recommended Models requests (#14224)

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Tomás Barreiro
2026-09-17 12:40:44 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent 721d185485
commit 7f75100da2
24 changed files with 312 additions and 7 deletions
+3
View File
@@ -1,4 +1,5 @@
import { Readable, Writable } from "node:stream";
import { registerClineClientIdentity } from "../utils/cline-client-identity";
import { writeDiagnostic } from "../utils/output";
export interface AcpModeOptions {
@@ -11,6 +12,8 @@ export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
);
const { AcpAgent } = await import("./acpAgent");
registerClineClientIdentity("cline-acp");
writeDiagnostic("[acp] starting ACP mode over stdio…");
const stream = ndJsonStream(
+2
View File
@@ -15,6 +15,7 @@ import {
cleanupActiveRuntime,
isAbortInProgress,
} from "./runtime/active-runtime";
import { registerClineClientIdentity } from "./utils/cline-client-identity";
import { resolveCliLaunchSpec } from "./utils/internal-launch";
import { writeErr } from "./utils/output";
@@ -32,6 +33,7 @@ if (!isMainThread) {
// daemon-hosted session spawns do not inherit it and try to become daemons.
// The hub daemon owns its process-level abort handling. Installing the CLI's
// fatal rejection handler first would make expected abort rejections exit it.
registerClineClientIdentity("cline-cli");
void import("@cline/core/hub/daemon-entry");
} else {
// Same reasoning as the daemon sentinel above: consume the supervised-connector
+2
View File
@@ -18,6 +18,7 @@ import {
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { filterChatModels } from "./utils/chat-models";
import { registerClineClientIdentity } from "./utils/cline-client-identity";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -146,6 +147,7 @@ function startupTargetTakesPrecedenceOverMigrationNotice(
}
export async function runCli(): Promise<void> {
registerClineClientIdentity("cline-cli");
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -0,0 +1,12 @@
import { setClineClientIdentity } from "@cline/shared";
import { getCliBuildInfo } from "./common";
export function registerClineClientIdentity(name: string): void {
const { version } = getCliBuildInfo();
setClineClientIdentity({
name,
version,
platform: "cli",
platformVersion: version,
});
}
@@ -1,5 +1,7 @@
import type { AgentEvent } from "@cline/core";
import { setClineClientIdentity } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerClineClientIdentity } from "./cline-client-identity";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
@@ -9,6 +11,7 @@ import {
afterEach(() => {
clearClineFreeModelCostCache();
setClineClientIdentity(undefined);
vi.unstubAllGlobals();
});
@@ -39,6 +42,29 @@ describe("shouldZeroClineFreeModelCost", () => {
);
});
it("identifies the CLI client on the free model request", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(JSON.stringify({ free: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
);
vi.stubGlobal("fetch", fetchMock);
registerClineClientIdentity("cline-cli");
await shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
});
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
"X-CLIENT-TYPE": "cline-cli",
});
});
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
vi.stubGlobal(
"fetch",
+2 -1
View File
@@ -1,4 +1,4 @@
import type { AgentEvent } from "@cline/core";
import { type AgentEvent, buildClineClientHeaders } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
@@ -37,6 +37,7 @@ async function fetchClineFreeModelIds(
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
headers: buildClineClientHeaders(),
signal: controller.signal,
});
if (!response.ok) return undefined;
@@ -12,8 +12,10 @@ import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper";
import {
captureSdkError,
disableCurrentDirectoryExecutableSearch,
setClineClientIdentity,
} from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
import { configureConnectorCliLaunch } from "./connectors";
import {
broadcastEvent,
@@ -236,6 +238,8 @@ async function runEntrypoint(): Promise<void> {
runTelemetrySelfcheck();
return;
}
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
disableCurrentDirectoryExecutableSearch();
if (await runRemoteHelperEntrypoint()) {
return;
+3
View File
@@ -12,6 +12,7 @@ import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
import { registerClineClientIdentity } from "./services/ClineClientIdentity"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId } from "./services/logging/distinctId"
@@ -63,6 +64,8 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
})
}
void registerClineClientIdentity()
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
// which depend on the `vscode` module and cannot live in the SDK package.
// Must run before any handler is built (standalone utilities or task loop).
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const identityMocks = vi.hoisted(() => ({
setClineClientIdentity: vi.fn(),
}))
const hostState = vi.hoisted(() => ({
hostVersion: {} as {
platform?: string
version?: string
clineType?: string
clineVersion?: string
},
hostVersionError: undefined as Error | undefined,
}))
vi.mock("@cline/shared", () => ({
setClineClientIdentity: identityMocks.setClineClientIdentity,
}))
vi.mock("@/hosts/host-provider", () => ({
HostProvider: {
env: {
getHostVersion: vi.fn(async () => {
if (hostState.hostVersionError) {
throw hostState.hostVersionError
}
return hostState.hostVersion
}),
},
},
}))
vi.mock("@/shared/services/Logger", () => ({
Logger: { log: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock("@/registry", () => ({
ExtensionRegistryInfo: { version: "9.9.9" },
}))
import { registerClineClientIdentity } from "./ClineClientIdentity"
describe("registerClineClientIdentity", () => {
beforeEach(() => {
identityMocks.setClineClientIdentity.mockClear()
hostState.hostVersionError = undefined
hostState.hostVersion = {
platform: "Visual Studio Code",
version: "1.103.0",
clineType: "VSCode Extension",
clineVersion: "3.40.0",
}
})
it("publishes the host-reported client identity", async () => {
await registerClineClientIdentity()
expect(identityMocks.setClineClientIdentity).toHaveBeenCalledWith({
name: "VSCode Extension",
version: "3.40.0",
platform: "Visual Studio Code",
platformVersion: "1.103.0",
})
})
it("falls back to the extension's own identity when the host bridge fails", async () => {
hostState.hostVersionError = new Error("host bridge unavailable")
await registerClineClientIdentity()
expect(identityMocks.setClineClientIdentity).toHaveBeenCalledWith({
name: "VSCode Extension",
version: "9.9.9",
})
})
})
@@ -0,0 +1,24 @@
import { setClineClientIdentity } from "@cline/shared"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineClient } from "@/shared/cline"
import { EmptyRequest } from "@/shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
export async function registerClineClientIdentity(): Promise<void> {
try {
const host = await HostProvider.env.getHostVersion(EmptyRequest.create({}))
setClineClientIdentity({
name: host.clineType || ClineClient.VSCode,
version: host.clineVersion || ExtensionRegistryInfo.version,
platform: host.platform || undefined,
platformVersion: host.version || undefined,
})
} catch (error) {
Logger.log("Failed to get IDE/platform info via HostBridge EnvService.getHostVersion", error)
setClineClientIdentity({
name: ClineClient.VSCode,
version: ExtensionRegistryInfo.version,
})
}
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
"src/core/controller/remoteConfig/**/*.test.ts",
"src/core/controller/state/**/*.test.ts",
"src/core/controller/slash/**/*.test.ts",
"src/services/ClineClientIdentity.test.ts",
"src/services/mcp/__tests__/settingsLock.test.ts",
"src/shared/model-catalog/provider-helpers.test.ts",
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
+1
View File
@@ -6,6 +6,7 @@
export * as Llms from "@cline/llms";
export {
buildClineClientHeaders,
ClineFreeModelLimitError,
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
@@ -2,7 +2,8 @@ import {
GENERATED_CLINE_RECOMMENDED_MODELS,
getGeneratedProviderModels,
} from "@cline/llms";
import { describe, expect, it } from "vitest";
import { setClineClientIdentity } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyClineFeaturedModels,
type ClineRecommendedModelsData,
@@ -94,6 +95,10 @@ function namesOf(data: ClineRecommendedModelsData) {
}
describe("fetchClineRecommendedModels", () => {
afterEach(() => {
setClineClientIdentity(undefined);
});
it("resolves display names from the models catalog", async () => {
const data = await fetchClineRecommendedModels({
baseUrl: BASE_URL,
@@ -118,6 +123,22 @@ describe("fetchClineRecommendedModels", () => {
]);
});
it("identifies the client on the feed request", async () => {
setClineClientIdentity({ name: "VSCode Extension", version: "3.40.0" });
const fetchImpl = vi.fn(jsonResponse(ENDPOINT_PAYLOAD));
await fetchClineRecommendedModels({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch,
catalogLoader: async () => CATALOG,
});
expect(fetchImpl.mock.calls[0]?.[1]?.headers).toMatchObject({
"X-CLIENT-TYPE": "VSCode Extension",
"X-CLIENT-VERSION": "3.40.0",
});
});
it("degrades to endpoint names and id slugs when the catalog is unavailable", async () => {
const data = await fetchClineRecommendedModels({
baseUrl: BASE_URL,
@@ -1,4 +1,5 @@
import {
buildClineClientHeaders,
GENERATED_CLINE_RECOMMENDED_MODELS,
getGeneratedProviderModels,
VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES,
@@ -133,11 +134,12 @@ async function fetchWithTimeout(
fetchImpl: typeof fetch,
input: string,
timeoutMs: number,
headers: Record<string, string>,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(input, { signal: controller.signal });
return await fetchImpl(input, { headers, signal: controller.signal });
} finally {
clearTimeout(timer);
}
@@ -265,6 +267,7 @@ export async function fetchClineRecommendedModels(
fetchImpl,
`${base}/api/v1/ai/cline/recommended-models`,
timeoutMs,
buildClineClientHeaders(),
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json: unknown = await resp.json();
@@ -1,4 +1,5 @@
import { getClineEnvironmentConfig } from "@cline/shared";
import { buildClineClientHeaders } from "../providers/cline-client-headers";
import type { ModelInfo } from "./types";
export interface ClineRecommendedModelEntry {
@@ -159,7 +160,7 @@ export async function fetchClineRecommendedModelsPayload(
fetcher: typeof fetch = fetch,
): Promise<ClineRecommendedModelsPayload> {
const url = `${getClineEnvironmentConfig().apiBaseUrl}/api/v1/ai/cline/recommended-models`;
const response = await fetcher(url);
const response = await fetcher(url, { headers: buildClineClientHeaders() });
if (!response.ok) {
throw new Error(
`Failed to load Cline recommended models from ${url}: HTTP ${response.status}`,
@@ -1143,6 +1143,9 @@ describe("models-dev-catalog", () => {
expect(fetcher).toHaveBeenCalledWith("https://models.dev/api.json");
expect(fetcher).toHaveBeenCalledWith(
"https://api.cline.bot/api/v1/ai/cline/recommended-models",
expect.objectContaining({
headers: expect.objectContaining({ "X-CLIENT-TYPE": "cline-sdk" }),
}),
);
expect(result.openrouter).toHaveProperty("vendor/live-base-model");
expect(result["cline-pass"]?.["cline-pass/live-base-model"]).toMatchObject({
+1
View File
@@ -37,6 +37,7 @@ export {
resolveProviderUsageCostDisplay,
shouldShowProviderUsageCost,
} from "./providers/billing";
export { buildClineClientHeaders } from "./providers/cline-client-headers";
export {
type ProviderLocalCli,
resolveProviderLocalCli,
+1
View File
@@ -106,6 +106,7 @@ export {
resolveProviderUsageCostDisplay,
shouldShowProviderUsageCost,
} from "./providers/billing";
export { buildClineClientHeaders } from "./providers/cline-client-headers";
export type * from "./providers/gateway";
export { createGateway, DefaultGateway } from "./providers/gateway";
export {
@@ -0,0 +1,57 @@
import { getClineClientIdentity, setClineClientIdentity } from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import { buildClineClientHeaders } from "./cline-client-headers";
afterEach(() => {
setClineClientIdentity(undefined);
});
describe("buildClineClientHeaders", () => {
it("falls back to the SDK client type when no identity is registered", () => {
expect(getClineClientIdentity()).toBeUndefined();
expect(buildClineClientHeaders()).toEqual({
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-IS-MULTIROOT": "false",
"User-Agent": "Cline/unknown",
"X-CLIENT-TYPE": "cline-sdk",
"X-CLIENT-VERSION": "unknown",
"X-PLATFORM": "cline-sdk",
"X-PLATFORM-VERSION": "unknown",
});
});
it("stamps the registered client identity", () => {
setClineClientIdentity({
name: "VSCode Extension",
version: "3.40.0",
platform: "Visual Studio Code",
platformVersion: "1.100.0",
});
expect(buildClineClientHeaders()).toMatchObject({
"User-Agent": "Cline/3.40.0",
"X-CLIENT-TYPE": "VSCode Extension",
"X-CLIENT-VERSION": "3.40.0",
"X-PLATFORM": "Visual Studio Code",
"X-PLATFORM-VERSION": "1.100.0",
});
});
it("ignores blank identity fields and honors an explicit override", () => {
setClineClientIdentity({ name: " ", version: "9.9.9" });
expect(buildClineClientHeaders()).toMatchObject({
"X-CLIENT-TYPE": "cline-sdk",
"X-CLIENT-VERSION": "9.9.9",
"X-PLATFORM": "cline-sdk",
"X-PLATFORM-VERSION": "9.9.9",
});
expect(
buildClineClientHeaders({ name: "cline-cli", version: "1.2.3" }),
).toMatchObject({
"X-CLIENT-TYPE": "cline-cli",
"X-CLIENT-VERSION": "1.2.3",
});
});
});
@@ -0,0 +1,28 @@
import {
type ClineClientIdentity,
getClineClientIdentity,
} from "@cline/shared";
import { DEFAULT_CLINE_REQUEST_HEADERS } from "./request-headers";
function trimNonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
}
export function buildClineClientHeaders(
identity: ClineClientIdentity | undefined = getClineClientIdentity(),
): Record<string, string> {
const clientType =
trimNonEmpty(identity?.name) ??
DEFAULT_CLINE_REQUEST_HEADERS["X-CLIENT-TYPE"];
const clientVersion = trimNonEmpty(identity?.version) ?? "unknown";
return {
...DEFAULT_CLINE_REQUEST_HEADERS,
"User-Agent": `Cline/${clientVersion}`,
"X-CLIENT-TYPE": clientType,
"X-CLIENT-VERSION": clientVersion,
"X-PLATFORM": trimNonEmpty(identity?.platform) ?? clientType,
"X-PLATFORM-VERSION":
trimNonEmpty(identity?.platformVersion) ?? clientVersion,
};
}
@@ -32,7 +32,7 @@ export interface ResolveProviderRequestHeadersInput {
headers?: ProviderRequestHeaderLayers;
}
const DEFAULT_CLINE_REQUEST_HEADERS: Record<string, string> = {
export const DEFAULT_CLINE_REQUEST_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-IS-MULTIROOT": "false",
+5
View File
@@ -422,6 +422,11 @@ export {
TEAM_LIFECYCLE_EVENT_TYPE,
TEAM_PROGRESS_EVENT_TYPE,
} from "./rpc/team-progress";
export type { ClineClientIdentity } from "./runtime/cline-client-identity";
export {
getClineClientIdentity,
setClineClientIdentity,
} from "./runtime/cline-client-identity";
export type {
ClineEnvironment,
ClineEnvironmentConfig,
+7 -2
View File
@@ -484,6 +484,11 @@ export {
resolveClineBuildEnv,
withResolvedClineBuildEnv,
} from "./runtime/build-env";
export type { ClineClientIdentity } from "./runtime/cline-client-identity";
export {
getClineClientIdentity,
setClineClientIdentity,
} from "./runtime/cline-client-identity";
export type {
ClineEnvironment,
ClineEnvironmentConfig,
@@ -523,6 +528,7 @@ export type {
CaptureAgentUnexpectedReasoningTokensInput,
CaptureSdkErrorInput,
CaptureTaskLifecycleEventInput,
CoreSpawnReason,
ITelemetryService,
OpenTelemetryClientConfig,
SdkTelemetryErrorComponent,
@@ -533,11 +539,11 @@ export type {
TelemetryPrimitive,
TelemetryProperties,
TelemetryValue,
CoreSpawnReason,
} from "./services/telemetry";
export {
AGENT_UNEXPECTED_REASONING_TOKENS_EVENT,
buildSdkErrorProperties,
CORE_SPAWN_REASONS,
captureAgentUnexpectedReasoningTokens,
captureSdkError,
captureTaskLifecycleEvent,
@@ -552,7 +558,6 @@ export {
TASK_PROVIDER_REQUEST_STARTED_EVENT,
TASK_PROVIDER_STREAM_FAILED_EVENT,
TASK_PROVIDER_STREAM_STARTED_EVENT,
CORE_SPAWN_REASONS,
} from "./services/telemetry";
export type { ClineTelemetryServiceConfig } from "./services/telemetry-config";
export {
@@ -0,0 +1,24 @@
export interface ClineClientIdentity {
name?: string;
version?: string;
platform?: string;
platformVersion?: string;
}
interface ClineClientIdentityGlobal {
__clineClientIdentity?: ClineClientIdentity;
}
function identityHolder(): ClineClientIdentityGlobal {
return globalThis as ClineClientIdentityGlobal;
}
export function setClineClientIdentity(
identity: ClineClientIdentity | undefined,
): void {
identityHolder().__clineClientIdentity = identity;
}
export function getClineClientIdentity(): ClineClientIdentity | undefined {
return identityHolder().__clineClientIdentity;
}