fix(provider-options): require manual apply for recommendations (#1152)

Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
This commit is contained in:
MengXi 2026-03-18 14:07:43 -07:00 committed by GitHub
commit d3dc6bdc8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 783 additions and 67 deletions

View file

@ -0,0 +1,4 @@
"@read-frog/extension": patch
---
fix(provider-options): stop auto applying recommended provider options

View file

@ -8,10 +8,11 @@ import { useFieldContext } from "./form-context"
type SelectFieldAutoSaveProps = React.ComponentProps<typeof Select> & {
formForSubmit: { handleSubmit: () => void }
label: React.ReactNode
labelExtra?: React.ReactNode
}
export function SelectFieldAutoSave(
{ formForSubmit, label, ...props }: SelectFieldAutoSaveProps,
{ formForSubmit, label, labelExtra, ...props }: SelectFieldAutoSaveProps,
) {
const field = useFieldContext<string | undefined>()
const errors = useStore(field.store, state => state.meta.errors)
@ -26,9 +27,12 @@ export function SelectFieldAutoSave(
return (
<Field invalid={hasError}>
<FieldLabel nativeLabel={false} render={<div />}>
{label}
</FieldLabel>
<div className="flex items-end justify-between w-full">
<FieldLabel nativeLabel={false} render={<div />}>
{label}
</FieldLabel>
{labelExtra}
</div>
<Select
value={field.state.value}
onValueChange={handleValueChange}

View file

@ -0,0 +1,140 @@
// @vitest-environment jsdom
import type { ReactNode } from "react"
import type { APIProviderConfig } from "@/types/config/provider"
import { act, fireEvent, render, screen } from "@testing-library/react"
import { useEffect, useState } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { updateProviderConfig } from "@/utils/atoms/provider"
import { formOpts, useAppForm } from "../form"
import { ProviderOptionsField } from "../provider-options-field"
vi.mock("#imports", () => ({
i18n: {
t: (key: string) => key,
},
}))
vi.mock("@/components/help-tooltip", () => ({
HelpTooltip: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}))
vi.mock("@/components/ui/json-code-editor", () => ({
JSONCodeEditor: ({
value,
onChange,
placeholder,
}: {
value?: string
onChange?: (value: string) => void
placeholder?: string
}) => (
<textarea
aria-label="provider-options-editor"
value={value}
placeholder={placeholder}
onChange={event => onChange?.(event.target.value)}
/>
),
}))
const baseProviderConfig: APIProviderConfig = {
id: "provider-1",
name: "OpenAI",
enabled: true,
provider: "openai",
model: {
model: "gpt-5-mini",
isCustomModel: false,
customModel: null,
},
providerOptions: undefined,
}
function ProviderOptionsFieldHarness({
initialConfig,
externalProviderOptions,
}: {
initialConfig: APIProviderConfig
externalProviderOptions?: Record<string, unknown>
}) {
const [providerConfig, setProviderConfig] = useState(initialConfig)
const form = useAppForm({
...formOpts,
defaultValues: providerConfig,
onSubmit: async ({ value }) => {
setProviderConfig(value)
},
})
useEffect(() => {
form.reset(providerConfig)
}, [providerConfig, form])
return (
<>
<ProviderOptionsField form={form} />
<button
type="button"
onClick={() => {
if (externalProviderOptions === undefined) {
return
}
setProviderConfig(updateProviderConfig(providerConfig, {
providerOptions: externalProviderOptions,
}) as APIProviderConfig)
}}
>
apply-external
</button>
</>
)
}
describe("providerOptionsField", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it("preserves local formatting when a successful editor save echoes back through the form", async () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
vi.advanceTimersByTime(500)
await Promise.resolve()
})
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
})
it("syncs the editor when an external update arrives, even if the saved value is unchanged", async () => {
const externalProviderOptions = { enableThinking: false }
render(
<ProviderOptionsFieldHarness
initialConfig={{
...baseProviderConfig,
providerOptions: externalProviderOptions,
}}
externalProviderOptions={externalProviderOptions}
/>,
)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.change(editor, { target: { value: "{" } })
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
await Promise.resolve()
})
expect(screen.getByLabelText("provider-options-editor")).toHaveValue(JSON.stringify(externalProviderOptions, null, 2))
})
})

