fix(llms): keep ClinePass provider options on the wire in the shared Cline provider

The shared Cline provider hardcoded the AI SDK provider name to "cline",
but the openai-compatible model reads request-body passthrough options from
providerOptions[<name>]. Option routing emits ClinePass options under the
"cline-pass"/"clinePass" buckets, so gateway reasoning (extended thinking
budgets) silently stopped reaching the wire for cline-pass after it moved
off the generic openai-compatible module.

Thread the gateway provider id through as the provider name, and restore
strictJsonSchema: false for the new "cline" provider-options target so the
wire format matches the previous openai-compatible behavior. Add cline-pass
coverage at both the option-routing and request-body levels.
This commit is contained in:
Saoud Rizwan
2026-08-13 00:17:52 +00:00
parent 657456c4a7
commit 78dc6f3e79
4 changed files with 78 additions and 4 deletions
@@ -58,7 +58,12 @@ export function buildCompatibleProviderOptions(options: {
const promptCache = hasPromptCacheRoute ? createEphemeralCacheControl() : {};
return {
...(target === "openai-compatible" ? { strictJsonSchema: false } : {}),
// The "cline" target is the OpenAI-compatible Cline provider; keep the
// same relaxed JSON schema behavior it had before it split off from the
// generic openai-compatible target.
...(target === "openai-compatible" || target === "cline"
? { strictJsonSchema: false }
: {}),
...buildCompatibleThinkingOptions({ request, context, suppressions }),
...(reasoning ? { reasoning } : {}),
...promptCache,
@@ -593,6 +593,27 @@ describe("composeAiSdkProviderOptions: Anthropic thinking precedence", () => {
},
],
},
{
name: "ClinePass-routed Sonnet 4.5 -> gateway reasoning under provider-id and alias buckets",
request: {
providerId: "cline-pass",
modelId: "anthropic/claude-sonnet-4-5",
reasoning: { enabled: true, effort: "low" },
},
context: { family: "claude-sonnet" },
expect: [
{
bucket: "cline-pass",
has: { reasoning: { enabled: true, max_tokens: 1024 } },
lacks: ["thinking"],
},
{
bucket: "clinePass",
has: { reasoning: { enabled: true, max_tokens: 1024 } },
lacks: ["thinking"],
},
],
},
{
name: "legacy custom Claude with promptCacheStrategy -> Anthropic reasoning",
request: {
+38 -1
View File
@@ -1,5 +1,6 @@
import type { GatewayResolvedProviderConfig } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCline } from "./cline";
import { createCline, createClineProviderModule } from "./cline";
describe("createCline", () => {
const fetchMock = vi.fn<typeof fetch>();
@@ -112,6 +113,42 @@ describe("createCline", () => {
expect(body).not.toHaveProperty("max_tokens");
});
it.each([
"cline",
"cline-pass",
])("applies %s provider-options buckets to the request body", async (providerId) => {
const modelId = "anthropic/claude-sonnet-4.6";
fetchMock.mockResolvedValue(jsonCompletionResponse(modelId));
const module = await createClineProviderModule(
{
providerId,
apiKey: "test-key",
baseUrl: "https://api.cline.bot/api/v1",
fetch: fetchMock,
} as unknown as GatewayResolvedProviderConfig,
{ provider: { id: providerId } } as never,
);
const model = module.model(modelId) as {
doGenerate: (options: unknown) => Promise<unknown>;
};
// The gateway routes Cline options under the concrete provider id
// and its camelCase alias (see clineGatewayReasoningRule +
// buildProviderAndAliasPatch). The provider name must match the
// provider id for these to reach the wire.
const bucket = { reasoning: { enabled: true, max_tokens: 1024 } };
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
providerOptions:
providerId === "cline"
? { cline: bucket }
: { "cline-pass": bucket, clinePass: bucket },
});
const body = capturedRequestBody(fetchMock);
expect(body.reasoning).toEqual({ enabled: true, max_tokens: 1024 });
});
it("keeps max_tokens for non-reasoning models", async () => {
const modelId = "anthropic/claude-sonnet-4.6";
fetchMock.mockResolvedValue(jsonCompletionResponse(modelId));
+13 -2
View File
@@ -34,6 +34,13 @@ export interface ClineWebSearchOptions {
export interface ClineProviderOptions {
apiKey?: string;
baseURL: string;
/**
* AI SDK provider name, used as the `providerOptions` lookup key for
* request-body passthrough. Must match the gateway provider id ("cline" or
* "cline-pass") so options routed under that id (e.g. gateway reasoning)
* reach the wire. Defaults to "cline".
*/
name?: string;
headers?: Record<string, string>;
fetch?: typeof fetch;
onResponseError?: (response: Response) => Promise<void> | void;
@@ -181,7 +188,7 @@ export interface ClineProvider {
export function createCline(options: ClineProviderOptions): ClineProvider {
const providerFetch = createClineFetch(options);
const compatible = createOpenAICompatible({
name: "cline",
name: options.name ?? "cline",
baseURL: withoutTrailingSlash(options.baseURL),
apiKey: options.apiKey,
headers: options.headers,
@@ -220,11 +227,15 @@ function readResponseErrorHandler(
export async function createClineProviderModule(
config: GatewayResolvedProviderConfig,
_context: GatewayProviderContext,
context: GatewayProviderContext,
): Promise<ProviderFactoryResult> {
const cline = createCline({
apiKey: await resolveApiKey(config),
baseURL: config.baseUrl ?? "https://api.cline.bot/api/v1",
// Keep the provider name aligned with the gateway provider id so
// provider options emitted under "cline-pass"/"clinePass" buckets are
// still applied when this module serves ClinePass.
name: context.provider.id,
headers: config.headers,
fetch: config.fetch,
onResponseError: readResponseErrorHandler(config),