mirror of
https://github.com/cline/cline.git
synced 2026-09-09 23:29:54 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984cae4e40 | ||
|
|
ddb9ff6b90 | ||
|
|
47d08ecbe8 | ||
|
|
cfc0b0e9a9 | ||
|
|
f5ef62f5e2 | ||
|
|
73950619a7 |
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user