View file

@ -0,0 +1,162 @@
// @vitest-environment jsdom
import type { APIProviderConfig } from "@/types/config/provider"
import { useStore } from "@tanstack/react-form"
import { act, fireEvent, render, screen } from "@testing-library/react"
import { useEffect, useState } from "react"
import { describe, expect, it, vi } from "vitest"
import { formOpts, useAppForm } from "../form"
import { TranslateModelSelector } from "../translate-model-selector"
vi.mock("#imports", () => ({
i18n: {
t: (key: string) => key,
},
}))
vi.mock("../components/provider-options-recommendation-trigger", () => ({
ProviderOptionsRecommendationTrigger: ({
currentProviderOptions,
onApply,
}: {
currentProviderOptions?: Record<string, unknown>
onApply: (options: Record<string, unknown>) => void
}) => (
<div>
<button
type="button"
onClick={() => onApply({ reasoningEffort: "minimal" })}
>
apply-recommendation
</button>
<output aria-label="current-provider-options-prop">
{JSON.stringify(currentProviderOptions ?? null)}
</output>
</div>
),
}))
const duplicateProviderName = "Duplicate provider"
const baseProviderConfig: APIProviderConfig = {
id: "provider-1",
name: "OpenAI",
enabled: true,
provider: "openai",
model: {
model: "gpt-5-mini",
isCustomModel: true,
customModel: "gpt-5-mini",
},
providerOptions: undefined,
}
async function flushUpdates() {
await act(async () => {
await Promise.resolve()
})
}
function TranslateModelSelectorHarness({
initialConfig = baseProviderConfig,
}: {
initialConfig?: APIProviderConfig
}) {
const [providerConfig, setProviderConfig] = useState(initialConfig)
const [submitCount, setSubmitCount] = useState(0)
const form = useAppForm({
...formOpts,
defaultValues: providerConfig,
onSubmit: async ({ value }) => {
setSubmitCount(count => count + 1)
setProviderConfig(value)
},
})
const formValues = useStore(form.store, state => state.values)
useEffect(() => {
form.reset(providerConfig)
}, [providerConfig, form])
return (
<form.AppForm>
<form.AppField
name="name"
validators={{
onChange: ({ value }) => value === duplicateProviderName ? "Duplicate provider name" : undefined,
}}
>
{field => (
<input
aria-label="provider-name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value)
void form.handleSubmit()
}}
/>
)}
</form.AppField>
<TranslateModelSelector form={form} />
<output aria-label="form-name">{formValues.name}</output>
<output aria-label="form-provider-options">{JSON.stringify(formValues.providerOptions ?? null)}</output>
<output aria-label="persisted-name">{providerConfig.name}</output>
<output aria-label="persisted-provider-options">{JSON.stringify(providerConfig.providerOptions ?? null)}</output>
<output aria-label="submit-count">{String(submitCount)}</output>
</form.AppForm>
)
}
describe("translateModelSelector", () => {
it("keeps invalid form values while staging recommended provider options", async () => {
render(<TranslateModelSelectorHarness />)
fireEvent.change(screen.getByLabelText("provider-name"), {
target: { value: duplicateProviderName },
})
await flushUpdates()
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
await flushUpdates()
expect(screen.getByLabelText("provider-name")).toHaveValue(duplicateProviderName)
expect(screen.getByLabelText("form-name")).toHaveTextContent(duplicateProviderName)
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI")
expect(screen.getByLabelText("form-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
expect(screen.getByLabelText("current-provider-options-prop")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("null")
expect(screen.getByLabelText("submit-count")).toHaveTextContent("0")
})
it("persists staged recommendations after the validation error is fixed", async () => {
render(<TranslateModelSelectorHarness />)
fireEvent.change(screen.getByLabelText("provider-name"), {
target: { value: duplicateProviderName },
})
await flushUpdates()
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
await flushUpdates()
fireEvent.change(screen.getByLabelText("provider-name"), {
target: { value: "OpenAI Saved" },
})
await flushUpdates()
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI Saved")
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
expect(screen.getByLabelText("submit-count")).toHaveTextContent("1")
})
it("submits recommendations immediately when the form is valid", async () => {
render(<TranslateModelSelectorHarness />)
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
await flushUpdates()
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI")
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
expect(screen.getByLabelText("submit-count")).toHaveTextContent("1")
})
})

View file

@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ProviderOptionsRecommendationTrigger } from "../provider-options-recommendation-trigger"
vi.mock("#imports", () => ({
i18n: {
t: (key: string) => key,
},
}))
vi.mock("@/components/ui/json-code-editor", () => ({
JSONCodeEditor: ({
value,
placeholder,
}: {
value?: string
placeholder?: string
}) => (
<pre data-testid="provider-options-preview">
{value || placeholder}
</pre>
),
}))
describe("providerOptionsRecommendationTrigger", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it("does not render a trigger when the current model has no recommendation", () => {
render(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
modelId="plain-model"
onApply={vi.fn()}
/>,
)
expect(screen.queryByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
})).not.toBeInTheDocument()
})
it("flashes once when the model starts matching a new recommendation rule", () => {
const { rerender } = render(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
modelId="gpt-5-mini"
onApply={vi.fn()}
/>,
)
const trigger = screen.getByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
})
expect(trigger.className).not.toContain("text-primary")
rerender(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
modelId="gpt-5.1"
onApply={vi.fn()}
/>,
)
expect(trigger.className).toContain("text-primary")
act(() => {
vi.advanceTimersByTime(1400)
})
expect(trigger.className).not.toContain("text-primary")
})
it("shows the recommendation preview and applies it on demand", () => {
const onApply = vi.fn()
render(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
modelId="gpt-5-mini"
onApply={onApply}
/>,
)
fireEvent.click(screen.getByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
}))
expect(screen.getByText("options.apiProviders.form.providerOptionsRecommendationTitle")).toBeInTheDocument()
expect(screen.getByTestId("provider-options-preview")).toHaveTextContent("\"reasoningEffort\": \"minimal\"")
fireEvent.click(screen.getByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationApply",
}))
expect(onApply).toHaveBeenCalledWith({ reasoningEffort: "minimal" })
})
})

