mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-17 14:40:56 +08:00
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:
parent
3ce60b200c
commit
4472a9c74b
5 changed files with 95 additions and 15 deletions
|
|
@ -36,6 +36,7 @@ import {
|
||||||
listJobs,
|
listJobs,
|
||||||
normalizeSettings,
|
normalizeSettings,
|
||||||
removeJob,
|
removeJob,
|
||||||
|
splitProviderModel,
|
||||||
storePath,
|
storePath,
|
||||||
upsertJob,
|
upsertJob,
|
||||||
} from './store.js'
|
} from './store.js'
|
||||||
|
|
@ -1832,8 +1833,9 @@ export async function listPresetChoices(ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveJobModel(ctx, job) {
|
export async function resolveJobModel(ctx, job) {
|
||||||
const provider = typeof job?.provider === 'string' ? job.provider.trim() : ''
|
const split = splitProviderModel(job?.provider, job?.model)
|
||||||
const model = typeof job?.model === 'string' ? job.model.trim() : ''
|
const provider = split.provider
|
||||||
|
const model = split.model
|
||||||
if (provider && model) {
|
if (provider && model) {
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export {
|
||||||
workspaceVisibleIds,
|
workspaceVisibleIds,
|
||||||
} from './isolation.js'
|
} from './isolation.js'
|
||||||
export { wrapScheduledPrompt } from './prompt.js'
|
export { wrapScheduledPrompt } from './prompt.js'
|
||||||
export { normalizeJobModel } from './store.js'
|
export { normalizeJobModel, splitProviderModel } from './store.js'
|
||||||
export {
|
export {
|
||||||
assertCanAccessJob,
|
assertCanAccessJob,
|
||||||
canViewAllJobs,
|
canViewAllJobs,
|
||||||
|
|
|
||||||
38
lib/store.js
38
lib/store.js
|
|
@ -77,12 +77,46 @@ export function newId() {
|
||||||
return randomUUID()
|
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 = {}) {
|
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'
|
const reasoningEffort = typeof input.reasoningEffort === 'string'
|
||||||
? input.reasoningEffort.trim()
|
? input.reasoningEffort.trim()
|
||||||
: (typeof input.reasoning_effort === 'string' ? input.reasoning_effort.trim() : '')
|
: (typeof input.reasoning_effort === 'string' ? input.reasoning_effort.trim() : '')
|
||||||
|
const { provider, model } = splitProviderModel(input.provider, input.model)
|
||||||
if (!provider && !model) {
|
if (!provider && !model) {
|
||||||
return { provider: '', model: '', reasoningEffort: '' }
|
return { provider: '', model: '', reasoningEffort: '' }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
lib/tools.js
28
lib/tools.js
|
|
@ -20,6 +20,7 @@ import {
|
||||||
jobVisibleToIdentity,
|
jobVisibleToIdentity,
|
||||||
UNASSIGNED_OWNER,
|
UNASSIGNED_OWNER,
|
||||||
} from './ownership.js'
|
} from './ownership.js'
|
||||||
|
import { splitProviderModel } from './store.js'
|
||||||
|
|
||||||
const JOB_SCHEMA = {
|
const JOB_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
@ -328,20 +329,27 @@ async function requireOwnedJob(service, id, peer, identity = null) {
|
||||||
|
|
||||||
export function callerModelSelection(exec) {
|
export function callerModelSelection(exec) {
|
||||||
const opts = exec?.agent?.options || {}
|
const opts = exec?.agent?.options || {}
|
||||||
const provider = String(opts.provider || '').trim()
|
const split = splitProviderModel(opts.provider, opts.model)
|
||||||
const model = String(opts.model || '').trim()
|
if (!split.provider || !split.model) return { provider: '', model: '', reasoningEffort: '' }
|
||||||
if (!provider || !model) return { provider: '', model: '', reasoningEffort: '' }
|
|
||||||
const reasoningEffort = String(opts.reasoningEffort || '').trim()
|
const reasoningEffort = String(opts.reasoningEffort || '').trim()
|
||||||
return { provider, model, reasoningEffort }
|
return { provider: split.provider, model: split.model, reasoningEffort }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCreateModel(args, exec) {
|
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'
|
const reasoningEffort = typeof args?.reasoning_effort === 'string'
|
||||||
? args.reasoning_effort.trim()
|
? args.reasoning_effort.trim()
|
||||||
: (typeof args?.reasoningEffort === 'string' ? args.reasoningEffort.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)
|
const fromSession = callerModelSelection(exec)
|
||||||
return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort }
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
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: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
|
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.' },
|
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
||||||
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
||||||
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
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 "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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
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-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue