Compare commits

...

7 Commits

Author SHA1 Message Date
Arafatkatze d662b7e7e4 refactor(vscode): split Cline request metadata context 2026-07-08 13:48:08 -07:00
Bee c1d1f1099c refactor (#12136)
* refactor

* HostRegistryInfo.get() is strict and throws if called before init
2026-07-08 13:32:05 -07:00
Arafatkatze 29b4c42f35 fix(sdk): address Cline header review feedback 2026-07-07 16:37:58 -07:00
Arafatkatze 53ba93512f chore(sdk): drop core vitest alias 2026-07-06 22:25:04 -07:00
Arafatkatze 949e176e17 chore(sdk): drop agents vitest alias 2026-07-06 21:55:50 -07:00
Arafatkatze 497f4580af fix(sdk): centralize Cline request metadata headers 2026-07-03 15:14:40 -07:00
Arafatkatze 15dbdf762e fix(sdk): send Cline client type headers 2026-07-02 18:03:53 -07:00
31 changed files with 881 additions and 67 deletions
+1
View File
@@ -49,6 +49,7 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
type: ShowMessageType.ERROR,
message: "Failed to initialize storage. Please check logs for details or try restarting the client.",
})
throw error
}
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
@@ -55,7 +55,7 @@ async function makeAuthenticatedRequest<T>(endpoint: string, organizationId: str
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
...getAxiosSettings(),
}
@@ -63,7 +63,7 @@ async function makeAuthenticatedRequest<T>(endpoint: string, organizationId: str
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
...getAxiosSettings(),
}
@@ -215,7 +215,7 @@ async function performCommitMsgGeneration(controller: Controller, gitDiff: strin
// transform that doesn't need extended thinking; disabling reasoning also
// avoids sending both reasoning.effort and reasoning.max_tokens, which
// some providers (e.g. OpenRouter) reject.
const apiHandler = buildApiHandler(apiConfiguration, currentMode, { disableReasoning: true })
const apiHandler = await buildApiHandler(apiConfiguration, currentMode, { disableReasoning: true })
// Create a system prompt
const systemPrompt = PROMPT.system
+6 -1
View File
@@ -95,5 +95,10 @@ export const HostRegistryInfo = {
const ide = host.clineType || "unknown"
hostInfo = { hostVersion, extensionVersion, platform, os, ide, distinctId }
},
get: () => hostInfo,
get: () => {
if (!hostInfo) {
throw new Error("HostRegistryInfo has not been initialized. Call initialize() before using host metadata.")
}
return hostInfo
},
}
+1 -1
View File
@@ -60,7 +60,7 @@ export class ClineAccountService {
headers: {
Authorization: `Bearer ${clineAccountAuthToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
...config.headers,
},
...getAxiosSettings(),
+1 -1
View File
@@ -77,7 +77,7 @@ vi.mock("@/shared/net", () => ({
// Mock buildBasicClineHeaders
vi.mock("@/services/EnvUtils", () => ({
buildBasicClineHeaders: async () => ({}),
buildBasicClineHeaders: () => ({}),
}))
// Mock feature flags
+4 -4
View File
@@ -255,7 +255,7 @@ export class AuthService {
headers: {
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
...getAxiosSettings(),
})
@@ -483,7 +483,7 @@ export class AuthService {
apiBaseUrl,
// Use WorkOS device auth so the browser confirmation code can be surfaced in the extension.
useWorkOSDeviceAuth: true,
headers: await buildBasicClineHeaders(),
headers: buildBasicClineHeaders(),
callbacks: createOAuthClientCallbacks({
onOutput: (message) => {
resolveAuthMessage(message)
@@ -544,7 +544,7 @@ export class AuthService {
method: "POST",
headers: {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
body: JSON.stringify({
code: "test-personal-token",
@@ -755,7 +755,7 @@ export class AuthService {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
body: JSON.stringify({
grant_type: "authorization_code",
@@ -24,6 +24,15 @@ const mocks = vi.hoisted(() => {
}
return {
buildClineRequestMetadata: vi.fn(async () => ({
userAgent: "Cline/test",
clientName: "VSCode Extension",
clientVersion: "1.2.3",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
})),
getDistinctId: vi.fn(() => "test-distinct-id"),
getProviderSettingsManager: vi.fn(() => providerSettingsManager),
providerSettingsManager,
@@ -53,6 +62,52 @@ vi.mock("@/services/logging/distinctId", () => ({
getDistinctId: mocks.getDistinctId,
}))
vi.mock("@/services/EnvUtils", () => ({
buildClineRequestMetadata: mocks.buildClineRequestMetadata,
}))
vi.mock("@cline/llms", () => {
const generatedModels = {
cline: {
"anthropic/claude-sonnet-4.6": { id: "anthropic/claude-sonnet-4.6" },
},
"cline-pass": {
"cline-pass/glm-5.1": { id: "cline-pass/glm-5.1" },
},
gemini: {
"gemini-3.5-flash": { id: "gemini-3.5-flash" },
},
"openai-compatible": {
"gpt-4o": { id: "gpt-4o" },
},
}
return {
getGeneratedModelsForProvider: (providerId: keyof typeof generatedModels) => generatedModels[providerId] ?? {},
MODEL_COLLECTIONS_BY_PROVIDER_ID: {
cline: {
provider: { name: "Cline", defaultModelId: "anthropic/claude-sonnet-4.6" },
models: {},
},
"cline-pass": {
provider: { name: "ClinePass", defaultModelId: "cline-pass/glm-5.1" },
models: {},
},
gemini: {
provider: { name: "Gemini", defaultModelId: "gemini-3.5-pro" },
models: {},
},
"openai-compatible": {
provider: { name: "OpenAI Compatible", defaultModelId: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
models: {},
},
ollama: {
provider: { name: "Ollama", baseUrl: "http://localhost:11434/v1" },
models: {},
},
},
}
})
vi.mock("./provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
@@ -88,6 +143,15 @@ beforeEach(() => {
})
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
mocks.buildClineRequestMetadata.mockResolvedValue({
userAgent: "Cline/test",
clientName: "VSCode Extension",
clientVersion: "1.2.3",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
})
})
afterEach(() => {
@@ -193,6 +257,32 @@ describe("buildStartSessionInput", () => {
expect(result.prompt).toBeUndefined()
})
it("does not rewrite provider headers before start", () => {
const headers = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-Task-ID": "",
"X-CLIENT-TYPE": "VSCode Extension",
}
const config = makeBaseConfig({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId: "session-123",
headers,
providerConfig: {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
headers,
},
} as Partial<CoreSessionConfig>)
const result = buildStartSessionInput(config, { cwd: "/tmp/workspace" })
expect(result.config).toBe(config)
expect(config.headers?.["X-Task-ID"]).toBe("")
expect((config.providerConfig as any).headers["X-Task-ID"]).toBe("")
})
})
// ---------------------------------------------------------------------------
@@ -289,6 +379,40 @@ describe("buildSessionConfig", () => {
expect(config.apiKey).toBe("workos:test-access-token")
})
it("passes VS Code client request metadata without owning Cline provider headers", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "cline",
actModeClineModelId: "anthropic/claude-sonnet-4.6",
clineApiKey: "workos:test-access-token",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(mocks.buildClineRequestMetadata).toHaveBeenCalledTimes(1)
expect(config.headers).toBeUndefined()
expect((config.providerConfig as any).headers).toBeUndefined()
expect(config.extensionContext).toMatchObject({
client: {
name: "cline-vscode",
version: "1.2.3",
},
workspace: {
cwd: "/tmp/workspace",
ide: "VS Code",
platform: process.platform,
},
requestMetadata: {
clientType: "VSCode Extension",
clientVersion: "1.2.3",
userAgent: "Cline/test",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
},
})
})
it("resolves ClinePass from the shared Cline OAuth credentials", async () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
if (providerId !== "cline") {
+12 -2
View File
@@ -26,7 +26,7 @@ import type { Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
import { StateManager } from "@/core/storage/StateManager"
import { ExtensionRegistryInfo } from "@/registry"
import { buildClineRequestMetadata } from "@/services/EnvUtils"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { fetch } from "@/shared/net"
@@ -662,6 +662,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// own provider id spelling (e.g. "openai-compatible" rather than the
// extension's "openai"). Convert before handing the id to core.
const sdkProviderId = toSdkProviderId(providerId)
const clineRequestMetadata = await buildClineRequestMetadata()
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
// gateway; without it the agent loop uses bare global fetch and corporate
@@ -713,7 +714,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
user: distinctId ? { distinctId } : undefined,
client: {
name: "cline-vscode",
version: ExtensionRegistryInfo.version,
version: clineRequestMetadata.clientVersion,
},
workspace: {
rootPath: workspaceRoot,
@@ -723,6 +724,15 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
platform: process.platform,
mode: mode === "plan" ? "plan" : "act",
},
requestMetadata: {
clientType: clineRequestMetadata.clientName,
clientVersion: clineRequestMetadata.clientVersion,
userAgent: clineRequestMetadata.userAgent,
platform: clineRequestMetadata.platform,
platformVersion: clineRequestMetadata.platformVersion,
coreVersion: clineRequestMetadata.coreVersion,
isMultiRoot: clineRequestMetadata.isMultiRoot,
},
logger: sdkLogger,
},
hooks: buildAgentHooks(StateManager.get()),
+64 -1
View File
@@ -1,16 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { buildSdkProviderConfig } from "./sdk-api-handler"
import { buildApiHandler, buildSdkProviderConfig } from "./sdk-api-handler"
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getProviderSettings: vi.fn(),
}
return {
buildClineRequestMetadata: vi.fn(async () => ({
userAgent: "Cline/test",
clientName: "VSCode Extension",
clientVersion: "1.2.3",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
})),
createHandler: vi.fn((config: { modelId?: string }) => ({
createMessage: vi.fn(),
getModel: vi.fn(() => ({
id: config.modelId ?? "",
info: {},
})),
})),
getProviderSettingsManager: vi.fn(() => providerSettingsManager),
providerSettingsManager,
}
})
vi.mock("@/services/EnvUtils", () => ({
buildClineRequestMetadata: mocks.buildClineRequestMetadata,
}))
vi.mock("@cline/llms", async () => {
const actual = await vi.importActual<typeof import("@cline/llms")>("@cline/llms")
return {
...actual,
createHandler: mocks.createHandler,
}
})
vi.mock("./provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
@@ -82,4 +110,39 @@ describe("buildSdkProviderConfig", () => {
})
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("v0")
})
it("threads VS Code request metadata into standalone Cline handlers", async () => {
const handler = await buildApiHandler(
{
actModeApiProvider: "cline",
actModeClineModelId: "anthropic/claude-sonnet-4.6",
clineApiKey: "test-key",
},
"act",
{ disableReasoning: true },
)
expect(mocks.buildClineRequestMetadata).toHaveBeenCalledTimes(1)
expect(mocks.createHandler).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "cline",
extensionContext: expect.objectContaining({
client: {
name: "cline-vscode",
version: "1.2.3",
},
requestMetadata: {
clientType: "VSCode Extension",
clientVersion: "1.2.3",
userAgent: "Cline/test",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
},
}),
}),
)
expect((handler.getModel() as { providerId?: string }).providerId).toBe("cline")
})
})
+33 -2
View File
@@ -10,6 +10,7 @@
import { type ApiHandler, createHandler, type ProviderConfig } from "@cline/llms"
import type { ApiConfiguration } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import { buildClineRequestMetadata } from "@/services/EnvUtils"
import { fetch } from "@/shared/net"
import { buildBedrockProviderConfig } from "./bedrock-config"
import { resolveApiKey, resolveBaseUrl, resolveModelId, resolveVertexProviderConfig } from "./cline-session-factory"
@@ -24,6 +25,27 @@ export interface BuildApiHandlerOptions {
* OpenRouter don't receive a reasoning config at all.
*/
disableReasoning?: boolean
extensionContext?: ProviderConfig["extensionContext"]
}
function buildStandaloneExtensionContext(
clineRequestMetadata: Awaited<ReturnType<typeof buildClineRequestMetadata>>,
): ProviderConfig["extensionContext"] {
return {
client: {
name: "cline-vscode",
version: clineRequestMetadata.clientVersion,
},
requestMetadata: {
clientType: clineRequestMetadata.clientName,
clientVersion: clineRequestMetadata.clientVersion,
userAgent: clineRequestMetadata.userAgent,
platform: clineRequestMetadata.platform,
platformVersion: clineRequestMetadata.platformVersion,
coreVersion: clineRequestMetadata.coreVersion,
isMultiRoot: clineRequestMetadata.isMultiRoot,
},
}
}
/**
@@ -70,6 +92,7 @@ export function buildSdkProviderConfig(
// Bedrock needs its region + structured AWS auth options forwarded to the
// SDK gateway. Without these, a pasted Bedrock API key / region is dropped.
...(providerId === "bedrock" ? buildBedrockProviderConfig(configuration, mode) : {}),
...(options?.extensionContext ? { extensionContext: options.extensionContext } : {}),
}
if (options?.disableReasoning) {
@@ -95,8 +118,16 @@ export function buildSdkProviderConfig(
* returned handler implements the same `createMessage`/`getModel` surface, so
* existing callers continue to work unchanged.
*/
export function buildApiHandler(configuration: ApiConfiguration, mode: Mode, options?: BuildApiHandlerOptions): ApiHandler {
const providerConfig = buildSdkProviderConfig(configuration, mode, options)
export async function buildApiHandler(
configuration: ApiConfiguration,
mode: Mode,
options?: BuildApiHandlerOptions,
): Promise<ApiHandler> {
const extensionContext = options?.extensionContext ?? buildStandaloneExtensionContext(await buildClineRequestMetadata())
const providerConfig = buildSdkProviderConfig(configuration, mode, {
...options,
extensionContext,
})
const handler = createHandler(providerConfig)
const getModel = handler.getModel.bind(handler)
@@ -41,7 +41,7 @@ vi.mock("@/core/storage/remote-config/utils", () => ({
isRemoteConfigEnabled,
}))
vi.mock("@/services/EnvUtils", () => ({ buildBasicClineHeaders: async () => ({}) }))
vi.mock("@/services/EnvUtils", () => ({ buildBasicClineHeaders: () => ({}) }))
vi.mock("@/shared/net", () => ({ getAxiosSettings: () => ({}) }))
vi.mock("axios", () => ({ default: { request: vi.fn() } }))
+49 -16
View File
@@ -1,6 +1,5 @@
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { EmptyRequest } from "@/shared/proto/cline/common"
import { ExtensionRegistryInfo, HostRegistryInfo } from "@/registry"
import { Logger } from "@/shared/services/Logger"
// Canonical header names for extra client/host context
@@ -13,27 +12,61 @@ const ClineHeaders = {
IS_MULTIROOT: "X-IS-MULTIROOT",
} as const
export interface ClientIdentityContext {
platform: string
platformVersion: string
clientName: string
clientVersion: string
userAgent: string
coreVersion: string
}
export interface ClineRequestMetadataContext extends ClientIdentityContext {
isMultiRoot: boolean
}
export function buildExternalBasicHeaders(): Record<string, string> {
return {
"User-Agent": `Cline/${ExtensionRegistryInfo.version}`,
}
}
export async function buildBasicClineHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = buildExternalBasicHeaders()
try {
const host = await HostProvider.env.getHostVersion(EmptyRequest.create({}))
headers[ClineHeaders.PLATFORM] = host.platform || "unknown"
headers[ClineHeaders.PLATFORM_VERSION] = host.version || "unknown"
headers[ClineHeaders.CLIENT_TYPE] = host.clineType || "unknown"
headers[ClineHeaders.CLIENT_VERSION] = host.clineVersion || "unknown"
} catch (error) {
Logger.log("Failed to get IDE/platform info via HostBridge EnvService.getHostVersion", error)
headers[ClineHeaders.PLATFORM] = "unknown"
headers[ClineHeaders.PLATFORM_VERSION] = "unknown"
headers[ClineHeaders.CLIENT_TYPE] = "unknown"
headers[ClineHeaders.CLIENT_VERSION] = "unknown"
export function buildClientIdentityContext(): ClientIdentityContext {
const host = HostRegistryInfo.get()
return {
platform: host.platform || "unknown",
platformVersion: host.hostVersion || "unknown",
clientName: host.ide || "unknown",
clientVersion: host.extensionVersion || ExtensionRegistryInfo.version,
userAgent: buildExternalBasicHeaders()["User-Agent"],
coreVersion: ExtensionRegistryInfo.version,
}
}
export async function isMultiRootWorkspace(): Promise<boolean> {
try {
const { paths } = await HostProvider.workspace.getWorkspacePaths({})
return paths.length > 1
} catch (error) {
Logger.log("Failed to detect multi-root workspace", error)
return false
}
}
export async function buildClineRequestMetadata(): Promise<ClineRequestMetadataContext> {
return {
...buildClientIdentityContext(),
isMultiRoot: await isMultiRootWorkspace(),
}
}
export function buildBasicClineHeaders(): Record<string, string> {
const host = buildClientIdentityContext()
const headers: Record<string, string> = buildExternalBasicHeaders()
headers[ClineHeaders.PLATFORM] = host.platform
headers[ClineHeaders.PLATFORM_VERSION] = host.platformVersion
headers[ClineHeaders.CLIENT_TYPE] = host.clientName
headers[ClineHeaders.CLIENT_VERSION] = host.clientVersion
headers[ClineHeaders.CORE_VERSION] = ExtensionRegistryInfo.version
return headers
@@ -69,7 +69,7 @@ export class ClineAccountService {
headers: {
Authorization: `Bearer ${clineAccountAuthToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
...config.headers,
},
...getAxiosSettings(),
@@ -71,9 +71,6 @@ export class BannerService {
return BannerService.instance
}
const hostInfo = HostRegistryInfo.get()
if (!hostInfo) {
throw new Error("[BannerService] Ensure HostRegistryInfo is initialized before BannerService.")
}
BannerService.instance = new BannerService(controller, hostInfo)
return BannerService.instance
}
@@ -257,7 +254,7 @@ export class BannerService {
method: "POST",
headers: {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
},
body: JSON.stringify({
banner_id: bannerId,
@@ -299,7 +296,7 @@ export class BannerService {
const url = this.buildFetchUrl()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...buildBasicClineHeaders(),
}
const authToken = await AuthService.getInstance().getAuthToken()
if (authToken) {
@@ -109,6 +109,34 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
});
});
it("passes provider headers through to the llms gateway", () => {
const model = new ScriptedModel([]);
createAgentModel.mockReturnValue(model);
new Agent({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "test-key",
headers: {
"x-custom": "kept",
},
});
expect(createGateway).toHaveBeenCalledWith({
providerConfigs: [
{
providerId: "cline",
apiKey: "test-key",
baseUrl: undefined,
headers: {
"x-custom": "kept",
},
options: undefined,
},
],
});
});
it("forwards abort() to the active AgentRuntime", async () => {
let abortReason: unknown;
const model = new ScriptedModel([
+9 -1
View File
@@ -101,7 +101,15 @@ function resolveRuntimeConfig(
const { providerId, modelId, apiKey, baseUrl, headers, options, ...rest } =
config;
const gateway = createGateway({
providerConfigs: [{ providerId, apiKey, baseUrl, headers, options }],
providerConfigs: [
{
providerId,
apiKey,
baseUrl,
headers,
options,
},
],
telemetry: rest.telemetry,
});
const model = gateway.createAgentModel({ providerId, modelId });
@@ -21,6 +21,18 @@ vi.mock("@cline/llms", () => ({
normalizeProviderId: (id: string) => id,
}));
type GatewayConfigCall = {
providerConfigs: Array<{ headers?: Record<string, string> }>;
};
function getLastGatewayConfig(): GatewayConfigCall | undefined {
return (
gatewayMock.createGateway.mock.calls as unknown as Array<
[GatewayConfigCall]
>
).at(-1)?.[0];
}
describe("createAgentModelFromConfig", () => {
beforeEach(() => {
gatewayMock.createAgentModel.mockReset();
@@ -125,6 +137,145 @@ describe("createAgentModelFromConfig", () => {
);
});
it("adds SDK client headers for Cline provider requests", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "key",
sessionId: "sess-vscode",
systemPrompt: "",
tools: [],
extensionContext: {
client: { name: "cline-vscode", version: "1.2.3" },
workspace: {
rootPath: "/tmp/project",
platform: "darwin",
},
requestMetadata: {
clientType: "VSCode Extension",
clientVersion: "1.2.3",
userAgent: "Cline/1.2.3",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: true,
},
},
providerConfig: {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
headers: {
"x-provider": "kept",
},
},
},
undefined,
);
const gatewayConfig = getLastGatewayConfig();
expect(gatewayConfig?.providerConfigs[0].headers).toMatchObject({
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"User-Agent": "Cline/1.2.3",
"X-IS-MULTIROOT": "true",
"X-CLIENT-TYPE": "VSCode Extension",
"X-CLIENT-VERSION": "1.2.3",
"X-PLATFORM": "Visual Studio Code",
"X-PLATFORM-VERSION": "1.90.0",
"X-CORE-VERSION": "1.2.3",
"X-Task-ID": "sess-vscode",
"x-provider": "kept",
});
});
it("keeps custom Cline headers while derived SDK client headers win", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "key",
sessionId: "sess-vscode",
systemPrompt: "",
tools: [],
headers: {
"X-CLIENT-TYPE": "",
"X-IS-MULTIROOT": "",
"x-top": "wins",
},
extensionContext: {
client: { name: "cline-vscode", version: "1.2.3" },
workspace: {
rootPath: "/tmp/project",
platform: "darwin",
},
requestMetadata: {
clientType: "VSCode Extension",
clientVersion: "1.2.3",
isMultiRoot: true,
},
},
providerConfig: {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
headers: {
"X-CLIENT-TYPE": "provider-client",
"X-CLIENT-VERSION": "stale-provider-version",
"x-provider": "kept",
},
},
},
undefined,
);
const gatewayConfig = getLastGatewayConfig();
expect(gatewayConfig?.providerConfigs[0].headers).toMatchObject({
"X-CLIENT-TYPE": "VSCode Extension",
"X-CLIENT-VERSION": "1.2.3",
"X-IS-MULTIROOT": "true",
"X-Task-ID": "sess-vscode",
"x-provider": "kept",
"x-top": "wins",
});
expect(gatewayConfig?.providerConfigs[0].headers).not.toHaveProperty(
"X-PLATFORM",
);
});
it("preserves existing header precedence for non-Cline providers", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "key",
systemPrompt: "",
tools: [],
headers: {
"x-top": "wins",
},
providerConfig: {
providerId: "openai-compatible",
modelId: "custom-model",
headers: {
"x-provider": "not-merged",
},
},
},
undefined,
);
const gatewayConfig = getLastGatewayConfig();
expect(gatewayConfig?.providerConfigs[0].headers).toEqual({
"x-top": "wins",
});
});
it("preserves model capabilities and metadata when configuring gateway models", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
@@ -448,6 +599,9 @@ describe("createAgentModelFromConfig", () => {
it("uses a registered handler (adapter) instead of the gateway, building it lazily", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
const extensionContext = {
client: { name: "cline-vscode", version: "1.2.3" },
} satisfies NonNullable<AgentConfig["extensionContext"]>;
// Pretend a host handler is registered for this provider.
gatewayMock.hasRegisteredHandler.mockReturnValue(true);
@@ -469,6 +623,7 @@ describe("createAgentModelFromConfig", () => {
apiKey: "",
systemPrompt: "",
tools: [],
extensionContext,
},
undefined,
);
@@ -487,5 +642,8 @@ describe("createAgentModelFromConfig", () => {
// drain
}
expect(gatewayMock.createHandlerAsync).toHaveBeenCalledTimes(1);
expect(gatewayMock.createHandlerAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensionContext }),
);
});
});
@@ -5,13 +5,16 @@ import {
MODEL_COLLECTIONS_BY_PROVIDER_ID,
normalizeProviderId,
} from "@cline/llms";
import type {
AgentConfig,
AgentModel,
BasicLogger,
GatewayModelDefinition,
ITelemetryService,
ModelInfo,
import {
type AgentConfig,
type AgentModel,
type BasicLogger,
buildClineRequestHeaders,
type ExtensionContext,
type GatewayModelDefinition,
type ITelemetryService,
isClineProvider,
type ModelInfo,
} from "@cline/shared";
import { createAgentModelFromApiHandler } from "./apihandler-agent-model-adapter";
import type { ProviderConfig } from "./provider-settings";
@@ -96,6 +99,39 @@ export function resolveKnownModelsFromConfig(
);
}
function mergeHeaders(
baseHeaders: Record<string, string> | undefined,
overrideHeaders: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!baseHeaders && !overrideHeaders) {
return undefined;
}
return {
...(baseHeaders ?? {}),
...(overrideHeaders ?? {}),
};
}
function resolveHeadersFromConfig(
config: AgentConfig,
baseProviderConfig: ProviderConfig | undefined,
extensionContext: ExtensionContext | undefined,
): Record<string, string> | undefined {
const providerId = normalizeProviderId(config.providerId);
if (!isClineProvider(providerId)) {
return config.headers ?? baseProviderConfig?.headers;
}
const headers = mergeHeaders(baseProviderConfig?.headers, config.headers);
return buildClineRequestHeaders({
providerId,
headers,
extensionContext,
taskId: config.sessionId ?? baseProviderConfig?.taskId,
});
}
function toGatewayCapabilities(
capabilities: ModelInfo["capabilities"],
): GatewayModelDefinition["capabilities"] {
@@ -147,6 +183,13 @@ function toGatewayConfiguredModel(
};
}
function resolveExtensionContext(
config: AgentConfig,
baseProviderConfig: ProviderConfig | undefined,
): ExtensionContext | undefined {
return config.extensionContext ?? baseProviderConfig?.extensionContext;
}
export function createAgentModelFromConfig(
config: AgentConfig,
logger: BasicLogger | undefined,
@@ -155,20 +198,24 @@ export function createAgentModelFromConfig(
const pc = config.providerConfig as ProviderConfig | undefined;
const baseProviderConfig =
pc?.providerId === config.providerId ? pc : undefined;
const extensionContext = resolveExtensionContext(config, baseProviderConfig);
const normalizedProviderConfig: ProviderConfig = {
...(baseProviderConfig ?? {}),
providerId: config.providerId,
modelId: config.modelId,
apiKey: config.apiKey ?? baseProviderConfig?.apiKey,
baseUrl: config.baseUrl ?? baseProviderConfig?.baseUrl,
headers: config.headers ?? baseProviderConfig?.headers,
headers: resolveHeadersFromConfig(
config,
baseProviderConfig,
extensionContext,
),
knownModels: resolveKnownModelsFromConfig(config),
maxOutputTokens: config.maxTokensPerTurn,
reasoningEffort: config.reasoningEffort,
thinkingBudgetTokens: config.thinkingBudgetTokens,
thinking: config.thinking,
logger,
extensionContext: config.extensionContext,
};
// Host-registered custom handlers (e.g. VS Code LM, which needs the host's
@@ -183,7 +230,10 @@ export function createAgentModelFromConfig(
)
) {
return createAgentModelFromApiHandler(() =>
createHandlerAsync(normalizedProviderConfig),
createHandlerAsync({
...normalizedProviderConfig,
...(extensionContext ? { extensionContext } : {}),
}),
);
}
@@ -209,8 +259,7 @@ export function createAgentModelFromConfig(
},
],
logger,
telemetry:
telemetry ?? config.telemetry ?? config.extensionContext?.telemetry,
telemetry: telemetry ?? config.telemetry ?? extensionContext?.telemetry,
}).createAgentModel(
{
providerId: normalizedProviderConfig.providerId,
@@ -1,6 +1,7 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionContext } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProviderSettings } from "../types/provider-settings";
@@ -394,6 +395,57 @@ describe("prepareLocalRuntimeBootstrap", () => {
expect(bootstrap.providerConfig.fetch).toBeUndefined();
});
it("preserves stored Cline headers when session config only supplies request metadata", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
const config = input.config as typeof input.config & {
extensionContext: ExtensionContext;
};
config.extensionContext = {
client: { name: "cline-vscode", version: "1.2.3" },
requestMetadata: {
clientType: "VSCode Extension",
clientVersion: "1.2.3",
platform: "Visual Studio Code",
platformVersion: "1.90.0",
coreVersion: "1.2.3",
isMultiRoot: false,
},
};
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-cline",
providerSettingsManager: createProviderSettingsManager({
provider: "cline",
model: "anthropic/claude-haiku-4.5",
headers: {
"x-stored": "stored",
},
}) as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(bootstrap.config.headers).toBeUndefined();
expect(bootstrap.providerConfig.headers).toEqual({
"x-stored": "stored",
});
expect(bootstrap.providerConfig.extensionContext).toBeUndefined();
expect(bootstrap.config.extensionContext?.requestMetadata).toMatchObject({
clientType: "VSCode Extension",
platform: "Visual Studio Code",
});
});
it("adds Codex backend headers for openai-codex from stored OAuth settings", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
@@ -179,6 +179,51 @@ function deriveOpenAICodexAccountId(
return undefined;
}
function mergeExtensionContext(
base: ExtensionContext | undefined,
override: ExtensionContext | undefined,
): ExtensionContext | undefined {
if (!base && !override) {
return undefined;
}
return {
...(base ?? {}),
...(override ?? {}),
user:
base?.user || override?.user
? { ...(base?.user ?? {}), ...(override?.user ?? {}) }
: undefined,
client:
base?.client || override?.client
? ({
...(base?.client ?? {}),
...(override?.client ?? {}),
} as ExtensionContext["client"])
: undefined,
workspace:
base?.workspace || override?.workspace
? ({
...(base?.workspace ?? {}),
...(override?.workspace ?? {}),
} as ExtensionContext["workspace"])
: undefined,
requestMetadata:
base?.requestMetadata || override?.requestMetadata
? {
...(base?.requestMetadata ?? {}),
...(override?.requestMetadata ?? {}),
}
: undefined,
session:
base?.session || override?.session
? { ...(base?.session ?? {}), ...(override?.session ?? {}) }
: undefined,
automation: override?.automation ?? base?.automation,
logger: override?.logger ?? base?.logger,
telemetry: override?.telemetry ?? base?.telemetry,
};
}
function buildProviderConfig(
config: CoreSessionConfig,
sessionId: string,
@@ -225,9 +270,6 @@ function buildProviderConfig(
if (config.knownModels) {
providerConfig.knownModels = config.knownModels;
}
if (config.extensionContext) {
providerConfig.extensionContext = config.extensionContext;
}
// Thread a host-provided custom fetch through to the AI gateway providers.
// Precedence: explicit per-session config > stored provider settings > host default.
const sessionFetch = (config as { fetch?: typeof fetch }).fetch;
@@ -319,7 +361,10 @@ export async function prepareLocalRuntimeBootstrap(
// as workspaceMetadata; the structured object is kept as workspaceInfo.
const { workspaceInfo, workspaceMetadata, durationMs, vcsType, initError } =
await buildWorkspaceMetadataWithInfo(workspacePath);
const configuredExtensionContext = localConfig?.extensionContext;
const configuredExtensionContext = mergeExtensionContext(
(input.config as { extensionContext?: ExtensionContext }).extensionContext,
localConfig?.extensionContext,
);
const extensionContext: ExtensionContext = {
...(configuredExtensionContext ?? {}),
workspace: {
@@ -1,4 +1,8 @@
import { CLINE_ENVIRONMENT_ENV, CLINE_ENVIRONMENTS } from "@cline/shared";
import {
CLINE_ENVIRONMENT_ENV,
CLINE_ENVIRONMENTS,
DEFAULT_REQUEST_HEADERS,
} from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUILTIN_SPECS } from "./builtins";
import { getModelsForProvider, getProvider } from "./model-registry";
@@ -11,6 +15,14 @@ function findClineSpec() {
return spec;
}
function findClinePassSpec() {
const spec = BUILTIN_SPECS.find((s) => s.id === "cline-pass");
if (!spec) {
throw new Error("cline-pass builtin spec not found");
}
return spec;
}
describe("cline builtin spec defaults.baseUrl", () => {
const originalEnvironment = process.env[CLINE_ENVIRONMENT_ENV];
@@ -50,6 +62,15 @@ describe("cline builtin spec defaults.baseUrl", () => {
});
});
describe("cline builtin spec request headers", () => {
it("uses the shared default request headers for Cline-compatible providers", () => {
expect(findClineSpec().defaults?.headers).toEqual(DEFAULT_REQUEST_HEADERS);
expect(findClinePassSpec().defaults?.headers).toEqual(
DEFAULT_REQUEST_HEADERS,
);
});
});
describe("cline builtin models", () => {
it("prefers Vercel-style Z.ai model ids over equivalent OpenRouter ids", async () => {
const models = await getModelsForProvider("cline");
+8 -1
View File
@@ -1,4 +1,5 @@
import {
DEFAULT_REQUEST_HEADERS,
type GatewayModelCapability,
type GatewayModelDefinition,
type GatewayProviderManifest,
@@ -492,6 +493,8 @@ function createClineLikeSpec(
>
>,
): BuiltinSpec {
const { headers, ...defaults } = input.defaults ?? {};
return {
id: input.id,
name: input.name,
@@ -507,7 +510,11 @@ function createClineLikeSpec(
get baseUrl(): string {
return `${getClineEnvironmentConfig().apiBaseUrl}/api/v1`;
},
...input.defaults,
headers: {
...DEFAULT_REQUEST_HEADERS,
...(headers ?? {}),
},
...defaults,
},
metadata: {
...ANTHROPIC_AND_QWEN_CACHE_ROUTING_METADATA,
@@ -357,6 +357,63 @@ describe("createGatewayApiHandler.createMessage", () => {
openaiCompatibleSpy.mockClear();
});
it("adds SDK client headers for Cline provider requests", async () => {
streamTextSpy.mockReturnValue({
fullStream: (async function* () {
yield { type: "finish", finishReason: "stop" };
})(),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
});
const handler = createGatewayApiHandler({
providerId: "cline",
clientType: "openai-compatible",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "test-key",
taskId: "task-jetbrains",
extensionContext: {
client: { name: "cline-jetbrains", version: "2.3.4" },
workspace: {
rootPath: "/tmp/project",
platform: "darwin",
},
requestMetadata: {
userAgent: "Cline/2.3.4",
platform: "JetBrains",
platformVersion: "2026.1",
coreVersion: "2.3.4",
isMultiRoot: true,
},
},
headers: {
"x-custom": "kept",
},
});
for await (const _chunk of handler.createMessage("", [
{ role: "user", content: "Hello" },
])) {
// Drain the stream so the provider is constructed.
}
const factoryConfig = openaiCompatibleFactorySpy.mock.calls.at(-1)?.[0] as
| { headers?: Record<string, string> }
| undefined;
expect(factoryConfig?.headers).toMatchObject({
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"User-Agent": "Cline/2.3.4",
"X-IS-MULTIROOT": "true",
"X-CLIENT-TYPE": "cline-jetbrains",
"X-CLIENT-VERSION": "2.3.4",
"X-PLATFORM": "JetBrains",
"X-PLATFORM-VERSION": "2026.1",
"X-CORE-VERSION": "2.3.4",
"X-Task-ID": "task-jetbrains",
"x-custom": "kept",
});
});
it("does not convert catalog maxTokens into request maxOutputTokens", async () => {
streamTextSpy.mockReturnValue({
fullStream: (async function* () {
+13 -7
View File
@@ -1,9 +1,10 @@
import type {
AgentModelEvent,
GatewayModelDefinition,
GatewayProviderFactory,
GatewayProviderRegistration,
GatewayStreamRequest,
import {
type AgentModelEvent,
buildClineRequestHeaders,
type GatewayModelDefinition,
type GatewayProviderFactory,
type GatewayProviderRegistration,
type GatewayStreamRequest,
} from "@cline/shared";
import { nanoid } from "nanoid";
import type {
@@ -449,7 +450,12 @@ function buildGatewayConfig(config: ProviderConfig) {
providerId,
apiKey: config.apiKey ?? config.accessToken,
baseUrl: config.baseUrl,
headers: config.headers,
headers: buildClineRequestHeaders({
providerId,
headers: config.headers,
extensionContext: config.extensionContext,
taskId: config.taskId,
}),
timeoutMs: config.timeoutMs,
fetch: config.fetch,
defaultModelId: config.modelId,
+2 -1
View File
@@ -815,7 +815,8 @@ export interface AgentConfig {
telemetry?: ITelemetryService;
/**
* Ambient runtime context: user identity, client surface, workspace, logger,
* and telemetry. Threaded through to ProviderConfig so handlers can access it.
* telemetry, and host request metadata. Used by runtime extension setup and
* forwarded to registered provider handlers that need host-local context.
*/
extensionContext?: ExtensionContext;
@@ -29,6 +29,29 @@ export interface ClientContext {
version?: string;
}
/**
* Client/runtime metadata used when constructing provider request metadata.
*
* This is intentionally separate from WorkspaceContext so request headers can
* preserve legacy host metadata without changing prompt/workspace semantics.
*/
export interface RequestMetadataContext {
/** Legacy client type value for provider request headers. */
clientType?: string;
/** Client version value for provider request headers. */
clientVersion?: string;
/** HTTP User-Agent value for provider requests. */
userAgent?: string;
/** Host/application platform label, e.g. "Visual Studio Code". */
platform?: string;
/** Host/application version, e.g. the VS Code version. */
platformVersion?: string;
/** Cline core/runtime version. */
coreVersion?: string;
/** Whether the host is currently operating on multiple workspace roots. */
isMultiRoot?: boolean;
}
/**
* Identity of the authenticated user.
*/
@@ -80,6 +103,7 @@ export interface ExtensionContext {
user?: UserContext;
client?: ClientContext;
workspace?: WorkspaceContext;
requestMetadata?: RequestMetadataContext;
/** Core session metadata forwarded into plugin setup context. */
session?: AgentExtensionSessionContext;
/**
+7 -1
View File
@@ -41,6 +41,7 @@ export type {
ClientContext,
ClientName,
ExtensionContext,
RequestMetadataContext,
UserContext,
WorkspaceContext,
} from "./extensions/context";
@@ -193,7 +194,12 @@ export {
resolveReasoningBudgetFromRatio,
resolveReasoningEffortRatio,
} from "./llms/reasoning-effort";
export { DEFAULT_REQUEST_HEADERS, serializeAbortReason } from "./llms/requests";
export {
type BuildClineRequestHeadersInput,
buildClineRequestHeaders,
DEFAULT_REQUEST_HEADERS,
serializeAbortReason,
} from "./llms/requests";
export { CHARS_PER_TOKEN, estimateTokens } from "./llms/tokens";
export type {
ToolApprovalRequest,
+7 -1
View File
@@ -55,6 +55,7 @@ export type {
ClientContext,
ClientName,
ExtensionContext,
RequestMetadataContext,
UserContext,
WorkspaceContext,
} from "./extensions/context";
@@ -207,7 +208,12 @@ export {
resolveReasoningBudgetFromRatio,
resolveReasoningEffortRatio,
} from "./llms/reasoning-effort";
export { DEFAULT_REQUEST_HEADERS, serializeAbortReason } from "./llms/requests";
export {
type BuildClineRequestHeadersInput,
buildClineRequestHeaders,
DEFAULT_REQUEST_HEADERS,
serializeAbortReason,
} from "./llms/requests";
export { CHARS_PER_TOKEN, estimateTokens } from "./llms/tokens";
export type {
ToolApprovalRequest,
+82
View File
@@ -1,3 +1,6 @@
import type { ExtensionContext } from "../extensions/context";
import { isClineProvider } from "../providers/utils";
export const DEFAULT_REQUEST_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
@@ -5,6 +8,85 @@ export const DEFAULT_REQUEST_HEADERS: Record<string, string> = {
"X-CLIENT-TYPE": "cline-sdk",
};
export interface BuildClineRequestHeadersInput {
providerId: string;
headers?: Record<string, string>;
extensionContext?: ExtensionContext;
clientName?: string;
clientVersion?: string;
userAgent?: string;
platform?: string;
platformVersion?: string;
coreVersion?: string;
isMultiRoot?: boolean;
taskId?: string;
}
function cleanHeaderValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed.toLowerCase() !== "unknown" ? trimmed : undefined;
}
function cleanHeaderOverrides(
headers: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const cleaned = Object.fromEntries(
Object.entries(headers).filter(([, value]) => cleanHeaderValue(value)),
);
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
export function buildClineRequestHeaders(
input: BuildClineRequestHeadersInput,
): Record<string, string> | undefined {
if (!isClineProvider(input.providerId)) {
return input.headers;
}
const requestMetadata = input.extensionContext?.requestMetadata;
const clientName =
cleanHeaderValue(input.clientName) ??
cleanHeaderValue(requestMetadata?.clientType) ??
cleanHeaderValue(input.extensionContext?.client?.name);
const clientVersion =
cleanHeaderValue(input.clientVersion) ??
cleanHeaderValue(requestMetadata?.clientVersion) ??
cleanHeaderValue(input.extensionContext?.client?.version);
const userAgent =
cleanHeaderValue(input.userAgent) ??
cleanHeaderValue(requestMetadata?.userAgent);
const platform =
cleanHeaderValue(input.platform) ??
cleanHeaderValue(requestMetadata?.platform);
const platformVersion =
cleanHeaderValue(input.platformVersion) ??
cleanHeaderValue(requestMetadata?.platformVersion);
const coreVersion =
cleanHeaderValue(input.coreVersion) ??
cleanHeaderValue(requestMetadata?.coreVersion);
const isMultiRoot = input.isMultiRoot ?? requestMetadata?.isMultiRoot;
const taskId = cleanHeaderValue(input.taskId);
const headerOverrides = cleanHeaderOverrides(input.headers);
return {
...DEFAULT_REQUEST_HEADERS,
...(headerOverrides ?? {}),
...(userAgent ? { "User-Agent": userAgent } : {}),
...(clientName ? { "X-CLIENT-TYPE": clientName } : {}),
...(clientVersion ? { "X-CLIENT-VERSION": clientVersion } : {}),
...(platform ? { "X-PLATFORM": platform } : {}),
...(platformVersion ? { "X-PLATFORM-VERSION": platformVersion } : {}),
...(coreVersion ? { "X-CORE-VERSION": coreVersion } : {}),
...(isMultiRoot !== undefined
? { "X-IS-MULTIROOT": isMultiRoot ? "true" : "false" }
: {}),
...(taskId ? { "X-Task-ID": taskId } : {}),
};
}
export function serializeAbortReason(reason: unknown): unknown {
return reason instanceof Error
? { name: reason.name, message: reason.message }