Normalize doubled provider/model routes on cron create and fire.

Agents often pass zte/Qwen3-… as both provider and model; split combined routes so scheduled runs resolve a real model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 21:46:16 +08:00
parent 3ce60b200c
commit 4472a9c74b
5 changed files with 95 additions and 15 deletions

View file

@ -36,6 +36,7 @@ import {
listJobs,
normalizeSettings,
removeJob,
splitProviderModel,
storePath,
upsertJob,
} from './store.js'
@ -1832,8 +1833,9 @@ export async function listPresetChoices(ctx) {
}
export async function resolveJobModel(ctx, job) {
const provider = typeof job?.provider === 'string' ? job.provider.trim() : ''
const model = typeof job?.model === 'string' ? job.model.trim() : ''
const split = splitProviderModel(job?.provider, job?.model)
const provider = split.provider
const model = split.model
if (provider && model) {
return {
provider,

View file

@ -48,7 +48,7 @@ export {
workspaceVisibleIds,
} from './isolation.js'
export { wrapScheduledPrompt } from './prompt.js'
export { normalizeJobModel } from './store.js'
export { normalizeJobModel, splitProviderModel } from './store.js'
export {
assertCanAccessJob,
canViewAllJobs,

View file

@ -77,12 +77,46 @@ export function newId() {
return randomUUID()
}
/**
* Repair provider/model pairs when LLMs or hosts pass a combined "provider/model"
* route in one or both fields (e.g. provider=model="zte/Qwen3-…" → zte + Qwen3-…).
* Leaves legitimate model ids that contain "/" alone when provider is a distinct id.
* @param {unknown} provider
* @param {unknown} model
* @returns {{ provider: string, model: string }}
*/
export function splitProviderModel(provider, model) {
let p = String(provider || '').trim()
let m = String(model || '').trim()
if (!p && !m) return { provider: '', model: '' }
if (p && m && p === m && p.includes('/')) {
const at = p.indexOf('/')
return { provider: p.slice(0, at).trim(), model: p.slice(at + 1).trim() }
}
if (!p && m.includes('/')) {
const at = m.indexOf('/')
return { provider: m.slice(0, at).trim(), model: m.slice(at + 1).trim() }
}
if (p.includes('/') && !m) {
const at = p.indexOf('/')
return { provider: p.slice(0, at).trim(), model: p.slice(at + 1).trim() }
}
if (p && m.startsWith(`${p}/`)) {
return { provider: p, model: m.slice(p.length + 1).trim() }
}
if (p.includes('/') && m && p.endsWith(`/${m}`)) {
const at = p.indexOf('/')
return { provider: p.slice(0, at).trim(), model: m }
}
return { provider: p, model: m }
}
export function normalizeJobModel(input = {}) {
const provider = typeof input.provider === 'string' ? input.provider.trim() : ''
const model = typeof input.model === 'string' ? input.model.trim() : ''
const reasoningEffort = typeof input.reasoningEffort === 'string'
? input.reasoningEffort.trim()
: (typeof input.reasoning_effort === 'string' ? input.reasoning_effort.trim() : '')
const { provider, model } = splitProviderModel(input.provider, input.model)
if (!provider && !model) {
return { provider: '', model: '', reasoningEffort: '' }
}

View file

@ -20,6 +20,7 @@ import {
jobVisibleToIdentity,
UNASSIGNED_OWNER,
} from './ownership.js'
import { splitProviderModel } from './store.js'
const JOB_SCHEMA = {
type: 'object',
@ -328,20 +329,27 @@ async function requireOwnedJob(service, id, peer, identity = null) {
export function callerModelSelection(exec) {
const opts = exec?.agent?.options || {}
const provider = String(opts.provider || '').trim()
const model = String(opts.model || '').trim()
if (!provider || !model) return { provider: '', model: '', reasoningEffort: '' }
const split = splitProviderModel(opts.provider, opts.model)
if (!split.provider || !split.model) return { provider: '', model: '', reasoningEffort: '' }
const reasoningEffort = String(opts.reasoningEffort || '').trim()
return { provider, model, reasoningEffort }
return { provider: split.provider, model: split.model, reasoningEffort }
}
export function resolveCreateModel(args, exec) {
const provider = typeof args?.provider === 'string' ? args.provider.trim() : ''
const model = typeof args?.model === 'string' ? args.model.trim() : ''
const reasoningEffort = typeof args?.reasoning_effort === 'string'
? args.reasoning_effort.trim()
: (typeof args?.reasoningEffort === 'string' ? args.reasoningEffort.trim() : '')
if (provider && model) return { provider, model, reasoningEffort }
const explicit = splitProviderModel(args?.provider, args?.model)
if (explicit.provider && explicit.model) {
return { provider: explicit.provider, model: explicit.model, reasoningEffort }
}
// One of provider/model alone (after split) → ignore; inherit session instead of throwing.
if (explicit.provider || explicit.model) {
const fromSession = callerModelSelection(exec)
if (fromSession.provider && fromSession.model) {
return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort }
}
}
const fromSession = callerModelSelection(exec)
return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort }
}
@ -379,8 +387,8 @@ export function cronToolDefinitions(service, deps = {}) {
timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' },
time_zone: { type: 'string', description: 'Alias of timezone.' },
cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. WhatsApp/IM creates always use the current session cwd.' },
provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' },
model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
provider: { type: 'string', description: 'Provider id only (e.g. zte, deepseek, minimax-cn). Do NOT pass "provider/model". Must pair with model, or omit both to inherit the current session.' },
model: { type: 'string', description: 'Bare model id only (e.g. Qwen3-235B-A22B). Do NOT pass "provider/model" or repeat the provider. Must pair with provider, or omit both to inherit the current session.' },
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
@ -807,7 +815,7 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai')
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.',
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
'Model: omit provider+model to inherit the current session. If you pass them, use bare ids only (provider=zte, model=Qwen3-235B-A22B) — never pass "zte/Qwen3-…" as either field.',
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.',
'Session mirror: by default the run summary is NOT injected into the origin WhatsApp/Web chat. Pass mirror_to_session=true only when the user wants follow-up context in that chat. Full tool traces always stay in run history; IM delivery (when configured) is independent.',
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',

View file

@ -116,6 +116,42 @@ test('resolveCreateCwd prefers explicit cwd then the calling session', () => {
})
})
test('resolveCreateModel / normalizeJobModel split doubled provider/model routes', async (t) => {
const { normalizeJobModel, splitProviderModel } = await import('../lib/store.js')
assert.deepEqual(
splitProviderModel('zte/Qwen3-235B-A22B', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
assert.deepEqual(
splitProviderModel('zte', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
assert.deepEqual(
splitProviderModel('', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
// Legitimate model id with slash under a distinct provider stays intact.
assert.deepEqual(
splitProviderModel('openai', 'org/custom-model'),
{ provider: 'openai', model: 'org/custom-model' },
)
assert.deepEqual(
normalizeJobModel({ provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' }),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
assert.deepEqual(
resolveCreateModel(
{ provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' },
{ agent: { options: { provider: 'other', model: 'other-model' } } },
),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
assert.deepEqual(
resolveCreateModel({}, { agent: { options: { provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' } } }),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
})
test('cron_create hour+minute uses today and rejects a guessed past calendar date', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))