Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 984cae4e40 docs(cli): note comma separator limitation in TUI headers field 2026-06-09 19:25:59 -07:00
Saoud Rizwan ddb9ff6b90 fix(core): merge openai-compatible field metadata entries after rebase
The Azure Foundry CLI change on main and this branch both added an
openai-compatible entry to PROVIDER_CONFIG_FIELD_METADATA; the rebase
kept both keys, and the duplicate silently dropped the Azure API
version field. Combine them into one entry.
2026-06-09 19:11:17 -07:00
Saoud Rizwan 47d08ecbe8 feat(cli): merge auth headers on update and add --clear-headers
Repeated --header flags now merge into saved headers instead of
replacing the whole set, so incremental updates do not wipe unrelated
entries. --clear-headers removes all saved headers; combined with
--header it acts as a full replace. Also adds an e2e test running the
binary with the full set of new OpenAI-compatible flags.
2026-06-09 19:07:00 -07:00
Saoud Rizwan cfc0b0e9a9 test(cli): cover openai-compatible config fields in interactive auth TUI
Drives the onboarding flow to the OpenAI Compatible provider config
screen and asserts the new Custom Headers, Context Window, and Max
Output Tokens fields render alongside Base URL and API key.
2026-06-09 19:05:25 -07:00
Saoud Rizwan f5ef62f5e2 feat(cli): add OpenAI-compatible custom headers and model config options
Bring the CLI to parity with the VS Code extension's OpenAI Compatible
settings. Users could previously only set apiKey, model id, and base
URL.

cline auth quick setup gains:
- repeatable -H/--header key=value flags (value may contain =)
- --context-window and --max-output-tokens
- --supports-images / --no-supports-images (pins model capabilities)
- flags other than apikey/modelid update stored settings in place, so
  re-entering credentials is no longer required for incremental changes

The TUI onboarding flow and the in-session provider picker dialog gain
matching optional fields for openai-compatible: a custom headers line
(key=value, comma separated), context window, and max output tokens,
driven by new field metadata in provider-config-fields.
2026-06-09 19:05:24 -07:00
Saoud Rizwan 73950619a7 feat(core): honor stored model metadata overrides for custom provider models
Stored provider settings can carry contextWindow, maxTokens, and
capabilities for models that are not in any catalog, e.g. a custom model
id on the OpenAI-compatible provider. The runtime resolves compaction
thresholds, image support, and output token caps from knownModels
entries, so those settings were silently ignored.

