mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 00:43:22 +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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
38
lib/store.js
38
lib/store.js
|
|
@ -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: '' }
|
||||
}
|
||||
|
|
|
|||
28
lib/tools.js
28
lib/tools.js
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue