* feat(web,api): add fetch models and saved catalog support Split from PR #2752 (part 2 of 3). Backend: - /api/models/catalog endpoint for browsing remote model catalogs - /api/models/fetch endpoint for fetching available models from providers - Credential reuse with provider/API base matching for security - Default API base resolution for providers without explicit base Frontend: - FetchModelsDialog for importing models from remote providers - CatalogDialog for browsing and importing from model catalogs - Static import for FetchModelsDialog (replaces dynamic import from PR1) - Dynamic import retained for TestModelDialog (PR3 territory) * fix(web,api): support bare-array responses in fetchOpenAICompatibleModels * fix(web,api): tighten maskAPIKeyValue to match maskAPIKey policy For 9-12 character keys, maskAPIKeyValue exposed first 4 + last 4 chars (only 1 char masked for a 9-char key). Now uses the same policy as maskAPIKey: first 3 + last 2 for 9-12 chars, first 3 + last 4 for longer keys. Adds tests covering all key length boundaries.
764 lines
24 KiB
TypeScript
764 lines
24 KiB
TypeScript
import {
|
|
IconDownload,
|
|
IconLoader2,
|
|
IconPlugConnected,
|
|
} from "@tabler/icons-react"
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
import {
|
|
type ModelProviderOption,
|
|
addModel,
|
|
getCatalogs,
|
|
setDefaultModel,
|
|
} from "@/api/models"
|
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
|
import {
|
|
AdvancedSection,
|
|
Field,
|
|
KeyInput,
|
|
SwitchCardField,
|
|
} from "@/components/shared-form"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
|
import { refreshGatewayState } from "@/store/gateway"
|
|
|
|
import { FetchModelsDialog } from "./fetch-models-dialog"
|
|
import { type FieldValidation, validateModelField } from "./model-validation"
|
|
import { ProviderCombobox } from "./provider-combobox"
|
|
import { getProviderKey } from "./provider-label"
|
|
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry"
|
|
|
|
interface AddForm {
|
|
modelName: string
|
|
provider: string
|
|
model: string
|
|
apiBase: string
|
|
apiKey: string
|
|
proxy: string
|
|
authMethod: string
|
|
connectMode: string
|
|
workspace: string
|
|
rpm: string
|
|
maxTokensField: string
|
|
requestTimeout: string
|
|
thinkingLevel: string
|
|
toolSchemaTransform: string
|
|
extraBody: string
|
|
customHeaders: string
|
|
}
|
|
|
|
const EMPTY_ADD_FORM: AddForm = {
|
|
modelName: "",
|
|
provider: "",
|
|
model: "",
|
|
apiBase: "",
|
|
apiKey: "",
|
|
proxy: "",
|
|
authMethod: "",
|
|
connectMode: "",
|
|
workspace: "",
|
|
rpm: "",
|
|
maxTokensField: "",
|
|
requestTimeout: "",
|
|
thinkingLevel: "",
|
|
toolSchemaTransform: "",
|
|
extraBody: "",
|
|
customHeaders: "",
|
|
}
|
|
|
|
function normalizeApiBase(value: string): string {
|
|
return value.trim().replace(/\/+$/, "")
|
|
}
|
|
|
|
function getNextApiBaseForProviderChange(
|
|
currentApiBase: string,
|
|
currentProvider: string,
|
|
nextProvider: string,
|
|
): string {
|
|
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
|
|
const currentDefaultApiBase = normalizeApiBase(
|
|
PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "",
|
|
)
|
|
const nextDefaultApiBase =
|
|
PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? ""
|
|
|
|
if (!normalizedCurrentApiBase) {
|
|
return nextDefaultApiBase
|
|
}
|
|
|
|
if (
|
|
normalizedCurrentApiBase &&
|
|
currentDefaultApiBase &&
|
|
normalizedCurrentApiBase === currentDefaultApiBase
|
|
) {
|
|
return nextDefaultApiBase
|
|
}
|
|
|
|
return currentApiBase
|
|
}
|
|
|
|
interface AddModelSheetProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
existingModelNames: string[]
|
|
providerOptions?: ModelProviderOption[]
|
|
}
|
|
|
|
export function AddModelSheet({
|
|
open,
|
|
onClose,
|
|
onSaved,
|
|
existingModelNames,
|
|
providerOptions,
|
|
}: AddModelSheetProps) {
|
|
const { t } = useTranslation()
|
|
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
|
const [saving, setSaving] = useState(false)
|
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
|
const [fieldErrors, setFieldErrors] = useState<
|
|
Partial<Record<keyof AddForm, string>>
|
|
>({})
|
|
const [serverError, setServerError] = useState("")
|
|
const [modelValidation, setModelValidation] =
|
|
useState<FieldValidation | null>(null)
|
|
const [fetchOpen, setFetchOpen] = useState(false)
|
|
const [testOpen, setTestOpen] = useState(false)
|
|
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
|
const [catalogModels, setCatalogModels] = useState<string[]>([])
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Dynamic import for TestModelDialog (added in PR3)
|
|
const [TestModelDialogComp, setTestModelDialogComp] = useState<React.ComponentType<{
|
|
model: unknown; open: boolean; onClose: () => void;
|
|
inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string };
|
|
}> | null>(null)
|
|
useEffect(() => {
|
|
import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {})
|
|
}, [])
|
|
|
|
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
|
form.apiKey,
|
|
t("models.field.apiKeyPlaceholder"),
|
|
)
|
|
const isDirty =
|
|
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setForm(EMPTY_ADD_FORM)
|
|
setSetAsDefault(false)
|
|
setFieldErrors({})
|
|
setServerError("")
|
|
setModelValidation(null)
|
|
setFetchedModels([])
|
|
setCatalogModels([])
|
|
}
|
|
}, [open])
|
|
|
|
// Load catalog models when provider or apiBase changes
|
|
useEffect(() => {
|
|
const providerKey = getProviderKey(form.provider || undefined)
|
|
const apiBase = form.apiBase.trim().replace(/\/+$/, "")
|
|
if (!form.provider.trim()) {
|
|
setCatalogModels([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
getCatalogs()
|
|
.then((res) => {
|
|
if (cancelled) return
|
|
const matched = (res.entries || []).filter((e) => {
|
|
const ep = getProviderKey(e.provider || undefined)
|
|
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
|
|
return ep === providerKey && eb === apiBase
|
|
})
|
|
const ids = matched.flatMap((e) => e.models.map((m) => m.id))
|
|
const unique = [...new Set(ids)]
|
|
setCatalogModels(unique)
|
|
})
|
|
.catch(() => {})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [form.provider, form.apiBase])
|
|
|
|
const validate = (): boolean => {
|
|
const errors: Partial<Record<keyof AddForm, string>> = {}
|
|
const modelName = form.modelName.trim()
|
|
if (!modelName) {
|
|
errors.modelName = t("models.add.errorRequired")
|
|
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
|
errors.modelName = t("models.add.errorDuplicateModelName")
|
|
}
|
|
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
|
if (modelValidation?.level === "error") {
|
|
errors.model = t(
|
|
modelValidation.messageKey,
|
|
modelValidation.messageParams,
|
|
)
|
|
}
|
|
setFieldErrors(errors)
|
|
return Object.keys(errors).length === 0
|
|
}
|
|
|
|
const setField =
|
|
(key: keyof AddForm) =>
|
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
setForm((f) => ({ ...f, [key]: e.target.value }))
|
|
if (fieldErrors[key]) {
|
|
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
|
}
|
|
}
|
|
|
|
const debouncedValidateModel = useCallback(
|
|
(value: string, provider: string) => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
debounceRef.current = setTimeout(() => {
|
|
const result = validateModelField(value, provider || undefined)
|
|
setModelValidation(result)
|
|
}, 300)
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value
|
|
setForm((f) => ({ ...f, model: value }))
|
|
if (fieldErrors.model) {
|
|
setFieldErrors((prev) => ({ ...prev, model: undefined }))
|
|
}
|
|
debouncedValidateModel(value, form.provider)
|
|
}
|
|
|
|
const handleProviderChange = (provider: string) => {
|
|
setForm((f) => {
|
|
return {
|
|
...f,
|
|
provider,
|
|
apiBase: getNextApiBaseForProviderChange(
|
|
f.apiBase,
|
|
f.provider,
|
|
provider,
|
|
),
|
|
}
|
|
})
|
|
// Re-validate model with new provider context
|
|
if (form.model) {
|
|
debouncedValidateModel(form.model, provider)
|
|
}
|
|
// Clear setAsDefault if the new provider doesn't support being default
|
|
const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false
|
|
if (!allowed) {
|
|
setSetAsDefault(false)
|
|
}
|
|
}
|
|
|
|
const applyFix = () => {
|
|
if (modelValidation?.fix) {
|
|
setForm((f) => ({ ...f, model: modelValidation.fix! }))
|
|
setModelValidation(null)
|
|
}
|
|
}
|
|
|
|
const handleCommonModel = (modelId: string) => {
|
|
setForm((f) => ({ ...f, model: modelId }))
|
|
setModelValidation(null)
|
|
if (fieldErrors.model) {
|
|
setFieldErrors((prev) => ({ ...prev, model: undefined }))
|
|
}
|
|
}
|
|
|
|
const handleFetchFill = (models: string[]) => {
|
|
setFetchedModels(models)
|
|
if (models.length >= 1) {
|
|
setForm((f) => ({ ...f, model: models[0] }))
|
|
setModelValidation(null)
|
|
if (fieldErrors.model) {
|
|
setFieldErrors((prev) => ({ ...prev, model: undefined }))
|
|
}
|
|
}
|
|
}
|
|
|
|
const providerDef = PROVIDER_MAP.get(form.provider)
|
|
const commonModels = providerDef?.commonModels || []
|
|
const defaultModelAllowed = form.provider
|
|
? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false)
|
|
: false
|
|
|
|
const handleSave = async () => {
|
|
if (!validate()) return
|
|
|
|
let extraBody: Record<string, unknown> | undefined
|
|
let customHeaders: Record<string, string> | undefined
|
|
try {
|
|
if (form.extraBody.trim()) {
|
|
extraBody = JSON.parse(form.extraBody.trim())
|
|
} else {
|
|
extraBody = {}
|
|
}
|
|
} catch {
|
|
setServerError(
|
|
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
|
|
)
|
|
return
|
|
}
|
|
try {
|
|
if (form.customHeaders.trim()) {
|
|
customHeaders = JSON.parse(form.customHeaders.trim())
|
|
} else {
|
|
customHeaders = {}
|
|
}
|
|
} catch {
|
|
setServerError(
|
|
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
|
|
)
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
setServerError("")
|
|
try {
|
|
const modelName = form.modelName.trim()
|
|
const provider = form.provider.trim()
|
|
const modelId = form.model.trim()
|
|
await addModel({
|
|
model_name: modelName,
|
|
provider: provider || undefined,
|
|
model: modelId,
|
|
api_base: form.apiBase.trim() || undefined,
|
|
api_key: form.apiKey.trim() || undefined,
|
|
proxy: form.proxy.trim() || undefined,
|
|
auth_method: form.authMethod.trim() || undefined,
|
|
connect_mode: form.connectMode.trim() || undefined,
|
|
workspace: form.workspace.trim() || undefined,
|
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
|
max_tokens_field: form.maxTokensField.trim() || undefined,
|
|
request_timeout: form.requestTimeout
|
|
? Number(form.requestTimeout)
|
|
: undefined,
|
|
thinking_level: form.thinkingLevel.trim() || undefined,
|
|
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
|
extra_body: extraBody,
|
|
custom_headers: customHeaders,
|
|
})
|
|
if (setAsDefault) {
|
|
await setDefaultModel(modelName)
|
|
}
|
|
const gateway = await refreshGatewayState({ force: true })
|
|
showSaveSuccessOrRestartToast(
|
|
t,
|
|
t("models.add.saveSuccess"),
|
|
modelName,
|
|
gateway?.restartRequired === true,
|
|
)
|
|
onSaved()
|
|
onClose()
|
|
} catch (e) {
|
|
setServerError(e instanceof Error ? e.message : t("models.add.saveError"))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<SheetContent
|
|
side="right"
|
|
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
|
>
|
|
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
|
<SheetTitle className="text-base">
|
|
{t("models.add.title")}
|
|
</SheetTitle>
|
|
<SheetDescription className="text-xs">
|
|
{t("models.add.description")}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
|
<div className="space-y-5 px-6 py-5">
|
|
<Field
|
|
label={t("models.add.modelName")}
|
|
hint={t("models.add.modelNameHint")}
|
|
>
|
|
<Input
|
|
value={form.modelName}
|
|
onChange={setField("modelName")}
|
|
placeholder={t("models.add.modelNamePlaceholder")}
|
|
aria-invalid={!!fieldErrors.modelName}
|
|
/>
|
|
{fieldErrors.modelName && (
|
|
<p className="text-destructive text-xs">
|
|
{fieldErrors.modelName}
|
|
</p>
|
|
)}
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.provider")}
|
|
hint={t("models.field.providerHint")}
|
|
>
|
|
<ProviderCombobox
|
|
value={form.provider}
|
|
onChange={handleProviderChange}
|
|
placeholder={t("models.field.providerPlaceholder")}
|
|
backendOptions={providerOptions}
|
|
filterCreateAllowed
|
|
containerRef={scrollContainerRef}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.add.modelId")}
|
|
hint={t("models.add.modelIdHint")}
|
|
>
|
|
<Input
|
|
value={form.model}
|
|
onChange={handleModelChange}
|
|
placeholder={
|
|
providerDef
|
|
? `${commonModels[0] || "model-name"}`
|
|
: t("models.add.modelIdPlaceholder")
|
|
}
|
|
className="font-mono text-sm"
|
|
aria-invalid={
|
|
!!fieldErrors.model || modelValidation?.level === "error"
|
|
}
|
|
/>
|
|
{modelValidation && modelValidation.messageKey && (
|
|
<div
|
|
className={`flex items-center gap-2 text-xs ${
|
|
modelValidation.level === "error"
|
|
? "text-destructive"
|
|
: modelValidation.level === "warning"
|
|
? "text-yellow-600 dark:text-yellow-500"
|
|
: "text-green-600 dark:text-green-500"
|
|
}`}
|
|
>
|
|
<span>
|
|
{t(
|
|
modelValidation.messageKey,
|
|
modelValidation.messageParams,
|
|
)}
|
|
</span>
|
|
{modelValidation.fix && (
|
|
<button
|
|
type="button"
|
|
onClick={applyFix}
|
|
className="text-primary underline hover:no-underline"
|
|
>
|
|
{t("common.fix")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{fieldErrors.model && !modelValidation && (
|
|
<p className="text-destructive text-xs">
|
|
{fieldErrors.model}
|
|
</p>
|
|
)}
|
|
{commonModels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{commonModels.map((m) => (
|
|
<Badge
|
|
key={m}
|
|
variant="secondary"
|
|
className="hover:bg-secondary/80 cursor-pointer font-mono text-xs"
|
|
onClick={() => handleCommonModel(m)}
|
|
>
|
|
{m}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
{catalogModels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{catalogModels.map((m) => (
|
|
<Badge
|
|
key={m}
|
|
variant={form.model === m ? "default" : "outline"}
|
|
className="cursor-pointer font-mono text-xs"
|
|
onClick={() => handleCommonModel(m)}
|
|
>
|
|
{m}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
{fetchedModels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{fetchedModels.map((m) => (
|
|
<Badge
|
|
key={m}
|
|
variant={form.model === m ? "default" : "outline"}
|
|
className="cursor-pointer font-mono text-xs"
|
|
onClick={() => handleCommonModel(m)}
|
|
>
|
|
{m}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setFetchOpen(true)}
|
|
>
|
|
<IconDownload className="size-3" />
|
|
{t("models.fetch.title")}
|
|
</Button>
|
|
)}
|
|
{!form.provider && (
|
|
<span className="text-muted-foreground text-xs">
|
|
{t("models.field.selectProviderFirst")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
|
|
<Field label={t("models.field.apiKey")}>
|
|
<KeyInput
|
|
value={form.apiKey}
|
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
|
placeholder={apiKeyPlaceholder}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t("models.field.apiBase")}>
|
|
<Input
|
|
value={form.apiBase}
|
|
onChange={setField("apiBase")}
|
|
placeholder="https://api.example.com/v1"
|
|
/>
|
|
</Field>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setTestOpen(true)}
|
|
disabled={!form.provider || !form.model || !TestModelDialogComp}
|
|
>
|
|
<IconPlugConnected className="size-4" />
|
|
{t("models.test.testConnection")}
|
|
</Button>
|
|
</div>
|
|
|
|
<SwitchCardField
|
|
label={t("models.defaultOnSave.label")}
|
|
hint={
|
|
!defaultModelAllowed && form.provider
|
|
? t("models.defaultOnSave.unsupportedProvider")
|
|
: t("models.defaultOnSave.description")
|
|
}
|
|
checked={setAsDefault}
|
|
onCheckedChange={setSetAsDefault}
|
|
disabled={!defaultModelAllowed}
|
|
/>
|
|
|
|
<AdvancedSection>
|
|
<Field
|
|
label={t("models.field.proxy")}
|
|
hint={t("models.field.proxyHint")}
|
|
>
|
|
<Input
|
|
value={form.proxy}
|
|
onChange={setField("proxy")}
|
|
placeholder="http://127.0.0.1:7890"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.authMethod")}
|
|
hint={t("models.field.authMethodHint")}
|
|
>
|
|
<Input
|
|
value={form.authMethod}
|
|
onChange={setField("authMethod")}
|
|
placeholder="oauth"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.connectMode")}
|
|
hint={t("models.field.connectModeHint")}
|
|
>
|
|
<Input
|
|
value={form.connectMode}
|
|
onChange={setField("connectMode")}
|
|
placeholder="stdio"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.workspace")}
|
|
hint={t("models.field.workspaceHint")}
|
|
>
|
|
<Input
|
|
value={form.workspace}
|
|
onChange={setField("workspace")}
|
|
placeholder="/path/to/workspace"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.requestTimeout")}
|
|
hint={t("models.field.requestTimeoutHint")}
|
|
>
|
|
<Input
|
|
value={form.requestTimeout}
|
|
onChange={setField("requestTimeout")}
|
|
placeholder="60"
|
|
type="number"
|
|
min={0}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.rpm")}
|
|
hint={t("models.field.rpmHint")}
|
|
>
|
|
<Input
|
|
value={form.rpm}
|
|
onChange={setField("rpm")}
|
|
placeholder="60"
|
|
type="number"
|
|
min={0}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.thinkingLevel")}
|
|
hint={t("models.field.thinkingLevelHint")}
|
|
>
|
|
<Input
|
|
value={form.thinkingLevel}
|
|
onChange={setField("thinkingLevel")}
|
|
placeholder="off"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.maxTokensField")}
|
|
hint={t("models.field.maxTokensFieldHint")}
|
|
>
|
|
<Input
|
|
value={form.maxTokensField}
|
|
onChange={setField("maxTokensField")}
|
|
placeholder="max_completion_tokens"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.toolSchemaTransform")}
|
|
hint={t("models.field.toolSchemaTransformHint")}
|
|
>
|
|
<Input
|
|
value={form.toolSchemaTransform}
|
|
onChange={setField("toolSchemaTransform")}
|
|
placeholder="google"
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.extraBody")}
|
|
hint={t("models.field.extraBodyHint")}
|
|
>
|
|
<Textarea
|
|
value={form.extraBody}
|
|
onChange={setField("extraBody")}
|
|
placeholder='{"key": "value"}'
|
|
rows={3}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label={t("models.field.customHeaders")}
|
|
hint={t("models.field.customHeadersHint")}
|
|
>
|
|
<Textarea
|
|
value={form.customHeaders}
|
|
onChange={setField("customHeaders")}
|
|
placeholder='{"X-Source": "coding-plan"}'
|
|
rows={3}
|
|
/>
|
|
</Field>
|
|
</AdvancedSection>
|
|
|
|
{serverError && (
|
|
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
|
{serverError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
|
{isDirty && (
|
|
<ConfigChangeNotice
|
|
kind="save"
|
|
title={t("common.saveChangesTitle")}
|
|
description={t("models.unsavedPrompt")}
|
|
/>
|
|
)}
|
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={
|
|
!isDirty || saving || modelValidation?.level === "error"
|
|
}
|
|
>
|
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
|
{t("models.add.confirm")}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
|
|
<FetchModelsDialog
|
|
open={fetchOpen}
|
|
onClose={() => setFetchOpen(false)}
|
|
onFill={handleFetchFill}
|
|
provider={form.provider}
|
|
apiKey={form.apiKey}
|
|
apiBase={form.apiBase}
|
|
/>
|
|
|
|
{TestModelDialogComp && (
|
|
<TestModelDialogComp
|
|
model={null}
|
|
open={testOpen}
|
|
onClose={() => setTestOpen(false)}
|
|
inlineParams={{
|
|
provider: form.provider,
|
|
model: form.model,
|
|
apiBase: form.apiBase,
|
|
apiKey: form.apiKey,
|
|
authMethod: form.authMethod,
|
|
}}
|
|
/>
|
|
)}
|
|
</Sheet>
|
|
</>
|
|
)
|
|
}
|