fix(cron): preserve custom provider model choices

(cherry picked from commit c784dabdc3adb3573c8870d10b2a6db70e789e0f)
This commit is contained in:
fangliquan
2026-09-20 12:14:21 +08:00
committed by Teknium
parent 29414875c0
commit 46e1e49d30
3 changed files with 65 additions and 21 deletions

View File

@@ -2,14 +2,27 @@ import { describe, expect, it } from 'vitest'
import {
cronEditorUpdates,
cronModelChoiceValue,
jobIsScriptOnly,
lastErrorSummary,
parseCronDeliveryTargets,
parseCronModelChoiceValue,
toggleCronDeliveryTarget,
validateCronEditor
} from './cron-job-model'
import { nextRunOverdueMs } from './job-state'
describe('cron model choice values', () => {
it('round-trips provider and model colons without ambiguous pairs', () => {
const customProvider = cronModelChoiceValue('custom:internlm', 'intern-latest')
const colonModel = cronModelChoiceValue('custom', 'internlm:intern-latest')
expect(customProvider).not.toBe(colonModel)
expect(parseCronModelChoiceValue(customProvider)).toEqual({ provider: 'custom:internlm', model: 'intern-latest' })
expect(parseCronModelChoiceValue(colonModel)).toEqual({ provider: 'custom', model: 'internlm:intern-latest' })
})
})
describe('jobIsScriptOnly', () => {
it('is true when no_agent is set and a script is present', () => {
expect(jobIsScriptOnly({ no_agent: true, script: 'echo hi' })).toBe(true)

View File

@@ -45,6 +45,35 @@ export interface CronEditorSaveValues {
schedule: string
}
export interface CronModelChoice {
model: string
provider: string
}
/** Encode the provider/model pair as an opaque Select value without delimiter ambiguity. */
export function cronModelChoiceValue(provider: string, model: string): string {
return JSON.stringify([provider, model])
}
export function parseCronModelChoiceValue(value: string): CronModelChoice | null {
try {
const parsed: unknown = JSON.parse(value)
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== 'string' ||
typeof parsed[1] !== 'string'
) {
return null
}
return { provider: parsed[0], model: parsed[1] }
} catch {
return null
}
}
export function parseCronDeliveryTargets(value: string): string[] {
const targets = value
.split(',')

View File

@@ -77,9 +77,11 @@ import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, init
import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions'
import {
cronEditorUpdates,
cronModelChoiceValue,
jobIsScriptOnly,
lastErrorSummary,
parseCronDeliveryTargets,
parseCronModelChoiceValue,
toggleCronDeliveryTarget,
validateCronEditor
} from './cron-job-model'
@@ -1050,8 +1052,8 @@ function CronEditorDialog({
const [schedule, setSchedule] = useState('')
const [schedulePreset, setSchedulePreset] = useState('daily')
const [deliver, setDeliver] = useState(DEFAULT_DELIVER)
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
// Per-job model override encoded as an opaque provider/model pair.
// MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
// cron fields; the backend renders the prompt + schedule from them.
@@ -1106,7 +1108,9 @@ function CronEditorDialog({
setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? ''))
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setModelChoice(
initial && jobModel(initial) ? cronModelChoiceValue(jobProvider(initial), jobModel(initial)) : MODEL_DEFAULT_VALUE
)
setSlotValues({})
setTemplateChoice(editor.mode === 'create' ? (editor.blueprintKey ?? CUSTOM_TEMPLATE) : CUSTOM_TEMPLATE)
setError(null)
@@ -1149,7 +1153,9 @@ function CronEditorDialog({
// stored pin visible and re-selectable rather than silently dropping it.
const modelChoiceKnown =
modelChoice === MODEL_DEFAULT_VALUE ||
modelProviders.some(provider => (provider.models ?? []).some(model => `${provider.slug}:${model}` === modelChoice))
modelProviders.some(provider =>
(provider.models ?? []).some(model => cronModelChoiceValue(provider.slug, model) === modelChoice)
)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -1172,11 +1178,7 @@ function CronEditorDialog({
return
}
// Decode `${providerSlug}:${model}` — the model half may itself contain
// ':' (e.g. openrouter 'anthropic/claude-sonnet-4:beta'), so split once.
const overrideIndex = modelChoice === MODEL_DEFAULT_VALUE ? -1 : modelChoice.indexOf(':')
const overrideProvider = overrideIndex >= 0 ? modelChoice.slice(0, overrideIndex) : ''
const overrideModel = overrideIndex >= 0 ? modelChoice.slice(overrideIndex + 1) : ''
const override = parseCronModelChoiceValue(modelChoice)
setSaving(true)
setError(null)
@@ -1184,10 +1186,10 @@ function CronEditorDialog({
try {
await onSave({
deliver,
model: overrideModel,
model: override?.model ?? '',
name: name.trim(),
prompt: prompt.trim(),
provider: overrideProvider,
provider: override?.provider ?? '',
schedule: schedule.trim()
})
} catch (err) {
@@ -1357,21 +1359,21 @@ function CronEditorDialog({
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
{parseCronModelChoiceValue(modelChoice)?.model ?? modelChoice}
</SelectItem>
)}
{modelProviders.map(provider => (
<SelectGroup key={provider.slug}>
<SelectLabel>{provider.name}</SelectLabel>
{(provider.models ?? []).map(model => (
<SelectItem
className="font-mono"
key={`${provider.slug}:${model}`}
value={`${provider.slug}:${model}`}
>
{model}
</SelectItem>
))}
{(provider.models ?? []).map(model => {
const value = cronModelChoiceValue(provider.slug, model)
return (
<SelectItem className="font-mono" key={value} value={value}>
{model}
</SelectItem>
)
})}
</SelectGroup>
))}
</SelectContent>