diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index a95ef16cd33..f07e7b327e6 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -79,10 +79,6 @@ export default [ ], rules: { complexity: ["error", 27] }, }, - { - files: ["webview-ui/src/components/settings/CustomProviderDialog.tsx"], - rules: { complexity: ["error", 26] }, - }, { files: ["src/agent-manager/WorktreeStateManager.ts"], rules: { complexity: ["error", 24] }, diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts index d9d3796ad2f..1438e707225 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -1,4 +1,4 @@ -import type { ModelEntry } from "./CustomProviderModelCard" +import type { ModelEntry, VariantEntry } from "./CustomProviderModelCard" type Translator = (key: string, params?: Record) => string @@ -54,22 +54,95 @@ type ValidateResult = { const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" +function checkVariant(v: VariantEntry, seen: Set, t: Translator) { + const n = v.name.trim() + if (!n) return { name: t("provider.custom.error.required") } + if (seen.has(n)) return { name: t("provider.custom.error.duplicate") } + seen.add(n) + return { name: undefined } +} + +function checkModel(m: ModelEntry, seenModels: Set, t: Translator) { + const id = m.id.trim() + let idErr: string | undefined + if (!id) idErr = t("provider.custom.error.required") + else if (seenModels.has(id)) idErr = t("provider.custom.error.duplicate") + else seenModels.add(id) + + const nameErr = !m.name.trim() ? t("provider.custom.error.required") : undefined + const seen = new Set() + const variants = m.reasoning ? m.variants.map((v) => checkVariant(v, seen, t)) : [] + return { id: idErr, name: nameErr, variants } +} + +function checkHeader(h: HeaderRow, seenKeys: Set, t: Translator) { + const key = h.key.trim() + const value = h.value.trim() + if (!key && !value) return {} + + let keyErr: string | undefined + if (!key) keyErr = t("provider.custom.error.required") + else if (seenKeys.has(key.toLowerCase())) keyErr = t("provider.custom.error.duplicate") + else seenKeys.add(key.toLowerCase()) + + const valueErr = !value ? t("provider.custom.error.required") : undefined + return { key: keyErr, value: valueErr } +} + +function checkProviderID(id: string, editing: boolean, disabled: string[], existing: Set, t: Translator) { + const idErr = !id + ? t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(id) + ? t("provider.custom.error.providerID.format") + : undefined + const existsErr = + idErr || editing || !existing.has(id) || disabled.includes(id) + ? undefined + : t("provider.custom.error.providerID.exists") + return { idErr, existsErr } +} + +function serializeVariant(v: VariantEntry): [string, Record] { + const cfg: Record = {} + if (v.enableThinking !== undefined) cfg.enable_thinking = v.enableThinking + if (v.thinking !== undefined) cfg.thinking = { type: v.thinking } + if (v.reasoningEffort !== undefined) cfg.reasoningEffort = v.reasoningEffort + if (v.chatTemplateArgs !== undefined) cfg.chat_template_args = { enable_thinking: v.chatTemplateArgs } + return [v.name.trim(), cfg] +} + +function serializeModel(m: ModelEntry): [string, Record] { + const ventries = m.reasoning ? m.variants.filter((v) => v.name.trim()).map(serializeVariant) : [] + const entry: Record = { name: m.name.trim() } + if (m.reasoning) entry.reasoning = true + if (ventries.length > 0) entry.variants = Object.fromEntries(ventries) + return [m.id.trim(), entry] +} + +function resolveEnv(rawEnv: string | undefined, savedEnv: string[] | undefined) { + if (rawEnv) return { env: [rawEnv] } + if (savedEnv) return { env: savedEnv } + return {} +} + export function validateCustomProvider(input: ValidateArgs): ValidateResult { const providerID = input.form.providerID.trim() const name = input.form.name.trim() const baseURL = input.form.baseURL.trim() const apiKey = input.form.apiKey.trim() - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const rawEnv = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() // When editing and apiKey is empty, preserve existing env from the original config - const existingEnv = input.editing && !apiKey ? input.existingEnv : undefined - const key = apiKey && !env ? apiKey : undefined + const savedEnv = input.editing && !apiKey ? input.existingEnv : undefined + const key = apiKey && !rawEnv ? apiKey : undefined - const idError = !providerID - ? input.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? input.t("provider.custom.error.providerID.format") - : undefined + const { idErr, existsErr } = checkProviderID( + providerID, + input.editing, + input.disabledProviders, + input.existingProviderIDs, + input.t, + ) const nameError = !name ? input.t("provider.custom.error.name.required") : undefined const urlError = !baseURL @@ -78,102 +151,32 @@ export function validateCustomProvider(input: ValidateArgs): ValidateResult { ? input.t("provider.custom.error.baseURL.format") : undefined - const disabled = input.disabledProviders.includes(providerID) - const existsError = idError - ? undefined - : input.editing - ? undefined - : input.existingProviderIDs.has(providerID) && !disabled - ? input.t("provider.custom.error.providerID.exists") - : undefined - const seenModels = new Set() - const modelErrors = input.form.models.map((m) => { - const id = m.id.trim() - const modelIdError = !id - ? input.t("provider.custom.error.required") - : seenModels.has(id) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined - const seen = new Set() - const verrs = m.reasoning - ? m.variants.map((v) => { - const n = v.name.trim() - const vErr = !n - ? input.t("provider.custom.error.required") - : seen.has(n) - ? input.t("provider.custom.error.duplicate") - : (() => { - seen.add(n) - return undefined - })() - return { name: vErr } - }) - : [] - return { id: modelIdError, name: modelNameError, variants: verrs } - }) + const modelErrors = input.form.models.map((m) => checkModel(m, seenModels, input.t)) const modelsValid = modelErrors.every((m) => !m.id && !m.name && m.variants.every((v) => !v.name)) - const models = Object.fromEntries( - input.form.models.map((m) => { - const ventries = m.reasoning - ? m.variants - .filter((v) => v.name.trim()) - .map((v) => { - const cfg: Record = {} - if (v.enableThinking !== undefined) cfg.enable_thinking = v.enableThinking - if (v.thinking !== undefined) cfg.thinking = { type: v.thinking } - if (v.reasoningEffort !== undefined) cfg.reasoningEffort = v.reasoningEffort - if (v.chatTemplateArgs !== undefined) cfg.chat_template_args = { enable_thinking: v.chatTemplateArgs } - return [v.name.trim(), cfg] - }) - : [] - const entry: Record = { name: m.name.trim() } - if (m.reasoning) entry.reasoning = true - if (ventries.length > 0) entry.variants = Object.fromEntries(ventries) - return [m.id.trim(), entry] - }), - ) const seenHeaders = new Set() - const headerErrors = input.form.headers.map((h) => { - const hKey = h.key.trim() - const value = h.value.trim() - - if (!hKey && !value) return {} - const keyError = !hKey - ? input.t("provider.custom.error.required") - : seenHeaders.has(hKey.toLowerCase()) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(hKey.toLowerCase()) - return undefined - })() - const valueError = !value ? input.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) + const headerErrors = input.form.headers.map((h) => checkHeader(h, seenHeaders, input.t)) const headersValid = headerErrors.every((h) => !h.key && !h.value) - const headers = Object.fromEntries( - input.form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) const errors: FormErrors = { - providerID: idError ?? existsError, + providerID: idErr ?? existsErr, name: nameError, baseURL: urlError, models: modelErrors, headers: headerErrors, } - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + const ok = !idErr && !existsErr && !nameError && !urlError && modelsValid && headersValid if (!ok) return { errors } + const headers = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + const options = { baseURL, ...(Object.keys(headers).length ? { headers } : {}), @@ -188,9 +191,9 @@ export function validateCustomProvider(input: ValidateArgs): ValidateResult { config: { npm: OPENAI_COMPATIBLE, name, - ...(env ? { env: [env] } : existingEnv ? { env: existingEnv } : {}), + ...resolveEnv(rawEnv, savedEnv), options, - models, + models: Object.fromEntries(input.form.models.map(serializeModel)), }, }, }