From 4472a9c74b869163ab349750c1672245686b77a2 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 21:46:16 +0800 Subject: [PATCH] Normalize doubled provider/model routes on cron create and fire. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents often pass zte/Qwen3-… as both provider and model; split combined routes so scheduled runs resolve a real model. Co-authored-by: Cursor --- lib/host.js | 6 ++++-- lib/index.js | 2 +- lib/store.js | 38 ++++++++++++++++++++++++++++++++++++-- lib/tools.js | 28 ++++++++++++++++++---------- test/tools.test.js | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/lib/host.js b/lib/host.js index 45a5185..c627106 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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, diff --git a/lib/index.js b/lib/index.js index ad09f4a..96f32f8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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, diff --git a/lib/store.js b/lib/store.js index d12b552..2f485ad 100644 --- a/lib/store.js +++ b/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: '' } } diff --git a/lib/tools.js b/lib/tools.js index 6c7b678..2f1fe32 100644 --- a/lib/tools.js +++ b/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.', diff --git a/test/tools.test.js b/test/tools.test.js index 85c5113..c7c24b4 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -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 }))