Synthesize a knownModels entry for the configured model during local
runtime bootstrap when stored metadata overrides exist, and let
provider settings maxTokens flow through handler-factory as the request
max output tokens when the host sets no per-turn cap.
2026-06-09 18:57:31 -07:00
17 changed files with 1090 additions and 11 deletions
+10
View File
@@ -161,6 +161,16 @@ cline --json "Summarize this repository"
# Quick provider setup
cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
# OpenAI-compatible endpoint with custom headers and model configuration
cline auth --provider openai-compatible --apikey sk-... --modelid my-model \
--baseurl https://llm.example.com/v1 \
--header "X-Org=abc" --header "Authorization-Extra=token" \
--context-window 128000 --max-output-tokens 8192 --no-supports-images
# Update saved settings later without re-entering credentials
cline auth -p openai-compatible -H "X-Org=new-value"
cline auth -p openai-compatible --clear-headers
```
### Connectors
+318 -1
View File
@@ -1,14 +1,43 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ProviderSettingsManager } from "@cline/core";
import { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
getPersistedProviderApiKey,
normalizeAuthProviderId,
parseAuthCommandArgs,
parseHeaderFlags,
runAuthCommand,
saveOAuthProviderSettings,
} from "./auth";
function createTempProviderSettingsManager(): ProviderSettingsManager {
const dir = mkdtempSync(join(tmpdir(), "cline-auth-test-"));
return new ProviderSettingsManager({
filePath: join(dir, "settings", "providers.json"),
});
}
function createAuthIo() {
const out: string[] = [];
const err: string[] = [];
return {
io: {
writeln: (text?: string) => {
out.push(text ?? "");
},
writeErr: (text: string) => {
err.push(text);
},
},
out,
err,
};
}
describe("parseAuthCommandArgs", () => {
it("parses Azure API version quick setup option", () => {
expect(
@@ -97,6 +126,294 @@ describe("getPersistedProviderApiKey", () => {
});
});
describe("parseAuthCommandArgs", () => {
it("parses repeatable --header flags and model configuration options", () => {
const parsed = parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--header",
"X-Org=abc",
"-H",
"Authorization=Bearer a=b",
"--context-window",
"128000",
"--max-output-tokens",
"8192",
"--supports-images",
]);
expect(parsed.parseError).toBeUndefined();
expect(parsed.explicitProvider).toBe("openai-compatible");
expect(parsed.header).toEqual(["X-Org=abc", "Authorization=Bearer a=b"]);
expect(parsed.contextWindow).toBe("128000");
expect(parsed.maxOutputTokens).toBe("8192");
expect(parsed.supportsImages).toBe(true);
});
it("parses --no-supports-images as an explicit false", () => {
const parsed = parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--no-supports-images",
]);
expect(parsed.supportsImages).toBe(false);
});
it("leaves supportsImages undefined when neither flag is given", () => {
const parsed = parseAuthCommandArgs(["--provider", "openai-compatible"]);
expect(parsed.supportsImages).toBeUndefined();
});
});
describe("parseHeaderFlags", () => {
it("splits each header at the first equals sign", () => {
expect(
parseHeaderFlags(["X-Org=abc", "Authorization=Bearer a=b=c"]),
).toEqual({
headers: {
"X-Org": "abc",
Authorization: "Bearer a=b=c",
},
});
});
it("rejects headers without a key", () => {
expect(parseHeaderFlags(["=value"]).error).toMatch(/invalid --header/);
expect(parseHeaderFlags(["no-separator"]).error).toMatch(
/invalid --header/,
);
});
it("returns no headers for empty input", () => {
expect(parseHeaderFlags(undefined)).toEqual({});
expect(parseHeaderFlags([])).toEqual({});
});
});
describe("runAuthCommand quick setup", () => {
it("persists headers and model configuration for openai-compatible", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
baseurl: "https://llm.example.com/v1",
header: ["X-Org=abc", "Authorization=Bearer token=1"],
contextWindow: "128000",
maxOutputTokens: "8192",
supportsImages: true,
});
expect(err).toEqual([]);
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "sk-test",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
headers: {
"X-Org": "abc",
Authorization: "Bearer token=1",
},
contextWindow: 128_000,
maxTokens: 8_192,
});
expect(
manager.getProviderSettings("openai-compatible")?.capabilities,
).toEqual(expect.arrayContaining(["streaming", "tools", "vision"]));
});
it("updates stored settings without requiring the api key again", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
});
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
header: ["X-Team=infra"],
});
expect(err).toEqual([]);
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")).toMatchObject({
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Team": "infra" },
});
});
it("merges new headers into existing ones", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc", "X-Team": "old" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
header: ["X-Team=infra"],
});
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")?.headers).toEqual({
"X-Org": "abc",
"X-Team": "infra",
});
});
it("removes saved headers with --clear-headers", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
clearHeaders: true,
});
expect(exitCode).toBe(0);
expect(
manager.getProviderSettings("openai-compatible")?.headers,
).toBeUndefined();
});
it("replaces headers when --clear-headers is combined with --header", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc", "X-Team": "old" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
clearHeaders: true,
header: ["X-Fresh=1"],
});
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")?.headers).toEqual({
"X-Fresh": "1",
});
});
it("removes the vision capability with --no-supports-images", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
capabilities: ["streaming", "tools", "vision"],
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
supportsImages: false,
});
expect(exitCode).toBe(0);
const capabilities =
manager.getProviderSettings("openai-compatible")?.capabilities;
expect(capabilities).toEqual(
expect.arrayContaining(["streaming", "tools"]),
);
expect(capabilities).not.toContain("vision");
});
it("rejects custom headers for providers without endpoint customization", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "anthropic",
apikey: "sk-ant",
modelid: "claude-sonnet-4-20250514",
header: ["X-Org=abc"],
});
expect(exitCode).toBe(1);
expect(err.join("\n")).toMatch(/custom headers are only supported/i);
});
it("rejects model configuration options for non openai-compatible providers", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "anthropic",
apikey: "sk-ant",
modelid: "claude-sonnet-4-20250514",
contextWindow: "128000",
});
expect(exitCode).toBe(1);
expect(err.join("\n")).toMatch(/model configuration options/i);
});
it("rejects malformed header and numeric flag values", async () => {
const manager = createTempProviderSettingsManager();
const malformedHeader = createAuthIo();
expect(
await runAuthCommand({
providerSettingsManager: manager,
io: malformedHeader.io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
header: ["missing-separator"],
}),
).toBe(1);
expect(malformedHeader.err.join("\n")).toMatch(/invalid --header/);
const malformedNumber = createAuthIo();
expect(
await runAuthCommand({
providerSettingsManager: manager,
io: malformedNumber.io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
maxOutputTokens: "not-a-number",
}),
).toBe(1);
expect(malformedNumber.err.join("\n")).toMatch(/--max-output-tokens/);
});
});
describe("normalizeAuthProviderId", () => {
it("keeps CLI-only codex shorthand in CLI parsing", () => {
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
+195 -8
View File
@@ -50,6 +50,11 @@ type AuthQuickSetupInput = {
modelid: string;
baseurl?: string;
azureApiVersion?: string;
headers?: Record<string, string>;
clearHeaders?: boolean;
contextWindow?: number;
maxOutputTokens?: number;
supportsImages?: boolean;
};
type AuthCommandInput = {
@@ -60,6 +65,11 @@ type AuthCommandInput = {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
};
type ParsedAuthCommandArgs = {
@@ -68,6 +78,11 @@ type ParsedAuthCommandArgs = {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
parseError?: string;
};
@@ -78,6 +93,10 @@ type ParsedAuthCommandArgs = {
* which intentionally shadows the global `-p` (--plan) and `-m` (--model)
* short flags. Commander scopes options per-command, so there is no conflict.
*/
function collectRepeatable(value: string, previous: string[]): string[] {
return [...previous, value];
}
export function createAuthCommand(): Command {
const cmd = new Command("auth")
.description("Authenticate with an LLM provider")
@@ -88,7 +107,24 @@ export function createAuthCommand(): Command {
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "model id")
.option("-b, --baseurl <url>", "base URL")
.option("--azure-api-version <version>", "Azure API version");
.option("--azure-api-version <version>", "Azure API version")
.option(
"-H, --header <key=value>",
"custom HTTP header sent on every request (repeatable)",
collectRepeatable,
[],
)
.option("--context-window <tokens>", "context window size for the model")
.option(
"--max-output-tokens <tokens>",
"max output tokens per request for the model",
)
.option("--supports-images", "mark the model as supporting image input")
.option(
"--no-supports-images",
"mark the model as not supporting image input",
)
.option("--clear-headers", "remove all saved custom headers");
return cmd;
}
@@ -106,6 +142,11 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
}>();
const positionalProvider = cmd.args[0];
return {
@@ -114,9 +155,56 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
header: opts.header,
clearHeaders: opts.clearHeaders,
contextWindow: opts.contextWindow,
maxOutputTokens: opts.maxOutputTokens,
supportsImages: opts.supportsImages,
};
}
export function parseHeaderFlags(values: string[] | undefined): {
headers?: Record<string, string>;
error?: string;
} {
if (!values || values.length === 0) {
return {};
}
const headers: Record<string, string> = {};
for (const value of values) {
// Split at the first "=" so header values may contain "=" themselves.
const separatorIndex = value.indexOf("=");
const key = separatorIndex > 0 ? value.slice(0, separatorIndex).trim() : "";
if (!key) {
return {
error: `invalid --header "${value}" (expected format: key=value)`,
};
}
headers[key] = value.slice(separatorIndex + 1).trim();
}
return { headers };
}
function parsePositiveInteger(
value: string | undefined,
flag: string,
): { parsed?: number; error?: string } {
if (value === undefined) {
return {};
}
const parsed = Number.parseInt(value, 10);
if (
!Number.isFinite(parsed) ||
parsed <= 0 ||
String(parsed) !== value.trim()
) {
return {
error: `invalid ${flag} "${value}" (expected a positive integer)`,
};
}
return { parsed };
}
async function loadProviderCatalog(
providerSettingsManager: ProviderSettingsManager,
): Promise<Array<{ id: string; name: string }>> {
@@ -140,10 +228,15 @@ async function ensureQuickSetupInputValid(
if (!providerCatalog.some((provider) => provider.id === normalizedProvider)) {
return `invalid provider "${input.provider}"`;
}
if (!input.apikey.trim()) {
const existing =
providerSettingsManager.getProviderSettings(normalizedProvider);
const hasStoredApiKey = Boolean(
existing?.apiKey?.trim() || existing?.auth?.accessToken?.trim(),
);
if (!input.apikey.trim() && !hasStoredApiKey) {
return "auth quick setup requires --apikey <key>";
}
if (!input.modelid.trim()) {
if (!input.modelid.trim() && !existing?.model?.trim()) {
return "auth quick setup requires --modelid <id>";
}
if (
@@ -159,6 +252,22 @@ async function ensureQuickSetupInputValid(
) {
return "Azure API version is only supported for OpenAI-compatible providers";
}
if (
((input.headers && Object.keys(input.headers).length > 0) ||
input.clearHeaders) &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_NATIVE
) {
return "custom headers are only supported for OpenAI and OpenAI-compatible providers";
}
if (
(input.contextWindow !== undefined ||
input.maxOutputTokens !== undefined ||
input.supportsImages !== undefined) &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
) {
return "model configuration options (--context-window, --max-output-tokens, --supports-images) are only supported for the OpenAI-compatible provider";
}
return undefined;
}
@@ -169,6 +278,11 @@ function saveQuickAuthProviderSettings(input: {
modelid: string;
baseurl?: string;
azureApiVersion?: string;
headers?: Record<string, string>;
clearHeaders?: boolean;
contextWindow?: number;
maxOutputTokens?: number;
supportsImages?: boolean;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
@@ -178,9 +292,13 @@ function saveQuickAuthProviderSettings(input: {
provider: input.providerId as ProviderSettings["provider"],
}),
provider: input.providerId as ProviderSettings["provider"],
apiKey: input.apikey,
model: input.modelid,
};
if (input.apikey.trim()) {
nextSettings.apiKey = input.apikey;
}
if (input.modelid.trim()) {
nextSettings.model = input.modelid;
}
if (input.baseurl?.trim()) {
nextSettings.baseUrl = input.baseurl.trim();
}
@@ -190,6 +308,35 @@ function saveQuickAuthProviderSettings(input: {
apiVersion: input.azureApiVersion.trim(),
};
}
if (input.clearHeaders) {
delete nextSettings.headers;
}
if (input.headers && Object.keys(input.headers).length > 0) {
nextSettings.headers = {
...(input.clearHeaders ? {} : (existing?.headers ?? {})),
...input.headers,
};
}
if (input.contextWindow !== undefined) {
nextSettings.contextWindow = input.contextWindow;
}
if (input.maxOutputTokens !== undefined) {
nextSettings.maxTokens = input.maxOutputTokens;
}
if (input.supportsImages !== undefined) {
// Model capabilities default to "images allowed" when unset, so an
// explicit flag pins the capability list either way. Streaming and
// tools stay on; they are table stakes for any model Cline can drive.
const capabilities = new Set<
NonNullable<ProviderSettings["capabilities"]>[number]
>(existing?.capabilities ?? ["streaming", "tools"]);
if (input.supportsImages) {
capabilities.add("vision");
} else {
capabilities.delete("vision");
}
nextSettings.capabilities = [...capabilities];
}
input.providerSettingsManager.saveProviderSettings(nextSettings);
}
@@ -286,6 +433,27 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
const modelid = input.modelid?.trim() ?? "";
const baseurl = input.baseurl?.trim();
const azureApiVersion = input.azureApiVersion?.trim();
const { headers, error: headerError } = parseHeaderFlags(input.header);
if (headerError) {
input.io.writeErr(headerError);
return 1;
}
const contextWindow = parsePositiveInteger(
input.contextWindow,
"--context-window",
);
if (contextWindow.error) {
input.io.writeErr(contextWindow.error);
return 1;
}
const maxOutputTokens = parsePositiveInteger(
input.maxOutputTokens,
"--max-output-tokens",
);
if (maxOutputTokens.error) {
input.io.writeErr(maxOutputTokens.error);
return 1;
}
const validationError = await ensureQuickSetupInputValid(
{
provider: providerId,
@@ -293,6 +461,11 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
modelid,
baseurl,
azureApiVersion,
headers,
clearHeaders: input.clearHeaders,
contextWindow: contextWindow.parsed,
maxOutputTokens: maxOutputTokens.parsed,
supportsImages: input.supportsImages,
},
input.providerSettingsManager,
);
@@ -307,9 +480,18 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
modelid,
baseurl,
azureApiVersion,
headers,
clearHeaders: input.clearHeaders,
contextWindow: contextWindow.parsed,
maxOutputTokens: maxOutputTokens.parsed,
supportsImages: input.supportsImages,
});
const configuredModelId =
modelid ||
input.providerSettingsManager.getProviderSettings(providerId)?.model ||
"";
input.io.writeln(
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${configuredModelId})`,
);
return 0;
}
@@ -392,12 +574,17 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
typeof input.apikey === "string" ||
typeof input.modelid === "string" ||
typeof input.baseurl === "string" ||
typeof input.azureApiVersion === "string";
typeof input.azureApiVersion === "string" ||
(input.header?.length ?? 0) > 0 ||
input.clearHeaders === true ||
typeof input.contextWindow === "string" ||
typeof input.maxOutputTokens === "string" ||
typeof input.supportsImages === "boolean";
if (hasQuickSetupFlags) {
if (!input.explicitProvider?.trim()) {
input.io.writeErr(
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
"auth quick setup requires --provider <id> when using auth options like --apikey/--modelid/--baseurl/--header",
);
return 1;
}
+27
View File
@@ -153,6 +153,23 @@ export async function runCli(): Promise<void> {
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("--azure-api-version <version>", "Azure API version")
.option(
"-H, --header <key=value>",
"Custom HTTP header sent on every request (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[],
)
.option("--context-window <tokens>", "Context window size for the model")
.option(
"--max-output-tokens <tokens>",
"Max output tokens per request for the model",
)
.option("--supports-images", "Mark the model as supporting image input")
.option(
"--no-supports-images",
"Mark the model as not supporting image input",
)
.option("--clear-headers", "Remove all saved custom headers")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Working directory")
.option(
@@ -167,6 +184,11 @@ export async function runCli(): Promise<void> {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
config?: string;
cwd?: string;
dataDir?: string;
@@ -198,6 +220,11 @@ export async function runCli(): Promise<void> {
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
header: opts.header,
clearHeaders: opts.clearHeaders,
contextWindow: opts.contextWindow,
maxOutputTokens: opts.maxOutputTokens,
supportsImages: opts.supportsImages,
io,
});
});
+74 -1
View File
@@ -18,7 +18,11 @@ import {
} from "../helpers/constants.js";
import { clineEnv } from "../helpers/env.js";
import { waitForAuthScreen } from "../helpers/page-objects/auth.js";
import { expectExitCode, expectVisible } from "../helpers/terminal.js";
import {
expectExitCode,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
test.describe("cline auth (interactive screen)", () => {
test.use({
@@ -280,6 +284,75 @@ test.describe("cline auth --baseurl with non-OpenAI-compatible provider", () =>
});
});
test.describe("cline auth -p -k -m with headers and model config flags", () => {
test.use({
program: {
file: CLINE_BIN,
args: [
"auth",
"--provider",
"openai-compatible",
"--apikey",
"sk-test-key-12345",
"--modelid",
"my-model",
"--baseurl",
"https://api.example.com/v1",
"-H",
"X-Org=abc",
"-H",
"Authorization-Extra=Bearer a=b",
"--context-window",
"128000",
"--max-output-tokens",
"8192",
"--no-supports-images",
],
},
...TERMINAL_WIDE,
env: clineEnv("unauthenticated"),
});
test("exits successfully with full OpenAI-compatible configuration", async ({
terminal,
}) => {
await expectExitCode(terminal, EXIT_CODE_SUCCESS);
});
});
test.describe("cline auth openai-compatible config fields (interactive)", () => {
test.use({
program: { file: CLINE_BIN, args: ["auth"] },
...TERMINAL_WIDE,
env: clineEnv("unauthenticated"),
});
test("shows custom headers and model configuration fields", async ({
terminal,
}) => {
const settle = (ms = 500) =>
new Promise((resolve) => setTimeout(resolve, ms));
await waitForAuthScreen(terminal);
// Navigate to "Bring your own provider" (third menu entry) and open it
terminal.keyDown();
await settle();
terminal.keyDown();
await settle();
terminal.submit();
// Filter the provider list down to OpenAI Compatible and select it
await expectVisible(terminal, "Search providers...");
await settle();
await typeAndSubmit(terminal, "openai-compatible");
await expectVisible(terminal, [
"Base URL",
"API key",
"Custom Headers (optional)",
"Context Window (optional)",
"Max Output Tokens (optional)",
]);
});
});
test.describe("cline auth with invalid provider", () => {
test.use({
program: {
@@ -24,11 +24,14 @@ import {
} from "../../../utils/codex-cli";
import { palette } from "../../palette";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -318,6 +321,9 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
azureApiVersion: "Azure API Version",
headers: "Custom Headers",
contextWindow: "Context Window",
maxOutputTokens: "Max Output Tokens",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
gcpProjectId: "Google Cloud Project ID",
@@ -335,6 +341,9 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
apiKey: "sk-...",
baseUrl: "",
azureApiVersion: "2025-01-01-preview",
headers: "X-Header=value, X-Other=value",
contextWindow: "",
maxOutputTokens: "",
awsRegion: "us-east-1",
awsProfile: "default",
gcpProjectId: "my-gcp-project",
@@ -354,6 +363,9 @@ const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"azureApiVersion",
"apiKey",
"headers",
"contextWindow",
"maxOutputTokens",
"awsProfile",
"sapClientId",
"sapClientSecret",
@@ -427,6 +439,18 @@ export function ProviderConfigInputContent(
"us-central1";
if (config.fields.apiKey)
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
if (config.fields.headers)
initial.headers = formatProviderConfigHeaders(existingSettings?.headers);
if (config.fields.contextWindow)
initial.contextWindow =
existingSettings?.contextWindow !== undefined
? String(existingSettings.contextWindow)
: "";
if (config.fields.maxOutputTokens)
initial.maxOutputTokens =
existingSettings?.maxTokens !== undefined
? String(existingSettings.maxTokens)
: "";
if (config.fields.awsProfile)
initial.awsProfile = existingSettings?.aws?.profile?.trim() ?? "";
if (config.fields.sapClientId)
@@ -466,6 +490,28 @@ export function ProviderConfigInputContent(
apiKey: config.fields.apiKey ? apiKey : undefined,
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
...(config.fields.headers
? {
headers: resolveProviderConfigHeadersPatch(
values.headers,
existingSettings?.headers,
),
}
: {}),
...(config.fields.contextWindow
? {
contextWindow: resolveProviderConfigPositiveInteger(
values.contextWindow,
),
}
: {}),
...(config.fields.maxOutputTokens
? {
maxTokens: resolveProviderConfigPositiveInteger(
values.maxOutputTokens,
),
}
: {}),
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(values),
@@ -3,10 +3,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
parseProviderConfigHeaders,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "./provider-config-values";
@@ -117,4 +121,50 @@ describe("provider config values", () => {
apiVersion: "",
});
});
it("round-trips headers through format and parse", () => {
const headers = {
"X-Org": "abc",
Authorization: "Bearer a=b",
};
const formatted = formatProviderConfigHeaders(headers);
expect(formatted).toBe("X-Org=abc, Authorization=Bearer a=b");
expect(parseProviderConfigHeaders(formatted)).toEqual(headers);
});
it("parses headers leniently, dropping malformed entries", () => {
expect(
parseProviderConfigHeaders("X-Org=abc, broken, =nokey, X-Other = v "),
).toEqual({
"X-Org": "abc",
"X-Other": "v",
});
expect(parseProviderConfigHeaders("")).toEqual({});
expect(parseProviderConfigHeaders(undefined)).toEqual({});
});
it("builds a headers patch that deletes removed entries", () => {
expect(
resolveProviderConfigHeadersPatch("X-Org=abc", {
"X-Org": "old",
"X-Removed": "gone",
}),
).toEqual({
"X-Org": "abc",
"X-Removed": "",
});
expect(resolveProviderConfigHeadersPatch("", undefined)).toBeUndefined();
expect(resolveProviderConfigHeadersPatch("", { "X-Org": "old" })).toEqual({
"X-Org": "",
});
});
it("parses optional numeric fields, clearing blank or invalid input", () => {
expect(resolveProviderConfigPositiveInteger("128000")).toBe(128_000);
expect(resolveProviderConfigPositiveInteger(" 8192 ")).toBe(8_192);
expect(resolveProviderConfigPositiveInteger("")).toBeUndefined();
expect(resolveProviderConfigPositiveInteger(undefined)).toBeUndefined();
expect(resolveProviderConfigPositiveInteger("abc")).toBeUndefined();
expect(resolveProviderConfigPositiveInteger("-5")).toBeUndefined();
});
});
@@ -62,6 +62,81 @@ export function resolveProviderConfigAzure(values: ProviderConfigValues): {
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
}
/** Serialize stored headers into the single-line "key=value, key2=value2" form. */
export function formatProviderConfigHeaders(
headers: Record<string, string> | undefined,
): string {
if (!headers) {
return "";
}
return Object.entries(headers)
.map(([key, value]) => `${key}=${value}`)
.join(", ");
}
/**
* Parse the single-line headers field. Entries are comma separated; each
* entry splits at its first "=" so values may themselves contain "=".
* Commas are reserved as entry separators, so header values containing a
* comma (e.g. Accept=text/html, application/json) are not supported here:
* the text after the comma parses as a new entry and is dropped if it has
* no "=". Use `cline auth -H` for such values. Entries without a key are
* dropped (the form is deliberately forgiving; provider errors are the
* authoritative feedback).
*/
export function parseProviderConfigHeaders(
value: string | undefined,
): Record<string, string> {
const headers: Record<string, string> = {};
if (!value) {
return headers;
}
for (const entry of value.split(",")) {
const separatorIndex = entry.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = entry.slice(0, separatorIndex).trim();
if (!key) {
continue;
}
headers[key] = entry.slice(separatorIndex + 1).trim();
}
return headers;
}
/**
* Build the headers patch for `saveLocalProviderSettings` so the text field
* is authoritative: parsed entries are upserted and existing keys missing
* from the field are emptied, which the merge layer treats as deletion.
*/
export function resolveProviderConfigHeadersPatch(
value: string | undefined,
existingHeaders: Record<string, string> | undefined,
): Record<string, string> | undefined {
const parsed = parseProviderConfigHeaders(value);
const patch: Record<string, string> = {};
for (const key of Object.keys(existingHeaders ?? {})) {
if (!(key in parsed)) {
patch[key] = "";
}
}
Object.assign(patch, parsed);
return Object.keys(patch).length > 0 ? patch : undefined;
}
/** Parse an optional numeric field; blank or invalid input clears the setting. */
export function resolveProviderConfigPositiveInteger(
value: string | undefined,
): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export function updateProviderConfigValue(
previous: ProviderConfigValues,
field: ProviderConfigFieldKey,
@@ -29,10 +29,13 @@ import {
} from "../../components/searchable-list";
import { palette } from "../../palette";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -395,6 +398,19 @@ export function useOnboardingController(props: OnboardingControllerProps) {
if (config.fields.apiKey) {
initialValues.apiKey = existing?.apiKey?.trim() ?? "";
}
if (config.fields.headers) {
initialValues.headers = formatProviderConfigHeaders(existing?.headers);
}
if (config.fields.contextWindow) {
initialValues.contextWindow =
existing?.contextWindow !== undefined
? String(existing.contextWindow)
: "";
}
if (config.fields.maxOutputTokens) {
initialValues.maxOutputTokens =
existing?.maxTokens !== undefined ? String(existing.maxTokens) : "";
}
if (config.fields.awsProfile) {
initialValues.awsProfile = existing?.aws?.profile?.trim() ?? "";
}
@@ -458,11 +474,35 @@ export function useOnboardingController(props: OnboardingControllerProps) {
byoFields.sapResourceGroup ||
byoFields.sapDeploymentId;
const existingSettings =
providerSettingsManager.getProviderSettings(activeProviderId);
saveLocalProviderSettings(providerSettingsManager, {
providerId: activeProviderId,
apiKey: byoFields.apiKey ? apiKey : undefined,
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
...(byoFields.headers
? {
headers: resolveProviderConfigHeadersPatch(
byoValues.headers,
existingSettings?.headers,
),
}
: {}),
...(byoFields.contextWindow
? {
contextWindow: resolveProviderConfigPositiveInteger(
byoValues.contextWindow,
),
}
: {}),
...(byoFields.maxOutputTokens
? {
maxTokens: resolveProviderConfigPositiveInteger(
byoValues.maxOutputTokens,
),
}
: {}),
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(byoValues),
@@ -6,6 +6,9 @@ export const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"azureApiVersion",
"apiKey",
"headers",
"contextWindow",
"maxOutputTokens",
"awsProfile",
"sapClientId",
"sapClientSecret",
@@ -223,6 +223,9 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
azureApiVersion: "Azure API Version",
headers: "Custom Headers",
contextWindow: "Context Window",
maxOutputTokens: "Max Output Tokens",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
sapClientId: "Client ID",
@@ -238,6 +241,9 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
apiKey: "Paste your API key here...",
baseUrl: "",
azureApiVersion: "2025-01-01-preview",
headers: "X-Header=value, X-Other=value",
contextWindow: "",
maxOutputTokens: "",
awsRegion: "us-east-1",
awsProfile: "default",
sapClientId: "sb-...|xsuaa_std!b...",
@@ -167,6 +167,57 @@ describe("createAgentModelFromConfig", () => {
});
});
it("falls back to providerConfig maxOutputTokens when maxTokensPerTurn is unset", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "openai-compatible",
modelId: "my-custom-model",
apiKey: "test-key",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "openai-compatible",
modelId: "my-custom-model",
maxOutputTokens: 8_192,
},
} satisfies AgentConfig,
undefined,
);
expect(gatewayMock.createAgentModel).toHaveBeenLastCalledWith(
{ providerId: "openai-compatible", modelId: "my-custom-model" },
{ maxTokens: 8_192 },
);
});
it("prefers maxTokensPerTurn over providerConfig maxOutputTokens", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "openai-compatible",
modelId: "my-custom-model",
apiKey: "test-key",
systemPrompt: "",
tools: [],
maxTokensPerTurn: 4_096,
providerConfig: {
providerId: "openai-compatible",
modelId: "my-custom-model",
maxOutputTokens: 8_192,
},
} satisfies AgentConfig,
undefined,
);
expect(gatewayMock.createAgentModel).toHaveBeenLastCalledWith(
{ providerId: "openai-compatible", modelId: "my-custom-model" },
{ maxTokens: 4_096 },
);
});
it("forwards Bedrock AWS settings as gateway provider options", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
@@ -159,7 +159,8 @@ export function createAgentModelFromConfig(
baseUrl: config.baseUrl ?? baseProviderConfig?.baseUrl,
headers: config.headers ?? baseProviderConfig?.headers,
knownModels: resolveKnownModelsFromConfig(config),
maxOutputTokens: config.maxTokensPerTurn,
maxOutputTokens:
config.maxTokensPerTurn ?? baseProviderConfig?.maxOutputTokens,
reasoningEffort: config.reasoningEffort,
thinkingBudgetTokens: config.thinkingBudgetTokens,
thinking: config.thinking,
@@ -392,6 +392,82 @@ describe("prepareLocalRuntimeBootstrap", () => {
expect(bootstrap.providerConfig.fetch).toBeUndefined();
});
it("synthesizes a knownModels entry from stored model metadata overrides", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
input.config.providerId = "openai-compatible";
input.config.modelId = "my-custom-model";
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-model-overrides",
providerSettingsManager: createProviderSettingsManager({
provider: "openai-compatible",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
contextWindow: 128_000,
maxTokens: 8_192,
capabilities: ["vision", "tools"],
}) as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(
bootstrap.providerConfig.knownModels?.["my-custom-model"],
).toMatchObject({
id: "my-custom-model",
contextWindow: 128_000,
maxTokens: 8_192,
});
expect(
bootstrap.providerConfig.knownModels?.["my-custom-model"]?.capabilities,
).toEqual(expect.arrayContaining(["images", "files", "tools"]));
});
it("does not synthesize a knownModels entry without stored model metadata", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
input.config.providerId = "openai-compatible";
input.config.modelId = "my-custom-model";
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-no-model-overrides",
providerSettingsManager: createProviderSettingsManager({
provider: "openai-compatible",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
headers: { "x-team": "infra" },
}) as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(
bootstrap.providerConfig.knownModels?.["my-custom-model"],
).toBeUndefined();
expect(bootstrap.providerConfig.headers).toMatchObject({
"x-team": "infra",
});
});
it("adds Codex backend headers for openai-codex from stored OAuth settings", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
@@ -5,6 +5,8 @@ import type {
AgentTool,
ExtensionContext,
ITelemetryService,
ModelCapability,
ModelInfo,
RuntimeConfigExtensionKind,
ToolApprovalRequest,
ToolApprovalResult,
@@ -179,6 +181,74 @@ function deriveOpenAICodexAccountId(
return undefined;
}
const MODEL_CAPABILITIES_BY_PROVIDER_CAPABILITY: Partial<
Record<
NonNullable<ProviderSettings["capabilities"]>[number],
ModelCapability[]
>
> = {
streaming: ["streaming"],
tools: ["tools"],
reasoning: ["reasoning"],
"prompt-cache": ["prompt-cache"],
vision: ["images", "files"],
"computer-use": ["computer-use"],
};
function toModelCapabilities(
capabilities: ProviderSettings["capabilities"],
): ModelCapability[] | undefined {
if (!capabilities || capabilities.length === 0) {
return undefined;
}
const mapped = new Set<ModelCapability>();
for (const capability of capabilities) {
for (const modelCapability of MODEL_CAPABILITIES_BY_PROVIDER_CAPABILITY[
capability
] ?? []) {
mapped.add(modelCapability);
}
}
return mapped.size > 0 ? [...mapped] : undefined;
}
/**
* Stored provider settings can carry user-supplied model metadata
* (contextWindow, maxTokens, capabilities) for models absent from every
* catalog, e.g. a custom model id on the OpenAI-compatible provider. The
* runtime resolves model behaviour (compaction thresholds, image support,
* output-token caps) from `knownModels` entries, so without an entry for the
* configured model those settings would be silently ignored.
*/
function buildStoredModelInfoOverride(
stored: ProviderSettings | undefined,
modelId: string | undefined,
knownModels: ProviderConfig["knownModels"],
): ModelInfo | undefined {
if (!stored || !modelId) {
return undefined;
}
const capabilities = toModelCapabilities(stored.capabilities);
if (
stored.contextWindow === undefined &&
stored.maxTokens === undefined &&
!capabilities
) {
return undefined;
}
const existing = knownModels?.[modelId];
return {
...(existing ?? {}),
id: modelId,
name: existing?.name ?? modelId,
...(stored.contextWindow !== undefined
? { contextWindow: stored.contextWindow }
: {}),
...(stored.maxTokens !== undefined ? { maxTokens: stored.maxTokens } : {}),
...(capabilities ? { capabilities } : {}),
};
}
function buildProviderConfig(
config: CoreSessionConfig,
sessionId: string,
@@ -225,6 +295,17 @@ function buildProviderConfig(
if (config.knownModels) {
providerConfig.knownModels = config.knownModels;
}
const modelInfoOverride = buildStoredModelInfoOverride(
stored,
providerConfig.modelId,
providerConfig.knownModels,
);
if (modelInfoOverride) {
providerConfig.knownModels = {
...(providerConfig.knownModels ?? {}),
[modelInfoOverride.id]: modelInfoOverride,
};
}
if (config.extensionContext) {
providerConfig.extensionContext = config.extensionContext;
}
@@ -115,6 +115,21 @@ describe("getProviderConfigFields", () => {
expect(result.fields.apiKey?.optional).toBe(true);
});
it("exposes optional headers and model configuration fields for OpenAI Compatible", () => {
const result = getProviderConfigFields("openai-compatible");
expect(result.fields.headers?.optional).toBe(true);
expect(result.fields.headers?.label).toMatch(/custom headers/i);
expect(result.fields.contextWindow?.optional).toBe(true);
expect(result.fields.maxOutputTokens?.optional).toBe(true);
});
it("does not expose headers or model configuration fields for other providers", () => {
const anthropic = getProviderConfigFields("anthropic");
expect(anthropic.fields.headers).toBeUndefined();
expect(anthropic.fields.contextWindow).toBeUndefined();
expect(anthropic.fields.maxOutputTokens).toBeUndefined();
});
it("returns api-key auth with awsRegion, apiKey, and awsProfile for bedrock", () => {
const result = getProviderConfigFields("bedrock");
expect(result.providerId).toBe("bedrock");
@@ -5,6 +5,9 @@ export type ProviderConfigFieldKey =
| "apiKey"
| "baseUrl"
| "azureApiVersion"
| "headers"
| "contextWindow"
| "maxOutputTokens"
| "awsRegion"
| "awsProfile"
| "gcpProjectId"
@@ -37,6 +40,9 @@ const FIELD_KEYS: ProviderConfigFieldKey[] = [
"apiKey",
"baseUrl",
"azureApiVersion",
"headers",
"contextWindow",
"maxOutputTokens",
"awsRegion",
"awsProfile",
"gcpProjectId",
@@ -63,6 +69,21 @@ const PROVIDER_CONFIG_FIELD_METADATA: Partial<
description:
"For Azure AI Foundry deployments, use a Base URL ending at /openai/deployments/<deployment> and set the Azure API version.",
fields: {
headers: {
label: "Custom Headers (optional)",
placeholder: "X-Header=value, X-Other=value",
optional: true,
},
contextWindow: {
label: "Context Window (optional)",
placeholder: "e.g. 128000",
optional: true,
},
maxOutputTokens: {
label: "Max Output Tokens (optional)",
placeholder: "e.g. 8192",
optional: true,
},
azureApiVersion: {
label: "Azure API Version (optional)",
placeholder: "2025-01-01-preview",