fix(cron): preserve custom provider model choices
(cherry picked from commit c784dabdc3adb3573c8870d10b2a6db70e789e0f)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user