Compare commits

...
19 changed files with 894 additions and 171 deletions
+23
View File
@@ -208,6 +208,11 @@ message GcpProviderConfig {
optional string region = 2;
}
message AzureProviderConfig {
optional string api_version = 1;
optional bool use_identity = 2;
}
message ProviderConfigResponse {
string provider_id = 1;
optional string base_url = 2;
@@ -222,6 +227,12 @@ message ProviderConfigResponse {
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
optional AzureProviderConfig azure = 14;
optional int64 max_tokens = 15;
optional int64 context_window = 16;
optional double temperature = 17;
optional ProviderPricingPatch pricing = 18;
optional ProviderReasoningPatch reasoning = 19;
}
message CommittedModelSelection {
@@ -236,6 +247,13 @@ message ProviderReasoningPatch {
optional int32 budget_tokens = 3;
}
message ProviderPricingPatch {
optional double input = 1;
optional double output = 2;
optional double cache_read = 3;
optional double cache_write = 4;
}
message WriteProviderConfigPatch {
optional string api_key = 1;
optional string base_url = 2;
@@ -249,6 +267,11 @@ message WriteProviderConfigPatch {
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
optional int64 max_tokens = 13;
optional int64 context_window = 14;
optional double temperature = 15;
optional ProviderPricingPatch pricing = 16;
optional AzureProviderConfig azure = 17;
}
message WriteProviderConfigRequest {
@@ -1,11 +1,18 @@
import type { ApiConfiguration } from "@shared/api"
import axios from "axios"
import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import { ApiFormat, OpenAiModelsRequest, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
vi.mock("axios", () => ({
default: {
get: vi.fn(),
},
}))
type TestStateManager = {
setGlobalStateBatch: ReturnType<typeof vi.fn>
flushPendingState?: ReturnType<typeof vi.fn<() => Promise<void>>>
@@ -144,6 +151,35 @@ describe("provider model catalog handlers", () => {
expect(catalog.resolveModels).toHaveBeenCalledWith(providerId, { forceRefresh: true })
})
it("refreshOpenAiModels falls back to the saved provider API key", async () => {
const { refreshOpenAiModels } = await import("../refreshOpenAiModels")
vi.mocked(axios.get).mockResolvedValueOnce({
data: { data: [{ id: "gpt-4.1" }] },
})
const providerId = parseProviderId("openai")
const store = makeStore({
providerId,
apiKey: "saved-openai-key",
baseUrl: "https://openai-compatible.example/v1",
})
const controller = makeController(store, makeCatalog())
const response = await refreshOpenAiModels(controller as any, OpenAiModelsRequest.create({
baseUrl: "https://openai-compatible.example/v1",
apiKey: "",
}))
expect(response.values).toEqual(["gpt-4.1"])
expect(axios.get).toHaveBeenCalledWith(
"https://openai-compatible.example/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer saved-openai-key",
}),
}),
)
})
it("readProviderConfig redacts secrets", async () => {
const { readProviderConfig } = await import("../readProviderConfig")
const providerId = parseProviderId("cline")
@@ -210,6 +246,20 @@ describe("provider model catalog handlers", () => {
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("writeProviderConfig preserves the max tokens unset sentinel", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("openai-compatible")
const store = makeStore({ providerId, maxTokens: -1 })
const controller = makeController(store, makeCatalog())
await writeProviderConfig(controller, {
providerId: "openai-compatible",
patch: { headers: {}, maxTokens: -1 },
})
expect(store.write).toHaveBeenCalledWith(providerId, { maxTokens: -1 })
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -13,6 +13,7 @@ import type {
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import {
AwsProviderConfig,
AzureProviderConfig,
CatalogErrorInfo,
CommitModelSelectionRequest,
CommittedModelSelection,
@@ -143,6 +144,16 @@ function toRedactedGcpProviderConfigProto(gcp: EffectiveProviderConfig["gcp"]):
})
}
function toRedactedAzureProviderConfigProto(azure: EffectiveProviderConfig["azure"]): AzureProviderConfig | undefined {
if (!azure) {
return undefined
}
return AzureProviderConfig.create({
apiVersion: azure.apiVersion,
useIdentity: azure.useIdentity,
})
}
function toAwsProviderConfigPatch(protoPatch: WriteProviderConfigPatch): ProviderConfigPatch["aws"] {
if (!protoPatch.aws) {
return undefined
@@ -163,6 +174,16 @@ function toAwsProviderConfigPatch(protoPatch: WriteProviderConfigPatch): Provide
}
}
function toAzureProviderConfigPatch(protoPatch: WriteProviderConfigPatch): ProviderConfigPatch["azure"] {
if (!protoPatch.azure) {
return undefined
}
return {
...(protoPatch.azure.apiVersion !== undefined ? { apiVersion: protoPatch.azure.apiVersion } : {}),
...(protoPatch.azure.useIdentity !== undefined ? { useIdentity: protoPatch.azure.useIdentity } : {}),
}
}
export function toProviderModelsResponse(
providerId: ProviderId,
requestId: string,
@@ -199,6 +220,25 @@ export function toRedactedProviderConfigResponse(
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
aws: toRedactedAwsProviderConfigProto(config.aws),
gcp: toRedactedGcpProviderConfigProto(config.gcp),
azure: toRedactedAzureProviderConfigProto(config.azure),
maxTokens: config.maxTokens,
contextWindow: config.contextWindow,
temperature: config.temperature,
pricing: config.pricing
? {
input: config.pricing.input,
output: config.pricing.output,
cacheRead: config.pricing.cacheRead,
cacheWrite: config.pricing.cacheWrite,
}
: undefined,
reasoning: config.reasoning
? {
enabled: config.reasoning.enabled,
effort: config.reasoning.effort,
budgetTokens: config.reasoning.budgetTokens,
}
: undefined,
})
}
@@ -218,6 +258,20 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.azure !== undefined ? { azure: toAzureProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.maxTokens !== undefined ? { maxTokens: Number(protoPatch.maxTokens) } : {}),
...(protoPatch.contextWindow !== undefined ? { contextWindow: Number(protoPatch.contextWindow) } : {}),
...(protoPatch.temperature !== undefined ? { temperature: protoPatch.temperature } : {}),
...(protoPatch.pricing !== undefined
? {
pricing: {
...(protoPatch.pricing.input !== undefined ? { input: protoPatch.pricing.input } : {}),
...(protoPatch.pricing.output !== undefined ? { output: protoPatch.pricing.output } : {}),
...(protoPatch.pricing.cacheRead !== undefined ? { cacheRead: protoPatch.pricing.cacheRead } : {}),
...(protoPatch.pricing.cacheWrite !== undefined ? { cacheWrite: protoPatch.pricing.cacheWrite } : {}),
},
}
: {}),
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
? {
auth: {
@@ -451,11 +451,47 @@ describe("buildSessionConfig", () => {
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect(config.knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels?.["custom-reasoner"]).toMatchObject({
id: "custom-reasoner",
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 16_000,
maxTokens: 4_096,
pricing: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
})
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
expect((config as any).maxTokensPerTurn).toBe(4_096)
})
it("hydrates OpenAI Compatible model metadata from providers.json when state lacks model info", async () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "openai",
model: "custom-reasoner",
contextWindow: 32_000,
maxTokens: 2_048,
pricing: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.02 },
temperature: 0.3,
} as any)
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config as any).maxTokensPerTurn).toBe(2_048)
expect((config.providerConfig as any).maxInputTokens).toBe(32_000)
expect((config.providerConfig as any).temperature).toBeUndefined()
expect((config.providerConfig as any).knownModels?.["custom-reasoner"]).toMatchObject({
contextWindow: 32_000,
maxTokens: 2_048,
pricing: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.02 },
temperature: 0.3,
})
})
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "oca",
+111 -6
View File
@@ -19,7 +19,7 @@ import {
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import type { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from "@shared/Languages"
import { Logger } from "@shared/services/Logger"
@@ -203,10 +203,108 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
return isReasoningEffort(effort) ? { thinking: true, reasoningEffort: effort } : undefined
}
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
const maxTokens = modelInfo?.maxTokens
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value)
}
function positiveFiniteNumber(value: unknown): number | undefined {
return isFiniteNumber(value) && value > 0 ? value : undefined
}
function resolveOpenAiCompatibleModelInfo(config: ApiConfiguration | undefined, mode: Mode): ModelInfo | undefined {
const stateModelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
if (stateModelInfo) {
return stateModelInfo
}
try {
const manager = getProviderSettingsManager(resolveDataDir())
const settings = manager.getProviderSettings("openai-compatible") ?? manager.getProviderSettings("openai")
if (!settings) {
return undefined
}
const modelSettings = settings as ProviderSettings
const contextWindow = positiveFiniteNumber(modelSettings.contextWindow)
const maxTokens = positiveFiniteNumber(modelSettings.maxTokens)
const inputPrice = isFiniteNumber(modelSettings.pricing?.input) ? modelSettings.pricing.input : undefined
const outputPrice = isFiniteNumber(modelSettings.pricing?.output) ? modelSettings.pricing.output : undefined
const cacheReadsPrice = isFiniteNumber(modelSettings.pricing?.cacheRead) ? modelSettings.pricing.cacheRead : undefined
const cacheWritesPrice = isFiniteNumber(modelSettings.pricing?.cacheWrite) ? modelSettings.pricing.cacheWrite : undefined
const temperature = isFiniteNumber(modelSettings.temperature) ? modelSettings.temperature : undefined
if (
contextWindow === undefined &&
maxTokens === undefined &&
inputPrice === undefined &&
outputPrice === undefined &&
cacheReadsPrice === undefined &&
cacheWritesPrice === undefined &&
temperature === undefined
) {
return undefined
}
return {
name: settings.model,
supportsPromptCache: false,
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(inputPrice !== undefined ? { inputPrice } : {}),
...(outputPrice !== undefined ? { outputPrice } : {}),
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
...(temperature !== undefined ? { temperature } : {}),
}
} catch (error) {
Logger.warn("[SessionFactory] Failed to read OpenAI Compatible model settings from providers.json", error)
return undefined
}
}
function resolveOpenAiCompatibleAzureConfig(config: ApiConfiguration | undefined): ProviderSettings["azure"] | undefined {
let providerAzure: ProviderSettings["azure"] | undefined
try {
const manager = getProviderSettingsManager(resolveDataDir())
providerAzure = manager.getProviderSettings("openai-compatible")?.azure ?? manager.getProviderSettings("openai")?.azure
} catch (error) {
Logger.warn("[SessionFactory] Failed to read OpenAI Compatible Azure settings from providers.json", error)
}
const apiVersion = providerAzure?.apiVersion?.trim() || config?.azureApiVersion?.trim() || undefined
const useIdentity = providerAzure?.useIdentity ?? config?.azureIdentity
if (apiVersion === undefined && useIdentity === undefined) {
return undefined
}
return {
...(apiVersion !== undefined ? { apiVersion } : {}),
...(useIdentity !== undefined ? { useIdentity } : {}),
}
}
function toSdkKnownModelInfo(modelId: string, modelInfo: ModelInfo): NonNullable<CoreSessionConfig["knownModels"]> {
return {
[modelId]: {
id: modelId,
name: modelInfo.name ?? modelId,
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
maxInputTokens: modelInfo.contextWindow,
temperature: modelInfo.temperature,
pricing:
modelInfo.inputPrice !== undefined ||
modelInfo.outputPrice !== undefined ||
modelInfo.cacheReadsPrice !== undefined ||
modelInfo.cacheWritesPrice !== undefined
? {
input: modelInfo.inputPrice ?? 0,
output: modelInfo.outputPrice ?? 0,
cacheRead: modelInfo.cacheReadsPrice ?? 0,
cacheWrite: modelInfo.cacheWritesPrice ?? 0,
}
: undefined,
},
}
}
// ---------------------------------------------------------------------------
@@ -607,7 +705,9 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
apiKey = resolveApiKey(providerId, apiConfig)
}
apiKey = apiKey ?? ""
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
const openAiCompatibleModelInfo = providerId === "openai" ? resolveOpenAiCompatibleModelInfo(apiConfig, mode) : undefined
const openAiCompatibleAzureConfig = providerId === "openai" ? resolveOpenAiCompatibleAzureConfig(apiConfig) : undefined
const maxTokensPerTurn = positiveFiniteNumber(openAiCompatibleModelInfo?.maxTokens)
const reasoningConfig =
providerId === "oca"
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
@@ -679,6 +779,11 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(openAiCompatibleAzureConfig ? { azure: openAiCompatibleAzureConfig } : {}),
...(openAiCompatibleModelInfo ? { knownModels: toSdkKnownModelInfo(modelId, openAiCompatibleModelInfo) } : {}),
...(positiveFiniteNumber(openAiCompatibleModelInfo?.contextWindow) !== undefined
? { maxInputTokens: openAiCompatibleModelInfo?.contextWindow }
: {}),
fetch,
}
@@ -89,6 +89,24 @@ export interface GcpProviderConfig {
readonly region?: string
}
export interface AzureProviderConfig {
readonly apiVersion?: string
readonly useIdentity?: boolean
}
export interface ProviderPricingConfig {
readonly input?: number
readonly output?: number
readonly cacheRead?: number
readonly cacheWrite?: number
}
export interface ProviderReasoningConfig {
readonly enabled?: boolean
readonly effort?: string
readonly budgetTokens?: number
}
export interface EffectiveProviderConfig {
readonly providerId: ProviderId
readonly apiKey?: string
@@ -98,6 +116,12 @@ export interface EffectiveProviderConfig {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly azure?: AzureProviderConfig
readonly maxTokens?: number
readonly contextWindow?: number
readonly temperature?: number
readonly pricing?: ProviderPricingConfig
readonly reasoning?: ProviderReasoningConfig
/**
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
* Compatible with `apiKey`; some providers populate both.
@@ -132,6 +156,13 @@ interface ProviderReasoningPatch {
readonly budgetTokens?: number
}
interface ProviderPricingPatch {
readonly input?: number
readonly output?: number
readonly cacheRead?: number
readonly cacheWrite?: number
}
export interface ProviderConfigPatch {
readonly apiKey?: string | null
readonly baseUrl?: string | null
@@ -140,6 +171,7 @@ export interface ProviderConfigPatch {
readonly region?: string | null
readonly aws?: AwsProviderConfig | null
readonly gcp?: GcpProviderConfig | null
readonly azure?: AzureProviderConfig | null
readonly auth?: {
readonly accessToken?: string
readonly refreshToken?: string
@@ -147,6 +179,10 @@ export interface ProviderConfigPatch {
} | null
readonly reasoning?: ProviderReasoningPatch | null
readonly extras?: Readonly<Record<string, unknown>> | null
readonly maxTokens?: number | null
readonly contextWindow?: number | null
readonly temperature?: number | null
readonly pricing?: ProviderPricingPatch | null
}
// ---------------------------------------------------------------------------
@@ -1,7 +1,15 @@
import type { ApiConfiguration } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
import type {
AwsProviderConfig,
AzureProviderConfig,
EffectiveProviderConfig,
GcpProviderConfig,
ProviderId,
ProviderPricingConfig,
ProviderReasoningConfig,
} from "./contracts"
import { toSdkProviderId } from "./sdk-provider-id"
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
@@ -18,6 +26,12 @@ type ProviderSettingsLike = {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly azure?: AzureProviderConfig
readonly maxTokens?: number
readonly contextWindow?: number
readonly temperature?: number
readonly pricing?: ProviderPricingConfig
readonly reasoning?: ProviderReasoningConfig
readonly auth?: AuthConfig
readonly extras?: ExtrasConfig
}
@@ -157,6 +171,38 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
return typeof value === "boolean" ? value : undefined
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key]
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function readPricing(record: Record<string, unknown>): ProviderPricingConfig | undefined {
const pricing = record.pricing
if (!isPlainRecord(pricing)) {
return undefined
}
const result: ProviderPricingConfig = {
input: readNumber(pricing, "input"),
output: readNumber(pricing, "output"),
cacheRead: readNumber(pricing, "cacheRead"),
cacheWrite: readNumber(pricing, "cacheWrite"),
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readReasoning(record: Record<string, unknown>): ProviderReasoningConfig | undefined {
const reasoning = record.reasoning
if (!isPlainRecord(reasoning)) {
return undefined
}
const result: ProviderReasoningConfig = {
enabled: readBoolean(reasoning, "enabled"),
effort: readString(reasoning, "effort"),
budgetTokens: readNumber(reasoning, "budgetTokens"),
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
const gcp = record.gcp
if (!isPlainRecord(gcp)) {
@@ -170,6 +216,19 @@ function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readAzure(record: Record<string, unknown>): AzureProviderConfig | undefined {
const azure = record.azure
if (!isPlainRecord(azure)) {
return undefined
}
const result: AzureProviderConfig = {
apiVersion: readString(azure, "apiVersion"),
useIdentity: readBoolean(azure, "useIdentity"),
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined {
const aws = record.aws
if (!isPlainRecord(aws)) {
@@ -206,6 +265,12 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
region: readString(settings, "region"),
aws: readAws(settings),
gcp: readGcp(settings),
azure: readAzure(settings),
maxTokens: readNumber(settings, "maxTokens"),
contextWindow: readNumber(settings, "contextWindow"),
temperature: readNumber(settings, "temperature"),
pricing: readPricing(settings),
reasoning: readReasoning(settings),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike
@@ -279,6 +344,18 @@ function readStateGcp(provider: string, config: ApiConfiguration): GcpProviderCo
return Object.values(gcp).some((value) => value !== undefined) ? gcp : undefined
}
function readStateAzure(provider: string, config: ApiConfiguration): AzureProviderConfig | undefined {
if (provider !== "openai") {
return undefined
}
const azure: AzureProviderConfig = {
apiVersion: readStringFromConfig(config, "azureApiVersion"),
useIdentity: readStateBoolean(config, "azureIdentity"),
}
return Object.values(azure).some((value) => value !== undefined) ? azure : undefined
}
function readStateAws(provider: string, config: ApiConfiguration): AwsProviderConfig | undefined {
if (provider !== "bedrock") {
return undefined
@@ -308,6 +385,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
region: readStringFromConfig(config, regionFields[provider]),
aws: readStateAws(provider, config),
gcp: readStateGcp(provider, config),
azure: readStateAzure(provider, config),
auth: readStateAuth(provider, config),
extras: readStateExtras(provider, config),
}
@@ -333,6 +411,19 @@ function mergeGcp(first: GcpProviderConfig | undefined, second: GcpProviderConfi
return { ...first, ...second }
}
function mergeAzure(
first: AzureProviderConfig | undefined,
second: AzureProviderConfig | undefined,
): AzureProviderConfig | undefined {
if (!first) {
return second
}
if (!second) {
return first
}
return { ...first, ...second }
}
function mergeAws(first: AwsProviderConfig | undefined, second: AwsProviderConfig | undefined): AwsProviderConfig | undefined {
if (!first) {
return second
@@ -372,6 +463,12 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
// fields as a fallback for old installs, but let providers.json win when both exist.
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
assignIfDefined(merged, "azure", mergeAzure(stateConfig.azure, providerSettings.azure))
assignIfDefined(merged, "maxTokens", providerSettings.maxTokens)
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow)
assignIfDefined(merged, "temperature", providerSettings.temperature)
assignIfDefined(merged, "pricing", providerSettings.pricing)
assignIfDefined(merged, "reasoning", providerSettings.reasoning)
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
@@ -233,6 +233,10 @@ describe("createProviderConfigStore", () => {
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
contextWindow: 128_000,
maxTokens: 8_192,
pricing: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.02 },
temperature: 0.2,
},
})
const store = createProviderConfigStore()
@@ -245,6 +249,10 @@ describe("createProviderConfigStore", () => {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "gpt-oss-120b",
contextWindow: 128_000,
maxTokens: 8_192,
pricing: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.02 },
temperature: 0.2,
})
})
@@ -353,6 +361,102 @@ describe("createProviderConfigStore", () => {
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
})
it("persists explicit provider model overrides to providers.json", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.write(providerId, {
headers: { "X-Test": "1" },
azure: { apiVersion: "2025-01-01-preview", useIdentity: true },
reasoning: { enabled: true, effort: "high" },
contextWindow: 128_000,
maxTokens: 8_192,
pricing: { input: 0, output: 2.5, cacheRead: 0.1, cacheWrite: 0.2 },
temperature: 0.2,
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
headers: { "X-Test": "1" },
azure: { apiVersion: "2025-01-01-preview", useIdentity: true },
reasoning: { enabled: true, effort: "high" },
contextWindow: 128_000,
maxTokens: 8_192,
pricing: { input: 0, output: 2.5, cacheRead: 0.1, cacheWrite: 0.2 },
temperature: 0.2,
})
})
it("persists explicit model overrides for the SDK OpenAI Compatible id", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai-compatible")
store.write(providerId, {
contextWindow: 64_000,
maxTokens: 4_096,
pricing: { input: 0.2, output: 0.6, cacheRead: 0.03, cacheWrite: 0.04 },
temperature: 0.1,
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
contextWindow: 64_000,
maxTokens: 4_096,
pricing: { input: 0.2, output: 0.6, cacheRead: 0.03, cacheWrite: 0.04 },
temperature: 0.1,
})
})
it("persists the OpenAI Compatible max tokens unset sentinel", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai-compatible")
store.write(providerId, {
maxTokens: -1,
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
maxTokens: -1,
})
})
it("does not persist explicit custom model overrides for non-OpenAI-Compatible providers", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
openrouter: {
provider: "openrouter",
apiKey: "existing-key",
contextWindow: 64_000,
maxTokens: 4_096,
temperature: 0.2,
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
store.write(providerId, {
contextWindow: 128_000,
maxTokens: 8_192,
pricing: { input: 0, output: 2.5, cacheRead: 0.1, cacheWrite: 0.2 },
temperature: 0.3,
})
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
provider: "openrouter",
apiKey: "existing-key",
})
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("contextWindow")
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("maxTokens")
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("temperature")
expect(mocks.getSavedProviderSettings("openrouter")).not.toHaveProperty("pricing")
})
it("updates providers.json model with setLastUsed false when planActSeparateModelsSetting=false", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
+97 -6
View File
@@ -21,7 +21,21 @@ import { toSdkProviderId } from "./sdk-provider-id"
import { adaptSdkModelInfo } from "./shape-adapter"
type ProviderSettingsRecord = Record<string, unknown>
type ProviderSettingsPatchKey = "apiKey" | "baseUrl" | "apiLine" | "headers" | "region" | "auth" | "extras" | "aws" | "gcp"
type ProviderSettingsPatchKey =
| "apiKey"
| "baseUrl"
| "apiLine"
| "headers"
| "region"
| "auth"
| "extras"
| "aws"
| "gcp"
| "azure"
| "maxTokens"
| "contextWindow"
| "temperature"
| "pricing"
type ModelInfoKeys = {
readonly plan: keyof ApiConfiguration & SettingsKey
@@ -89,6 +103,11 @@ const providerConfigStateKeys: Record<ProviderSettingsPatchKey, Partial<Record<s
extras: {},
aws: {},
gcp: {},
azure: {},
maxTokens: {},
contextWindow: {},
temperature: {},
pricing: {},
}
const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
@@ -131,6 +150,11 @@ function providerSettingsProviderId(providerId: ProviderId): string {
return toSdkProviderId(providerId)
}
function supportsCustomModelSettings(providerId: ProviderId): boolean {
const key = providerKey(providerId)
return key === "openai" || key === "openai-compatible" || toSdkProviderId(providerId) === "openai-compatible"
}
function memoryKey(providerId: ProviderId, mode: Mode): string {
return `${providerId}:${mode}`
}
@@ -296,8 +320,19 @@ function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsReco
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
const existing = getProviderSettings(providerId)
const next: ProviderSettingsRecord = { ...existing }
const canWriteCustomModelSettings = supportsCustomModelSettings(providerId)
const providerSettingsKeys = [
"apiKey",
"baseUrl",
"apiLine",
"headers",
"region",
"auth",
"extras",
...(canWriteCustomModelSettings ? (["maxTokens", "contextWindow", "temperature"] as const) : []),
] as const
for (const key of ["apiKey", "baseUrl", "apiLine", "headers", "region", "auth", "extras"] as const) {
for (const key of providerSettingsKeys) {
if (key in patch) {
const value = typeof patch[key] === "string" ? patchStringValue(patch[key]) : patchValue(patch[key])
if (value === undefined) {
@@ -308,6 +343,31 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if (!canWriteCustomModelSettings) {
delete next.maxTokens
delete next.contextWindow
delete next.temperature
delete next.pricing
}
if ("pricing" in patch && canWriteCustomModelSettings) {
const pricingPatch = patch.pricing
if (pricingPatch === null || pricingPatch === undefined) {
delete next.pricing
} else {
const existingPricing = isRecord(next.pricing) ? next.pricing : {}
const numberOrZero = (value: unknown): number =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0
next.pricing = {
...existingPricing,
input: numberOrZero(pricingPatch.input ?? existingPricing.input),
output: numberOrZero(pricingPatch.output ?? existingPricing.output),
cacheRead: numberOrZero(pricingPatch.cacheRead ?? existingPricing.cacheRead),
cacheWrite: numberOrZero(pricingPatch.cacheWrite ?? existingPricing.cacheWrite),
}
}
}
if ("gcp" in patch) {
const gcpPatch = patch.gcp
if (gcpPatch === null || gcpPatch === undefined) {
@@ -330,6 +390,28 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("azure" in patch) {
const azurePatch = patch.azure
if (azurePatch === null || azurePatch === undefined) {
delete next.azure
} else {
const existingAzure = isRecord(next.azure) ? next.azure : {}
const nextAzure: ProviderSettingsRecord = { ...existingAzure }
for (const [key, value] of Object.entries(azurePatch)) {
if (typeof value === "string" && value.length === 0) {
delete nextAzure[key]
} else {
nextAzure[key] = value
}
}
if (Object.keys(nextAzure).length === 0) {
delete next.azure
} else {
next.azure = nextAzure
}
}
}
if ("aws" in patch) {
const awsPatch = patch.aws
if (awsPatch === null || awsPatch === undefined) {
@@ -360,7 +442,11 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
merged.enabled = reasoningPatch.enabled
}
if (reasoningPatch.effort !== undefined) {
merged.effort = reasoningPatch.effort === "none" ? undefined : reasoningPatch.effort
if (reasoningPatch.effort === "none") {
delete merged.effort
} else {
merged.effort = reasoningPatch.effort
}
// When effort is "none", disable reasoning
if (reasoningPatch.effort === "none") {
merged.enabled = false
@@ -404,9 +490,14 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
// Prune model metadata that earlier builds may have written to providers.json.
delete next.contextWindow
delete next.maxTokens
// Prune model metadata that earlier builds may have written for providers
// whose catalog, not per-provider settings, owns model metadata.
if (!supportsCustomModelSettings(providerId)) {
delete next.contextWindow
delete next.maxTokens
delete next.temperature
delete next.pricing
}
saveProviderSettings(providerId, next)
}
+1 -1
View File
@@ -202,7 +202,7 @@ export const openAiModelInfoSafeDefaults: OpenAiCompatibleModelInfo = {
isR1FormatRequired: false,
inputPrice: 0,
outputPrice: 0,
temperature: 0,
temperature: -1,
}
// OpenAI Codex (ChatGPT Plus/Pro subscription)
@@ -97,8 +97,8 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const isCostAvailable =
(totalCost &&
modeFields.apiProvider === "openai" &&
modeFields.openAiModelInfo?.inputPrice &&
modeFields.openAiModelInfo?.outputPrice) ||
selectedModelInfo.inputPrice !== undefined &&
selectedModelInfo.outputPrice !== undefined) ||
(modeFields.apiProvider !== "vscode-lm" &&
modeFields.apiProvider !== "ollama" &&
modeFields.apiProvider !== "lmstudio" &&
@@ -12,6 +12,8 @@ interface ReasoningEffortSelectorProps {
description?: string
allowedEfforts?: readonly OpenaiReasoningEffort[]
defaultEffort?: OpenaiReasoningEffort
value?: OpenaiReasoningEffort
persistToApiConfiguration?: boolean
/** Optional callback invoked after the effort value changes. Use to persist to provider-specific stores. */
onEffortChange?: (effort: OpenaiReasoningEffort) => void
}
@@ -22,22 +24,32 @@ const ReasoningEffortSelector = ({
description = "Higher effort improves depth, but uses more tokens.",
allowedEfforts = OPENAI_REASONING_EFFORT_OPTIONS,
defaultEffort = "medium",
value,
persistToApiConfiguration = true,
onEffortChange,
}: ReasoningEffortSelectorProps) => {
const { apiConfiguration } = useExtensionState()
const { handleModeFieldChange } = useApiConfigurationHandlers()
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
const selectedEffort =
isOpenaiReasoningEffort(modeFields.reasoningEffort) && allowedEfforts.includes(modeFields.reasoningEffort)
? modeFields.reasoningEffort
: defaultEffort
value && allowedEfforts.includes(value)
? value
: isOpenaiReasoningEffort(modeFields.reasoningEffort) && allowedEfforts.includes(modeFields.reasoningEffort)
? modeFields.reasoningEffort
: defaultEffort
return (
<div style={{ marginTop: 10, marginBottom: 5 }}>
<Label className="text-xs font-medium">{label}</Label>
<Select
onValueChange={(value) => {
handleModeFieldChange({ plan: "planModeReasoningEffort", act: "actModeReasoningEffort" }, value, currentMode)
if (persistToApiConfiguration) {
handleModeFieldChange(
{ plan: "planModeReasoningEffort", act: "actModeReasoningEffort" },
value,
currentMode,
)
}
if (onEffortChange && isOpenaiReasoningEffort(value)) {
onEffortChange(value)
}
@@ -1,4 +1,4 @@
import { TooltipContent, TooltipTrigger } from "@radix-ui/react-tooltip"
import { TooltipTrigger } from "@radix-ui/react-tooltip"
import {
azureOpenAiDefaultApiVersion,
type ModelInfo,
@@ -11,8 +11,6 @@ import type { Mode } from "@shared/storage/types"
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Tooltip } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useDynamicProviderSelection } from "@/hooks/useDynamicProviderSelection"
import { useProviderConfig } from "@/hooks/useProviderConfig"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
@@ -21,8 +19,6 @@ import { BaseUrlField } from "../common/BaseUrlField"
import { DebouncedTextField } from "../common/DebouncedTextField"
import { ModelInfoView } from "../common/ModelInfoView"
import ReasoningEffortSelector from "../ReasoningEffortSelector"
import { parsePrice } from "../utils/pricingUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { useProviderApiKeyField } from "../utils/useProviderApiKeyField"
/**
@@ -35,6 +31,33 @@ interface OpenAICompatibleProviderProps {
currentMode: Mode
}
const finiteNumberOrUndefined = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined
const positiveFiniteNumberOrUndefined = (value: unknown): number | undefined => {
const numberValue = finiteNumberOrUndefined(value)
return numberValue !== undefined && numberValue > 0 ? numberValue : undefined
}
const finiteNumberOrZero = (value: unknown): number => finiteNumberOrUndefined(value) ?? 0
const numberInputValue = (value: unknown, fallback: unknown): string =>
(finiteNumberOrUndefined(value) ?? finiteNumberOrUndefined(fallback) ?? 0).toString()
const unsetSentinelNumberInputValue = (value: unknown, fallback: unknown): string => {
const numberValue = finiteNumberOrUndefined(value) ?? finiteNumberOrUndefined(fallback)
return numberValue !== undefined && numberValue >= 0 ? numberValue.toString() : ""
}
const parseNumberInput = (value: string, fallback: unknown): number => {
const trimmed = value.trim()
if (!trimmed || trimmed === ".") {
return finiteNumberOrZero(fallback)
}
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : finiteNumberOrZero(fallback)
}
/**
* The OpenAI Compatible provider configuration component
*/
@@ -44,8 +67,6 @@ export const OpenAICompatibleProvider = ({
isPopup,
currentMode,
}: OpenAICompatibleProviderProps) => {
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
const { config, write, commitSelection } = useProviderConfig(providerId)
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
@@ -53,46 +74,54 @@ export const OpenAICompatibleProvider = ({
const [availableOpenAiModels, setAvailableOpenAiModels] = useState<string[]>([])
const [isRefreshingOpenAiModels, setIsRefreshingOpenAiModels] = useState(false)
const [openAiModelsError, setOpenAiModelsError] = useState<string | undefined>(undefined)
// Only the built-in "openai" provider stores its API key in the legacy
// ApiConfiguration field; custom providers keep it in their per-provider
// config (available only as a masked length), so there is no plaintext key
// to seed the model-refresh request with.
const legacyOpenAiApiKey = providerId === "openai" ? apiConfiguration?.openAiApiKey || "" : ""
const latestOpenAiBaseUrlRef = useRef(config?.baseUrl || "")
const latestOpenAiApiKeyRef = useRef(legacyOpenAiApiKey)
const latestOpenAiApiKeyRef = useRef("")
const openAiModelsRequestRef = useRef(0)
useEffect(() => {
latestOpenAiBaseUrlRef.current = config?.baseUrl || ""
}, [config?.baseUrl])
useEffect(() => {
latestOpenAiApiKeyRef.current = legacyOpenAiApiKey
}, [legacyOpenAiApiKey])
const handleProviderConfigWriteError = useCallback((fieldName: string, error: unknown) => {
console.error(`Failed to update OpenAI Compatible ${fieldName}:`, error)
}, [])
// Built-in "openai" persists model selection to its legacy ApiConfiguration
// fields; custom/unknown providers persist via their per-provider committed
// selection. Prefer the committed selection and fall back to the legacy
// fields so the built-in provider keeps working unchanged.
const isOpenAiProvider = providerId === "openai" || providerId === "openai-compatible"
const { selectedModelId: legacySelectedModelId, selectedModelInfo: legacySelectedModelInfo } = useDynamicProviderSelection(
providerId,
apiConfiguration,
currentMode,
const handleAzureApiVersionChange = useCallback(
(value: string) => {
void write({ azure: { apiVersion: value } }).catch((error) =>
handleProviderConfigWriteError("Azure API version", error),
)
},
[handleProviderConfigWriteError, write],
)
const handleAzureIdentityChange = useCallback(
(enabled: boolean) => {
void write({ azure: { useIdentity: enabled } }).catch((error) =>
handleProviderConfigWriteError("Azure identity authentication", error),
)
},
[handleProviderConfigWriteError, write],
)
const committedSelection = currentMode === "plan" ? config?.planSelection : config?.actSelection
const selectedModelId = committedSelection?.modelId ?? legacySelectedModelId
const selectedModelId = committedSelection?.modelId ?? ""
const selectedModelInfo = committedSelection?.modelInfo
? fromProtobufModelInfo(committedSelection.modelInfo)
: legacySelectedModelInfo
: openAiModelInfoSafeDefaults
// The Model Configuration section reads/writes the resolved model info.
// OpenAiCompatibleModelInfo only adds optional fields over ModelInfo, so a
// resolved ModelInfo satisfies it structurally.
const openAiModelInfo: OpenAiCompatibleModelInfo = selectedModelInfo
const openAiModelInfo: OpenAiCompatibleModelInfo = {
...selectedModelInfo,
...(config?.contextWindow !== undefined ? { contextWindow: Number(config.contextWindow) } : {}),
...(config?.maxTokens !== undefined ? { maxTokens: Number(config.maxTokens) } : {}),
...(config?.temperature !== undefined ? { temperature: config.temperature } : {}),
...(config?.pricing?.input !== undefined ? { inputPrice: config.pricing.input } : {}),
...(config?.pricing?.output !== undefined ? { outputPrice: config.pricing.output } : {}),
...(config?.pricing?.cacheRead !== undefined ? { cacheReadsPrice: config.pricing.cacheRead } : {}),
...(config?.pricing?.cacheWrite !== undefined ? { cacheWritesPrice: config.pricing.cacheWrite } : {}),
}
const commitOpenAiSelection = useCallback(
(modelId: string, modelInfo = openAiModelInfo ?? openAiModelInfoSafeDefaults) => {
@@ -109,17 +138,25 @@ export const OpenAICompatibleProvider = ({
},
}).catch((error) => handleProviderConfigWriteError("model selection", error))
},
[commitSelection, currentMode, handleProviderConfigWriteError, openAiModelInfo],
[commitSelection, currentMode, handleProviderConfigWriteError, openAiModelInfo, providerId],
)
const handleOpenAiModelInfoChange = useCallback(
(modelInfo: typeof openAiModelInfoSafeDefaults) => {
if (isOpenAiProvider) {
handleModeFieldChange({ plan: "planModeOpenAiModelInfo", act: "actModeOpenAiModelInfo" }, modelInfo, currentMode)
}
(modelInfo: OpenAiCompatibleModelInfo) => {
commitOpenAiSelection(selectedModelId || "", modelInfo)
void write({
contextWindow: positiveFiniteNumberOrUndefined(modelInfo.contextWindow),
maxTokens: finiteNumberOrUndefined(modelInfo.maxTokens),
temperature: finiteNumberOrUndefined(modelInfo.temperature),
pricing: {
input: finiteNumberOrZero(modelInfo.inputPrice),
output: finiteNumberOrZero(modelInfo.outputPrice),
cacheRead: finiteNumberOrZero(modelInfo.cacheReadsPrice),
cacheWrite: finiteNumberOrZero(modelInfo.cacheWritesPrice),
},
}).catch((error) => handleProviderConfigWriteError("model configuration", error))
},
[commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider, selectedModelId],
[commitOpenAiSelection, handleProviderConfigWriteError, selectedModelId, write],
)
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
@@ -192,19 +229,18 @@ export const OpenAICompatibleProvider = ({
const toOpenAiModelInfo = useCallback(
(modelId: string): ModelInfo => ({
...openAiModelInfoSafeDefaults,
...(openAiModelInfo ?? {}),
name: modelId,
supportsPromptCache: openAiModelInfo?.supportsPromptCache ?? openAiModelInfoSafeDefaults.supportsPromptCache,
}),
[],
[openAiModelInfo],
)
const handleOpenAiModelSelection = useCallback(
(modelId: string, modelInfo = toOpenAiModelInfo(modelId)) => {
if (isOpenAiProvider) {
handleModeFieldChange({ plan: "planModeOpenAiModelId", act: "actModeOpenAiModelId" }, modelId, currentMode)
}
commitOpenAiSelection(modelId, modelInfo)
},
[commitOpenAiSelection, currentMode, handleModeFieldChange, isOpenAiProvider, toOpenAiModelInfo],
[commitOpenAiSelection, toOpenAiModelInfo],
)
const { savedApiKeyMask, handleApiKeyChange } = useProviderApiKeyField({
@@ -225,12 +261,8 @@ export const OpenAICompatibleProvider = ({
<div className="mb-2.5">
<div className="flex items-center gap-2 mb-1">
<span style={{ fontWeight: 500 }}>Base URL</span>
{remoteConfigSettings?.openAiBaseUrl !== undefined && (
<i className="codicon codicon-lock text-description text-sm" />
)}
</div>
<DebouncedTextField
disabled={remoteConfigSettings?.openAiBaseUrl !== undefined}
initialValue={config?.baseUrl || ""}
onChange={(value) => {
if (!config) {
@@ -247,9 +279,6 @@ export const OpenAICompatibleProvider = ({
/>
</div>
</TooltipTrigger>
<TooltipContent hidden={remoteConfigSettings?.openAiBaseUrl === undefined}>
This setting is managed by your organization's remote configuration
</TooltipContent>
</Tooltip>
<ApiKeyField initialValue={savedApiKeyMask} onChange={handleApiKeyChange} providerName="OpenAI Compatible" />
@@ -331,17 +360,10 @@ export const OpenAICompatibleProvider = ({
<TooltipTrigger>
<div className="flex items-center gap-2">
<span style={{ fontWeight: 500 }}>Custom Headers</span>
{remoteConfigSettings?.openAiHeaders !== undefined && (
<i className="codicon codicon-lock text-description text-sm" />
)}
</div>
</TooltipTrigger>
<TooltipContent hidden={remoteConfigSettings?.openAiHeaders === undefined}>
This setting is managed by your organization's remote configuration
</TooltipContent>
</Tooltip>
<VSCodeButton
disabled={remoteConfigSettings?.openAiHeaders !== undefined}
onClick={() => {
const currentHeaders = { ...headers }
const headerCount = Object.keys(currentHeaders).length
@@ -359,7 +381,6 @@ export const OpenAICompatibleProvider = ({
{headerEntries.map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
<DebouncedTextField
disabled={remoteConfigSettings?.openAiHeaders !== undefined}
initialValue={key}
onChange={(newValue) => {
const currentHeaders = config?.headers ?? {}
@@ -377,7 +398,6 @@ export const OpenAICompatibleProvider = ({
style={{ width: "40%" }}
/>
<DebouncedTextField
disabled={remoteConfigSettings?.openAiHeaders !== undefined}
initialValue={value}
onChange={(newValue) => {
void write({
@@ -392,7 +412,6 @@ export const OpenAICompatibleProvider = ({
/>
<VSCodeButton
appearance="secondary"
disabled={remoteConfigSettings?.openAiHeaders !== undefined}
onClick={() => {
const { [key]: _, ...rest } = config?.headers ?? {}
void write({ headers: rest }).catch((error) =>
@@ -408,34 +427,18 @@ export const OpenAICompatibleProvider = ({
)
})()}
{remoteConfigSettings?.azureApiVersion !== undefined ? (
<Tooltip>
<TooltipTrigger>
<BaseUrlField
disabled={true}
initialValue={apiConfiguration?.azureApiVersion}
label="Set Azure API version"
onChange={(value) => handleFieldChange("azureApiVersion", value)}
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
showLockIcon={true}
/>
</TooltipTrigger>
<TooltipContent>This setting is managed by your organization's remote configuration</TooltipContent>
</Tooltip>
) : (
<BaseUrlField
initialValue={apiConfiguration?.azureApiVersion}
label="Set Azure API version"
onChange={(value) => handleFieldChange("azureApiVersion", value)}
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
)}
<BaseUrlField
initialValue={config?.azure?.apiVersion}
label="Set Azure API version"
onChange={handleAzureApiVersionChange}
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
<VSCodeCheckbox
checked={apiConfiguration?.azureIdentity || false}
checked={config?.azure?.useIdentity || false}
onChange={(e: any) => {
const isChecked = e.target.checked === true
return handleFieldChange("azureIdentity", isChecked)
handleAzureIdentityChange(isChecked)
}}>
Use Azure Identity Authentication
</VSCodeCheckbox>
@@ -470,52 +473,40 @@ export const OpenAICompatibleProvider = ({
checked={!!openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.supportsImages = isChecked
handleOpenAiModelInfoChange(modelInfo)
commitOpenAiSelection(selectedModelId || "", { ...openAiModelInfo, supportsImages: isChecked })
}}>
Supports Images
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!openAiModelInfo?.isR1FormatRequired}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
handleOpenAiModelInfoChange(modelInfo)
}}>
Enable R1 messages format
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.contextWindow
? openAiModelInfo.contextWindow.toString()
: (openAiModelInfoSafeDefaults.contextWindow?.toString() ?? "")
initialValue={numberInputValue(
openAiModelInfo?.contextWindow,
openAiModelInfoSafeDefaults.contextWindow,
)}
onChange={(value) =>
handleOpenAiModelInfoChange({
...openAiModelInfo,
contextWindow: parseNumberInput(value, openAiModelInfoSafeDefaults.contextWindow),
})
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.contextWindow = Number(value)
handleOpenAiModelInfoChange(modelInfo)
}}
placeholder={openAiModelInfoSafeDefaults.contextWindow?.toString()}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</DebouncedTextField>
<DebouncedTextField
initialValue={
openAiModelInfo?.maxTokens
? openAiModelInfo.maxTokens.toString()
: (openAiModelInfoSafeDefaults.maxTokens?.toString() ?? "")
initialValue={unsetSentinelNumberInputValue(
openAiModelInfo?.maxTokens,
openAiModelInfoSafeDefaults.maxTokens,
)}
onChange={(value) =>
handleOpenAiModelInfoChange({
...openAiModelInfo,
maxTokens: parseNumberInput(value, openAiModelInfoSafeDefaults.maxTokens),
})
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.maxTokens = Number(value)
handleOpenAiModelInfoChange(modelInfo)
}}
placeholder="Not set"
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</DebouncedTextField>
@@ -523,31 +514,27 @@ export const OpenAICompatibleProvider = ({
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.inputPrice
? openAiModelInfo.inputPrice.toString()
: (openAiModelInfoSafeDefaults.inputPrice?.toString() ?? "")
initialValue={numberInputValue(openAiModelInfo?.inputPrice, openAiModelInfoSafeDefaults.inputPrice)}
onChange={(value) =>
handleOpenAiModelInfoChange({
...openAiModelInfo,
inputPrice: parseNumberInput(value, openAiModelInfoSafeDefaults.inputPrice),
})
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.inputPrice = parsePrice(value, openAiModelInfoSafeDefaults.inputPrice ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}
placeholder={openAiModelInfoSafeDefaults.inputPrice?.toString()}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</DebouncedTextField>
<DebouncedTextField
initialValue={
openAiModelInfo?.outputPrice
? openAiModelInfo.outputPrice.toString()
: (openAiModelInfoSafeDefaults.outputPrice?.toString() ?? "")
initialValue={numberInputValue(openAiModelInfo?.outputPrice, openAiModelInfoSafeDefaults.outputPrice)}
onChange={(value) =>
handleOpenAiModelInfoChange({
...openAiModelInfo,
outputPrice: parseNumberInput(value, openAiModelInfoSafeDefaults.outputPrice),
})
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.outputPrice = parsePrice(value, openAiModelInfoSafeDefaults.outputPrice ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}
placeholder={openAiModelInfoSafeDefaults.outputPrice?.toString()}
style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</DebouncedTextField>
@@ -555,16 +542,17 @@ export const OpenAICompatibleProvider = ({
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<DebouncedTextField
initialValue={
openAiModelInfo?.temperature
? openAiModelInfo.temperature.toString()
: (openAiModelInfoSafeDefaults.temperature?.toString() ?? "")
initialValue={unsetSentinelNumberInputValue(
openAiModelInfo?.temperature,
openAiModelInfoSafeDefaults.temperature,
)}
onChange={(value) =>
handleOpenAiModelInfoChange({
...openAiModelInfo,
temperature: parseNumberInput(value, openAiModelInfoSafeDefaults.temperature),
})
}
onChange={(value) => {
const modelInfo = openAiModelInfo ? { ...openAiModelInfo } : { ...openAiModelInfoSafeDefaults }
modelInfo.temperature = parsePrice(value, openAiModelInfoSafeDefaults.temperature ?? 0)
handleOpenAiModelInfoChange(modelInfo)
}}>
placeholder="Not set">
<span style={{ fontWeight: 500 }}>Temperature</span>
</DebouncedTextField>
</div>
@@ -592,10 +580,12 @@ export const OpenAICompatibleProvider = ({
void write({
reasoning: {
enabled: effort !== "none",
effort: effort !== "none" ? effort : undefined,
effort,
},
}).catch((err) => console.error("Failed to update OpenAI Compatible reasoning effort:", err))
}}
persistToApiConfiguration={false}
value={(config?.reasoning?.effort as any) ?? "none"}
/>
<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
</>
@@ -1,6 +1,7 @@
import { StringRequest } from "@shared/proto/cline/common"
import {
type AwsProviderConfig,
type AzureProviderConfig,
CommitModelSelectionRequest,
type GcpProviderConfig,
type ProviderConfigResponse,
@@ -17,6 +18,7 @@ export type ProviderConfigWritePatch = Partial<Omit<WriteProviderConfigPatch, "h
headers?: Record<string, string>
aws?: Partial<AwsProviderConfig>
gcp?: Partial<GcpProviderConfig>
azure?: Partial<AzureProviderConfig>
}
export interface ProviderModelSelection {
@@ -225,6 +225,31 @@ describe("createAgentModelFromConfig", () => {
);
});
it("omits negative temperature sentinel from gateway request options", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
createAgentModelFromConfig(
{
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "key",
systemPrompt: "",
tools: [],
providerConfig: {
providerId: "openai-compatible",
modelId: "custom-model",
temperature: -1,
},
},
undefined,
);
expect(gatewayMock.createAgentModel).toHaveBeenLastCalledWith(
{ providerId: "openai-compatible", modelId: "custom-model" },
undefined,
);
});
it("forwards Bedrock AWS settings as gateway provider options", async () => {
const { createAgentModelFromConfig } = await import("./handler-factory");
@@ -25,6 +25,12 @@ function compactOptions(
return Object.keys(compacted).length > 0 ? compacted : undefined;
}
function nonNegativeFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function usesOpenAICompatibleClient(config: ProviderConfig): boolean {
return (
config.providerId === "openai-compatible" ||
@@ -216,6 +222,9 @@ export function createAgentModelFromConfig(
providerId: normalizedProviderConfig.providerId,
modelId: normalizedProviderConfig.modelId,
},
{ maxTokens: normalizedProviderConfig.maxOutputTokens },
compactOptions({
maxTokens: normalizedProviderConfig.maxOutputTokens,
temperature: nonNegativeFiniteNumber(normalizedProviderConfig.temperature),
}),
);
}
@@ -15,6 +15,45 @@ describe("provider settings", () => {
expect(config.accessToken).toBe("oauth-access-token");
});
it("maps OpenAI Compatible provider pricing settings into model pricing", () => {
const config = toProviderConfig({
provider: "openai-compatible",
model: "custom-model",
pricing: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.02 },
});
expect(config.knownModels?.["custom-model"]?.pricing).toEqual({
input: 0.1,
output: 0.5,
cacheRead: 0.01,
cacheWrite: 0.02,
});
});
it("treats maxTokens -1 as unset for OpenAI Compatible runtime config", () => {
const config = toProviderConfig({
provider: "openai-compatible",
model: "custom-model",
maxTokens: -1,
});
expect(config.maxOutputTokens).toBeUndefined();
expect(config.knownModels?.["custom-model"]?.maxTokens).toBeUndefined();
});
it("does not overwrite catalog model info with sparse settings for non-OpenAI-Compatible providers", () => {
const config = toProviderConfig({
provider: "anthropic",
model: "claude-sonnet-4-6",
});
expect(config.knownModels?.["claude-sonnet-4-6"]).toMatchObject({
id: "claude-sonnet-4-6",
contextWindow: expect.any(Number),
maxTokens: expect.any(Number),
});
});
it("accepts the Bedrock apikey authentication alias", () => {
const result = safeParseSettings({
provider: "bedrock",
@@ -23,6 +23,18 @@ export const BUILT_IN_PROVIDER_IDS = Llms.BUILT_IN_PROVIDER_IDS;
export const isBuiltInProviderId = Llms.isBuiltInProviderId;
export const normalizeProviderId = Llms.normalizeProviderId;
function nonNegativeFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function positiveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
}
export type ProviderDefaultsConfig = ProviderDefaults;
export const ProviderIdSchema = z
@@ -131,6 +143,14 @@ export const ModelCatalogSettingsSchema = z.object({
failOnError: z.boolean().optional(),
});
export const PricingSettingsSchema = z.object({
input: z.number().nonnegative(),
output: z.number().nonnegative(),
cacheRead: z.number().nonnegative(),
cacheWrite: z.number().nonnegative(),
});
export type PricingSettings = z.infer<typeof PricingSettingsSchema>;
export type ModelCatalogSettings = z.infer<typeof ModelCatalogSettingsSchema>;
export type ModelCatalogConfig = ModelCatalogSettings;
@@ -142,8 +162,11 @@ export const ProviderSettingsSchema = z.object({
protocol: ProviderProtocolSchema.optional(),
client: ProviderClientSchema.optional(),
routingProviderId: ProviderIdSchema.optional(),
maxTokens: z.number().int().positive().optional(),
maxTokens: z.union([z.literal(-1), z.number().int().positive()]).optional(),
contextWindow: z.number().int().positive().optional(),
/** Pricing per million tokens (for usage tracking). */
pricing: PricingSettingsSchema.optional(),
temperature: z.number().optional(),
baseUrl: z.string().url().optional(),
headers: z.record(z.string(), z.string()).optional(),
timeout: z.number().int().positive().optional(),
@@ -231,11 +254,35 @@ export function toProviderConfig(
? BUILT_IN_PROVIDER.OPENAI_NATIVE
: undefined);
const supportsCustomModelSettings = normalizedProviderId === BUILT_IN_PROVIDER.OPENAI_COMPATIBLE;
const temperature = nonNegativeFiniteNumber(settings.temperature);
const maxTokens = positiveFiniteNumber(settings.maxTokens);
const configuredModelInfo = supportsCustomModelSettings && settings.model
? {
id: settings.model,
name: settings.model,
maxTokens,
contextWindow: settings.contextWindow,
maxInputTokens: settings.contextWindow,
temperature: settings.temperature,
pricing: settings.pricing
? {
input: settings.pricing.input,
output: settings.pricing.output,
cacheRead: settings.pricing.cacheRead,
cacheWrite: settings.pricing.cacheWrite,
}
: undefined,
}
: undefined;
const knownModels = includeKnownModels
? (providerDefaults?.knownModels ??
(Object.keys(generatedKnownModels).length > 0
? generatedKnownModels
: undefined))
? (configuredModelInfo
? { ...(providerDefaults?.knownModels ?? generatedKnownModels), [settings.model as string]: configuredModelInfo }
: (providerDefaults?.knownModels ??
(Object.keys(generatedKnownModels).length > 0
? generatedKnownModels
: undefined)))
: undefined;
const config: ProviderConfig = {
@@ -255,8 +302,9 @@ export function toProviderConfig(
baseUrl: resolvedBaseUrl,
headers: settings.headers,
timeoutMs: settings.timeout,
maxOutputTokens: settings.maxTokens,
maxOutputTokens: maxTokens,
maxInputTokens: settings.contextWindow,
temperature,
thinking: settings.reasoning?.enabled,
reasoningEffort,
thinkingBudgetTokens: settings.reasoning?.budgetTokens,
@@ -249,6 +249,8 @@ export interface CloudConfig {
export interface ProviderOptions {
/** OpenRouter provider sorting preference */
openRouterProviderSorting?: string;
/** Default request temperature override */
temperature?: number;
/** Runtime model catalog refresh configuration */
modelCatalog?: ModelCatalogConfig;
}