View file

@ -0,0 +1,168 @@
import type { JSONValue } from "ai"
import { i18n } from "#imports"
import { Icon } from "@iconify/react"
import { dequal } from "dequal"
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
import { Button } from "@/components/ui/base-ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/base-ui/popover"
import { JSONCodeEditor } from "@/components/ui/json-code-editor"
import { getRecommendedProviderOptionsMatch } from "@/utils/providers/options"
import { cn } from "@/utils/styles/utils"
interface ProviderOptionsRecommendationTriggerProps {
providerId: string
modelId?: string | null
currentProviderOptions?: Record<string, JSONValue>
onApply: (options: Record<string, JSONValue>) => void
}
const FLASH_DURATION_MS = 1400
export function ProviderOptionsRecommendationTrigger({
providerId,
modelId,
currentProviderOptions,
onApply,
}: ProviderOptionsRecommendationTriggerProps) {
const [open, setOpen] = useState(false)
const [isFlashing, setIsFlashing] = useState(false)
const hasMountedRef = useRef(false)
const previousProviderIdRef = useRef(providerId)
const previousMatchIndexRef = useRef<number | undefined>(undefined)
const closePopover = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOpen(false)
})
const startFlashing = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsFlashing(true)
})
const stopFlashing = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsFlashing(false)
})
const recommendation = useMemo(() => {
if (!modelId?.trim()) {
return undefined
}
return getRecommendedProviderOptionsMatch(modelId.trim())
}, [modelId])
const recommendationJson = useMemo(() => {
if (!recommendation) {
return ""
}
return JSON.stringify(recommendation.options, null, 2)
}, [recommendation])
const isApplied = useMemo(() => {
if (!recommendation || !currentProviderOptions) {
return false
}
return dequal(currentProviderOptions, recommendation.options)
}, [currentProviderOptions, recommendation])
useEffect(() => {
if (!recommendation) {
closePopover()
}
}, [recommendation])
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true
previousProviderIdRef.current = providerId
previousMatchIndexRef.current = recommendation?.matchIndex
return
}
if (previousProviderIdRef.current !== providerId) {
previousProviderIdRef.current = providerId
previousMatchIndexRef.current = recommendation?.matchIndex
stopFlashing()
return
}
if (previousMatchIndexRef.current !== recommendation?.matchIndex) {
previousMatchIndexRef.current = recommendation?.matchIndex
if (recommendation) {
startFlashing()
const timeoutId = window.setTimeout(() => {
setIsFlashing(false)
}, FLASH_DURATION_MS)
return () => {
window.clearTimeout(timeoutId)
}
}
stopFlashing()
}
}, [providerId, recommendation])
if (!recommendation) {
return null
}
const handleApply = () => {
onApply(recommendation.options)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={i18n.t("options.apiProviders.form.providerOptionsRecommendationTrigger")}
className={cn(
"text-muted-foreground hover:text-foreground transition-colors duration-700 ease-in-out",
isFlashing && "text-primary",
)}
/>
)}
>
<Icon icon="tabler:sparkles" className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-80 gap-3">
<PopoverHeader>
<PopoverTitle>{i18n.t("options.apiProviders.form.providerOptionsRecommendationTitle")}</PopoverTitle>
<PopoverDescription>{i18n.t("options.apiProviders.form.providerOptionsRecommendationDescription")}</PopoverDescription>
</PopoverHeader>
<JSONCodeEditor
value={recommendationJson}
editable={false}
height="132px"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant={isApplied ? "secondary" : "default"}
disabled={isApplied}
onClick={handleApply}
>
{isApplied
? i18n.t("options.apiProviders.form.providerOptionsRecommendationApplied")
: i18n.t("options.apiProviders.form.providerOptionsRecommendationApply")}
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -1,14 +1,12 @@
import type { APIProviderConfig, LLMProviderConfig } from "@/types/config/provider"
import type { APIProviderConfig } from "@/types/config/provider"
import { i18n } from "#imports"
import { useStore } from "@tanstack/react-form"
import { useEffect, useEffectEvent, useMemo, useState } from "react"
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
import { HelpTooltip } from "@/components/help-tooltip"
import { Field, FieldError, FieldLabel } from "@/components/ui/base-ui/field"
import { JSONCodeEditor } from "@/components/ui/json-code-editor"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { isLLMProviderConfig } from "@/types/config/provider"
import { resolveModelId } from "@/utils/providers/model"
import { getProviderOptions } from "@/utils/providers/options"
import { withForm } from "./form"
function parseJson(input: string): { valid: true, value: Record<string, unknown> | undefined } | { valid: false, error: string } {
@ -29,22 +27,51 @@ export const ProviderOptionsField = withForm({
const providerConfig = useStore(form.store, state => state.values)
const isLLMProvider = isLLMProviderConfig(providerConfig)
const toJson = (options: APIProviderConfig["providerOptions"]) =>
options ? JSON.stringify(options, null, 2) : ""
const toJson = useCallback(
(options: APIProviderConfig["providerOptions"]) => options ? JSON.stringify(options, null, 2) : "",
[],
)
const placeholderText = JSON.stringify({ field: "value" }, null, 2)
const externalJson = toJson(providerConfig.providerOptions)
// Local state for the JSON string input
const [jsonInput, setJsonInput] = useState(() => toJson(providerConfig.providerOptions))
const [jsonInput, setJsonInput] = useState(() => externalJson)
const lastCommittedJsonRef = useRef(externalJson)
const pendingEditorCommitRef = useRef(false)
// Keep editor input in sync when switching to a different provider config.
const syncJsonInput = useEffectEvent(() => {
const syncJsonInput = useEffectEvent((nextJson: string) => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setJsonInput(toJson(providerConfig.providerOptions))
setJsonInput(nextJson)
})
const resetSyncStateForProvider = useEffectEvent(() => {
lastCommittedJsonRef.current = externalJson
pendingEditorCommitRef.current = false
syncJsonInput(externalJson)
})
const readJsonInput = useEffectEvent(() => {
return jsonInput
})
useEffect(() => {
syncJsonInput()
resetSyncStateForProvider()
}, [providerConfig.id])
useEffect(() => {
if (pendingEditorCommitRef.current && externalJson === lastCommittedJsonRef.current) {
pendingEditorCommitRef.current = false
return
}
pendingEditorCommitRef.current = false
lastCommittedJsonRef.current = externalJson
if (readJsonInput() !== externalJson) {
syncJsonInput(externalJson)
}
}, [providerConfig.providerOptions, externalJson])
// Debounce the input value
const debouncedJsonInput = useDebouncedValue(jsonInput, 500)
@ -54,33 +81,17 @@ export const ProviderOptionsField = withForm({
// Submit when debounced value changes and is valid
useEffect(() => {
if (parseResult.valid) {
const normalizedJson = toJson(parseResult.value)
if (normalizedJson === lastCommittedJsonRef.current) {
return
}
lastCommittedJsonRef.current = normalizedJson
pendingEditorCommitRef.current = true
form.setFieldValue("providerOptions", parseResult.value)
void form.handleSubmit()
}
}, [parseResult, form])
const translateModel = useMemo(() => {
if (!isLLMProvider) {
return null
}
const llmConfig = providerConfig as LLMProviderConfig
return resolveModelId(llmConfig.model)
}, [isLLMProvider, providerConfig])
const defaultOptions = useMemo(() => {
if (!isLLMProvider || !translateModel) {
return {}
}
const options = getProviderOptions(translateModel, providerConfig.provider)
return options[providerConfig.provider] || {}
}, [isLLMProvider, translateModel, providerConfig.provider])
const placeholderText = useMemo(() => {
if (Object.keys(defaultOptions).length === 0) {
return "{\n \n}"
}
return JSON.stringify(defaultOptions, null, 2)
}, [defaultOptions])
}, [parseResult, form, toJson])
if (!isLLMProvider) {
return null

View file

@ -7,7 +7,9 @@ import { Checkbox } from "@/components/ui/base-ui/checkbox"
import { SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/base-ui/select"
import { isCustomLLMProviderConfig, isLLMProviderConfig, LLM_PROVIDER_MODELS } from "@/types/config/provider"
import { providerConfigAtom, updateLLMProviderConfig } from "@/utils/atoms/provider"
import { resolveModelId } from "@/utils/providers/model"
import { ModelSuggestionButton } from "./components/model-suggestion-button"
import { ProviderOptionsRecommendationTrigger } from "./components/provider-options-recommendation-trigger"
import { withForm } from "./form"
export const TranslateModelSelector = withForm({
@ -18,8 +20,23 @@ export const TranslateModelSelector = withForm({
if (!isLLMProviderConfig(providerConfig))
return <></>
const modelId = resolveModelId(providerConfig.model)
const { isCustomModel, customModel, model } = providerConfig.model
const applyRecommendedProviderOptions = (options: Record<string, unknown>) => {
form.setFieldValue("providerOptions", options)
void form.handleSubmit()
}
const recommendationTrigger = (
<ProviderOptionsRecommendationTrigger
providerId={providerConfig.id}
modelId={modelId}
currentProviderOptions={providerConfig.providerOptions}
onApply={applyRecommendedProviderOptions}
/>
)
return (
<div>
{isCustomModel
@ -29,15 +46,20 @@ export const TranslateModelSelector = withForm({
<field.InputFieldAutoSave
formForSubmit={form}
label={i18n.t("options.general.translationConfig.model.title")}
labelExtra={isCustomLLMProviderConfig(providerConfig) && (
<ModelSuggestionButton
baseURL={providerConfig.baseURL}
apiKey={providerConfig.apiKey}
onSelect={(model) => {
field.handleChange(model)
void form.handleSubmit()
}}
/>
labelExtra={(
<div className="flex items-center gap-2">
{recommendationTrigger}
{isCustomLLMProviderConfig(providerConfig) && (
<ModelSuggestionButton
baseURL={providerConfig.baseURL}
apiKey={providerConfig.apiKey}
onSelect={(model) => {
field.handleChange(model)
void form.handleSubmit()
}}
/>
)}
</div>
)}
value={customModel ?? ""}
/>
@ -47,7 +69,11 @@ export const TranslateModelSelector = withForm({
: (
<form.AppField name="model.model">
{field => (
<field.SelectFieldAutoSave formForSubmit={form} label={i18n.t("options.general.translationConfig.model.title")}>
<field.SelectFieldAutoSave
formForSubmit={form}
label={i18n.t("options.general.translationConfig.model.title")}
labelExtra={recommendationTrigger}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={i18n.t("options.apiProviders.form.models.translate.placeholder")} />
</SelectTrigger>

View file

@ -135,6 +135,11 @@ options:
providerOptions: Provider Options
providerOptionsHint: Provider-specific options for non-standard API features like thinking/reasoning mode. See the Vercel AI SDK docs or your provider's official documentation for configuration details.
providerOptionsDocsLink: View Provider Docs
providerOptionsRecommendationTrigger: View recommended provider options
providerOptionsRecommendationTitle: Recommended provider options detected
providerOptionsRecommendationDescription: Review and manually apply the recommended configuration.
providerOptionsRecommendationApply: Apply
providerOptionsRecommendationApplied: Applied
invalidJson: Invalid JSON format
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: プロバイダーオプション
providerOptionsHint: 思考/推論モードなどの非標準API機能のためのプロバイダー固有オプション。詳細はVercel AI SDKドキュメントまたはプロバイダーの公式ドキュメントをご覧ください。
providerOptionsDocsLink: プロバイダードキュメントを見る
providerOptionsRecommendationTrigger: 推奨プロバイダーオプションを表示
providerOptionsRecommendationTitle: 推奨プロバイダーオプションが見つかりました
providerOptionsRecommendationDescription: 推奨設定を確認し、手動で適用してください。
providerOptionsRecommendationApply: 適用
providerOptionsRecommendationApplied: 適用済み
invalidJson: 無効なJSON形式
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: 제공자 옵션
providerOptionsHint: 사고/추론 모드와 같은 비표준 API 기능을 위한 공급자별 옵션입니다. 자세한 내용은 Vercel AI SDK 문서 또는 공급자의 공식 문서를 참조하세요.
providerOptionsDocsLink: 공급자 문서 보기
providerOptionsRecommendationTrigger: 추천 제공자 옵션 보기
providerOptionsRecommendationTitle: 추천 제공자 옵션이 감지되었습니다
providerOptionsRecommendationDescription: 추천 구성을 검토한 뒤 수동으로 적용하세요.
providerOptionsRecommendationApply: 적용
providerOptionsRecommendationApplied: 적용됨
invalidJson: 잘못된 JSON 형식
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: Параметры провайдера
providerOptionsHint: Специфические параметры провайдера для нестандартных функций API, таких как режим размышления/рассуждения. Подробнее см. документацию Vercel AI SDK или официальную документацию вашего провайдера.
providerOptionsDocsLink: Документация провайдера
providerOptionsRecommendationTrigger: Показать рекомендуемые параметры провайдера
providerOptionsRecommendationTitle: Обнаружены рекомендуемые параметры провайдера
providerOptionsRecommendationDescription: Проверьте рекомендованную конфигурацию и примените её вручную.
providerOptionsRecommendationApply: Применить
providerOptionsRecommendationApplied: Применено
invalidJson: Неверный формат JSON
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: Sağlayıcı Seçenekleri
providerOptionsHint: Düşünme/akıl yürütme modu gibi standart dışı API özellikleri için sağlayıcıya özel seçenekler. Ayrıntılar için Vercel AI SDK belgelerine veya sağlayıcınızın resmi belgelerine bakın.
providerOptionsDocsLink: Sağlayıcı Belgelerini Görüntüle
providerOptionsRecommendationTrigger: Önerilen sağlayıcı seçeneklerini görüntüle
providerOptionsRecommendationTitle: Önerilen sağlayıcı seçenekleri bulundu
providerOptionsRecommendationDescription: Önerilen yapılandırmayı gözden geçirip manuel olarak uygulayın.
providerOptionsRecommendationApply: Uygula
providerOptionsRecommendationApplied: Uygulandı
invalidJson: Geçersiz JSON formatı
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: Tùy chọn nhà cung cấp
providerOptionsHint: Các tùy chọn dành riêng cho nhà cung cấp cho các tính năng API không chuẩn như chế độ suy nghĩ/lập luận. Xem tài liệu Vercel AI SDK hoặc tài liệu chính thức của nhà cung cấp để biết chi tiết cấu hình.
providerOptionsDocsLink: Xem Tài liệu Nhà cung cấp
providerOptionsRecommendationTrigger: Xem tùy chọn nhà cung cấp được đề xuất
providerOptionsRecommendationTitle: Đã phát hiện tùy chọn nhà cung cấp được đề xuất
providerOptionsRecommendationDescription: Hãy xem lại và tự áp dụng cấu hình được đề xuất.
providerOptionsRecommendationApply: Áp dụng
providerOptionsRecommendationApplied: Đã áp dụng
invalidJson: Định dạng JSON không hợp lệ
providers:
description:

View file

@ -135,6 +135,11 @@ options:
providerOptions: 提供商选项
providerOptionsHint: 用于非标准 API 功能(如思考/推理模式)的提供商特定选项。详情请参阅 Vercel AI SDK 文档或您的提供商官方文档。
providerOptionsDocsLink: 查看提供商文档
providerOptionsRecommendationTrigger: 查看推荐的提供商选项
providerOptionsRecommendationTitle: 检测到推荐的提供商选项
providerOptionsRecommendationDescription: 请先检查,再手动应用推荐配置。
providerOptionsRecommendationApply: 应用
providerOptionsRecommendationApplied: 已应用
invalidJson: JSON 格式无效
providers:
description:

View file

@ -134,6 +134,11 @@ options:
providerOptions: 提供者選項
providerOptionsHint: 用於非標準 API 功能(如思考/推理模式)的提供者特定選項。詳情請參閱 Vercel AI SDK 文件或您的提供者官方文件。
providerOptionsDocsLink: 查看提供者文件
providerOptionsRecommendationTrigger: 查看推薦的提供者選項
providerOptionsRecommendationTitle: 偵測到推薦的提供者選項
providerOptionsRecommendationDescription: 請先檢查,再手動套用推薦設定。
providerOptionsRecommendationApply: 套用
providerOptionsRecommendationApplied: 已套用
invalidJson: JSON 格式無效
providers:
description:

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"
import { getProviderOptions } from "../../providers/options"
import {
getProviderOptions,
getProviderOptionsWithOverride,
getRecommendedProviderOptionsMatch,
} from "../../providers/options"
describe("getProviderOptions", () => {
describe("model pattern matching", () => {
@ -101,4 +105,36 @@ describe("getProviderOptions", () => {
expect(end.openaiCompatible).toBeUndefined()
})
})
describe("user provider option overrides", () => {
it("should treat an explicit empty object as cleared provider options", () => {
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba", {})
expect(options).toBeUndefined()
})
it("should not fall back to recommendations when user options are undefined", () => {
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba")
expect(options).toBeUndefined()
})
it("should use user options as-is without merging matched defaults", () => {
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba", { foo: "bar" })
expect(options).toEqual({ alibaba: { foo: "bar" } })
})
})
describe("recommendation metadata", () => {
it("should expose the matched rule index for UI suggestion state", () => {
const gpt5Match = getRecommendedProviderOptionsMatch("gpt-5-mini")
const gpt51Match = getRecommendedProviderOptionsMatch("gpt-5.1")
expect(gpt5Match?.matchIndex).toBeTypeOf("number")
expect(gpt51Match?.matchIndex).toBeTypeOf("number")
expect(gpt5Match?.matchIndex).not.toBe(gpt51Match?.matchIndex)
})
it("should return undefined for models without recommendations", () => {
expect(getRecommendedProviderOptionsMatch("plain-model")).toBeUndefined()
})
})
})

View file

@ -109,7 +109,7 @@ export const LLM_MODEL_OPTIONS: Array<{
// Qwen/QwQ models - disable thinking
{
pattern: /^qwen/,
pattern: /^(Qwen|qwen)/,
options: { enableThinking: false },
},
]

View file

@ -1,37 +1,57 @@
import type { JSONValue } from "ai"
import { LLM_MODEL_OPTIONS } from "../constants/models"
export interface RecommendedProviderOptionsMatch {
matchIndex: number
options: Record<string, JSONValue>
}
/**
* Get provider options for AI SDK generateText calls.
* Matches model name against patterns and returns options for the current provider.
* Detect the recommended provider options for a given model.
* First match wins - more specific patterns should be placed first in MODEL_OPTIONS.
*/
export function getRecommendedProviderOptionsMatch(model: string): RecommendedProviderOptionsMatch | undefined {
for (const [matchIndex, { pattern, options }] of LLM_MODEL_OPTIONS.entries()) {
if (pattern.test(model)) {
return { matchIndex, options }
}
}
}
/**
* Get the recommended provider options payload without wrapping it by provider id.
*/
export function getRecommendedProviderOptions(model: string): Record<string, JSONValue> | undefined {
return getRecommendedProviderOptionsMatch(model)?.options
}
/**
* Wrap a recommendation for the AI SDK request shape.
*/
export function getProviderOptions(
model: string,
provider: string,
): Record<string, Record<string, JSONValue>> {
for (const { pattern, options } of LLM_MODEL_OPTIONS) {
if (pattern.test(model)) {
return { [provider]: options }
}
const options = getRecommendedProviderOptions(model)
if (!options) {
return {}
}
return {}
return { [provider]: options }
}
/**
* Get provider options for AI SDK generateText calls.
* If user-defined options exist, use them directly (no merge).
* Otherwise fall back to default pattern-matched options.
* Get provider options for AI SDK generateText calls using only user-saved overrides.
* Recommendations stay in the UI until the user explicitly applies and saves them.
*/
export function getProviderOptionsWithOverride(
model: string,
_model: string,
provider: string,
userOptions?: Record<string, JSONValue>,
): Record<string, Record<string, JSONValue>> {
// User options completely override defaults
if (userOptions && Object.keys(userOptions).length > 0) {
return { [provider]: userOptions }
): Record<string, Record<string, JSONValue>> | undefined {
if (!userOptions || Object.keys(userOptions).length === 0) {
return undefined
}
return getProviderOptions(model, provider)
return { [provider]: userOptions }
}