mirror of
https://github.com/cline/cline.git
synced 2026-09-13 01:39:57 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902e63006a | ||
|
|
026a739454 | ||
|
|
c1ef582492 |
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user