@desplega.ai/agent-swarm 1.158.0 → 1.159.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{acp-adapter-xaq4n6pc.js → acp-adapter-qhch0n2v.js} +1 -1
- package/dist/{actions-p9hz5yat.js → actions-dtsh1mfb.js} +33 -29
- package/dist/{anthropic-messages-51pz26qe.js → anthropic-messages-f2h06k64.js} +8 -3
- package/dist/{app-g26wdcz6.js → app-y1b9srva.js} +10 -9
- package/dist/{assistant-wydfqkb3.js → assistant-31hwy5xj.js} +34 -30
- package/dist/{azure-openai-responses-8gksqtmz.js → azure-openai-responses-0k1mfax5.js} +12 -13
- package/dist/{boot-reembed-se8zmdcp.js → boot-reembed-48d2qpkt.js} +11 -11
- package/dist/{boot-reembed-g5ckg9yb.js → boot-reembed-w0bryhkr.js} +12 -12
- package/dist/{boot-scrub-logs-wfxqan61.js → boot-scrub-logs-t65wbrn7.js} +9 -8
- package/dist/{boot-seeds-s3q01xj3.js → boot-seeds-h7c32hvm.js} +12 -11
- package/dist/{claude-adapter-tv2x4f36.js → claude-adapter-1t6611ne.js} +26 -21
- package/dist/{claude-managed-adapter-41b2s7fd.js → claude-managed-adapter-werp659j.js} +4 -2
- package/dist/{claude-managed-setup-60g8aazx.js → claude-managed-setup-y6rdgbc1.js} +11 -2
- package/dist/claude-sdk-session-zhkyk8yw.js +29319 -0
- package/dist/{cli-vj6h9kc2.js → cli-0m7wct0d.js} +59 -1
- package/dist/{cli-wscdvcqz.js → cli-0n2e4x49.js} +1 -1
- package/dist/cli-0xqde53b.js +176 -0
- package/dist/{cli-xberxa1r.js → cli-10ym38qc.js} +2 -2
- package/dist/{cli-5b6b62tz.js → cli-13hw4z91.js} +1 -1
- package/dist/{cli-rqz8qbw7.js → cli-20035trh.js} +50 -18
- package/dist/{cli-k0g1sjzm.js → cli-31nkw3g3.js} +3 -3
- package/dist/cli-3gvrr8vj.js +1422 -0
- package/dist/{cli-c07r9a2m.js → cli-48527fpw.js} +9 -8
- package/dist/{cli-24k806sc.js → cli-4aj25szq.js} +2 -2
- package/dist/{cli-7gtvvpbh.js → cli-4tvgz99m.js} +7 -7
- package/dist/cli-503ya656.js +455 -0
- package/dist/{cli-cw9zck8j.js → cli-5f2hq128.js} +8 -4
- package/dist/{cli-s8eet254.js → cli-68bmme5p.js} +11078 -27833
- package/dist/{cli-ftfvggzx.js → cli-79aq9cgb.js} +794 -563
- package/dist/cli-7cxw8b60.js +14910 -0
- package/dist/cli-7kyk9jr3.js +22 -0
- package/dist/cli-7zttzetj.js +324 -0
- package/dist/{cli-y95wp3tg.js → cli-96qd5psa.js} +1 -1
- package/dist/{cli-g8djp6qe.js → cli-9yg65c1j.js} +22 -0
- package/dist/{cli-3gmcwfyx.js → cli-ardpzhjm.js} +5 -5
- package/dist/{cli-7s3syk22.js → cli-b6thtzac.js} +5 -5
- package/dist/{cli-00w2c66p.js → cli-bgv70kym.js} +13 -2
- package/dist/{cli-j55n64b6.js → cli-bv0a38v9.js} +163 -44
- package/dist/cli-ca2w7zv7.js +115 -0
- package/dist/cli-cpvsaqsw.js +333 -0
- package/dist/cli-cygyqj3b.js +754 -0
- package/dist/{cli-x24pshtq.js → cli-dvjpkkjd.js} +6 -6
- package/dist/{cli-5d10vd2m.js → cli-e0ndxhtk.js} +4 -4
- package/dist/{cli-4198qc9v.js → cli-e4xnc0qp.js} +15 -3
- package/dist/{cli-rjehvrts.js → cli-eg7cdg68.js} +8 -8
- package/dist/cli-ejpygnqz.js +3815 -0
- package/dist/{cli-fgqxwrpv.js → cli-fjtp3463.js} +1631 -127
- package/dist/{cli-zt1227k1.js → cli-fkqhbg8g.js} +5 -3
- package/dist/{cli-47m6dkd3.js → cli-g9df2m37.js} +409 -168
- package/dist/cli-gzyh0j0g.js +8455 -0
- package/dist/cli-hgqfttfc.js +183 -0
- package/dist/cli-hj65wsts.js +17755 -0
- package/dist/cli-j9z8mfx3.js +476 -0
- package/dist/{cli-aehm1rbq.js → cli-kc3dp84d.js} +1135 -61
- package/dist/{cli-h38x0qhg.js → cli-kdnehe0t.js} +4 -3
- package/dist/{cli-20cj37vt.js → cli-m4excttz.js} +136 -2216
- package/dist/{cli-8qmme6vd.js → cli-mg24hkt4.js} +2 -2
- package/dist/{cli-xhaeg2nc.js → cli-mzkqpxee.js} +1 -1
- package/dist/{cli-q67y4wzm.js → cli-phsaj4xq.js} +24 -2
- package/dist/{cli-pw9rj5t9.js → cli-pqetb83f.js} +1 -176
- package/dist/{cli-rj19hc34.js → cli-qjxy52w7.js} +1 -1
- package/dist/{cli-53sz8gkz.js → cli-qv4jv8bx.js} +1 -1
- package/dist/cli-qvnnx4ct.js +1051 -0
- package/dist/cli-rb9m6rpe.js +4980 -0
- package/dist/{cli-tt9k8r35.js → cli-rfezr056.js} +476 -909
- package/dist/{cli-ggm92wcn.js → cli-rgjprct3.js} +1 -0
- package/dist/{cli-9httdpyq.js → cli-rrqmqr1s.js} +1 -1
- package/dist/cli-rykfmgwg.js +102 -0
- package/dist/{cli-eetrvv91.js → cli-t3p77qk1.js} +1 -1
- package/dist/{cli-68w09sjw.js → cli-vn0qv9cj.js} +25 -88
- package/dist/{cli-987hg0aa.js → cli-vwbbaecp.js} +2 -2
- package/dist/cli-wd9hz40d.js +69 -0
- package/dist/{cli-j57qkrpq.js → cli-wgjx0q33.js} +13 -13
- package/dist/cli-xgpf00sk.js +2149 -0
- package/dist/{cli-mcnhgnn1.js → cli-ykk5jaax.js} +184 -26
- package/dist/{cli-aky8tctm.js → cli-z9x0sh1t.js} +3 -3
- package/dist/{cli-1hkrch3k.js → cli-zw65mab6.js} +5 -5
- package/dist/cli.js +38 -29
- package/dist/cloudflare-workers-ai-system-one-1sg586qv.js +46 -0
- package/dist/{codex-adapter-cnm8wk11.js → codex-adapter-jqfqqa21.js} +21 -14
- package/dist/{codex-hook-9hptmdhj.js → codex-hook-5eys3dya.js} +2 -2
- package/dist/{codex-session-runner-gfg547zj.js → codex-session-runner-zd341dsm.js} +21 -14
- package/dist/{commands-e3bn0p75.js → commands-vfsysz4s.js} +9 -8
- package/dist/{db-03v9kzr2.js → db-s3znhvbn.js} +15 -10
- package/dist/{dispatcher-nrmg8yht.js → dispatcher-5xn0c7rf.js} +32 -28
- package/dist/{dsh-adapter-k03nfqa6.js → dsh-adapter-894mgfvs.js} +3 -3
- package/dist/{e2b-93x1k0j5.js → e2b-ez07znam.js} +4 -3
- package/dist/execute-71ysgv35.js +965 -0
- package/dist/{google-generative-ai-bxmv50nc.js → google-generative-ai-1nfcdphp.js} +5 -4
- package/dist/{google-vertex-809af514.js → google-vertex-1tqcd7b1.js} +5 -4
- package/dist/{handlers-aqcedbyb.js → handlers-5w4jwycr.js} +35 -31
- package/dist/{hook-7vkskkre.js → hook-853bxrr6.js} +16 -13
- package/dist/{hook-ybvmrmhq.js → hook-z0hbxj85.js} +16 -13
- package/dist/{http-2eqa8t24.js → http-akc3wy3m.js} +600 -123
- package/dist/{index-f6cryxwe.js → index-4a5njnm1.js} +2 -59
- package/dist/{index-a3vn96n8.js → index-5sz16ksh.js} +187 -47
- package/dist/{index-c6zsv1bd.js → index-bjmjh9h4.js} +11 -11
- package/dist/{index-chgdkq36.js → index-cd151qkq.js} +32 -28
- package/dist/{index-x71bvq0p.js → index-k661dzzr.js} +33 -29
- package/dist/{index-1yks3qcr.js → index-ttpbhnjg.js} +32 -28
- package/dist/{keepalive-vtkf5n99.js → keepalive-8p9rpvz5.js} +11 -10
- package/dist/{lead-h3sp357x.js → lead-jp69z5h3.js} +49 -45
- package/dist/llama-cpp-classify-t2tccams.js +335 -0
- package/dist/{maintenance-jzeqtk3q.js → maintenance-fwx4ev0k.js} +12 -12
- package/dist/{mistral-conversations-zx0kn554.js → mistral-conversations-bhvwqcnk.js} +14 -17
- package/dist/{oauth-refresh-sweep-qp3dzmdp.js → oauth-refresh-sweep-cwcq1ctx.js} +10 -9
- package/dist/{onboard-tmsp0qbz.js → onboard-2b4q0t84.js} +6 -5
- package/dist/{openai-codex-responses-4vjqzvjp.js → openai-codex-responses-nekthgks.js} +21 -8
- package/dist/{openai-completions-9hjrqbw8.js → openai-completions-hxtzwj0s.js} +8 -11
- package/dist/{openai-responses-7b1pn0xt.js → openai-responses-w4r118w7.js} +22 -17
- package/dist/{opencode-adapter-x6x2da1z.js → opencode-adapter-zz7xvjj6.js} +6 -4
- package/dist/{openrouter-images-9f9ek4rw.js → openrouter-images-b6sqqjgn.js} +4 -6
- package/dist/{otel-impl-9mz73q65.js → otel-impl-g8rs4vwr.js} +1 -1
- package/dist/{pi-messages-txpjj39q.js → pi-messages-vxdt8v7j.js} +2 -1
- package/dist/{pi-mono-adapter-m6dvv0dz.js → pi-mono-adapter-n0me28wt.js} +29 -17
- package/dist/pricing-refresh-0v77385v.js +39 -0
- package/dist/{rbac-roles-c9wqz83x.js → rbac-roles-krjyazbj.js} +10 -9
- package/dist/{render-v2-gryrh9h8.js → render-v2-3wjsp9aw.js} +10 -9
- package/dist/runtime-ktjysacx.js +24 -0
- package/dist/{setup-q9720bt6.js → setup-yeqryke6.js} +6 -5
- package/dist/typesafe-system-one-96960jxp.js +27 -0
- package/dist/{virtual-modules-3htpgn8x.js → virtual-modules-dzdeq1sf.js} +21 -13
- package/dist/{work-objects-b76x7deb.js → work-objects-3pakpkw0.js} +35 -31
- package/dist/{worker-6vrxbv41.js → worker-85testgj.js} +49 -45
- package/openapi.json +863 -14
- package/package.json +9 -8
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/tasks/human-free.ts +442 -0
- package/src/be/db/tasks/read.ts +56 -1
- package/src/be/db/tasks/write.ts +107 -11
- package/src/be/db.ts +89 -165
- package/src/be/harness-model-support.ts +182 -0
- package/src/be/human-free-drain.ts +67 -0
- package/src/be/migrations/182_task_human_free_flag.sql +63 -0
- package/src/be/migrations/183_human_free_reclassify_queue.sql +25 -0
- package/src/be/migrations/184_model_catalog.sql +120 -0
- package/src/be/model-catalog-store.ts +644 -0
- package/src/be/model-tier-keys.ts +31 -0
- package/src/be/model-tier-resolution.ts +489 -0
- package/src/be/model-validation.ts +112 -0
- package/src/be/models-catalog.ts +61 -7
- package/src/be/modelsdev-cache.json +82 -0
- package/src/be/modelsdev-cache.ts +2 -0
- package/src/be/pricing-refresh.ts +154 -12
- package/src/be/scripts/typecheck.ts +15 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +61 -9
- package/src/be/swarm-config-guard.ts +6 -0
- package/src/cli.tsx +10 -0
- package/src/commands/claude-managed-setup.ts +3 -1
- package/src/commands/runner.ts +76 -2
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +64 -15
- package/src/heartbeat/templates.ts +2 -2
- package/src/http/agents.ts +49 -1
- package/src/http/index.ts +8 -0
- package/src/http/models-catalog.ts +357 -8
- package/src/http/poll.ts +45 -2
- package/src/http/schedules.ts +36 -6
- package/src/http/session-data.ts +2 -5
- package/src/http/stats.ts +6 -8
- package/src/http/tasks.ts +26 -1
- package/src/http/usage-cache.ts +56 -15
- package/src/http/workflows.ts +35 -8
- package/src/prompts/session-templates.ts +1 -0
- package/src/providers/claude-managed-models.ts +47 -161
- package/src/providers/codex-adapter.ts +4 -4
- package/src/providers/codex-models.ts +65 -198
- package/src/providers/reasoning-effort.ts +44 -140
- package/src/rbac/legacy-policy.ts +25 -0
- package/src/rbac/permissions.ts +10 -0
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/types/stdlib.d.ts +15 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +15 -0
- package/src/server.ts +6 -0
- package/src/slack/message-text.ts +56 -2
- package/src/slack/thread-buffer.ts +10 -2
- package/src/tests/agents-list-model-display.test.ts +4 -6
- package/src/tests/base-prompt.test.ts +3 -0
- package/src/tests/claude-adapter.test.ts +10 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -18
- package/src/tests/codex-adapter.test.ts +2 -1
- package/src/tests/codex-gpt6-release.test.ts +66 -5
- package/src/tests/context-window.test.ts +7 -5
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +5 -1
- package/src/tests/db-task-write-contract.test.ts +4 -1
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/explicit-model-validation.test.ts +332 -0
- package/src/tests/harness-model-support.test.ts +374 -0
- package/src/tests/heartbeat-checklist-config.test.ts +71 -0
- package/src/tests/heartbeat-checklist.test.ts +4 -2
- package/src/tests/heartbeat-orphan-resume.test.ts +48 -0
- package/src/tests/human-free-flag.test.ts +279 -0
- package/src/tests/human-free-oracle.ts +57 -0
- package/src/tests/human-free-reclassify.test.ts +669 -0
- package/src/tests/migration-046-budgets.test.ts +3 -3
- package/src/tests/model-catalog-migration.test.ts +134 -0
- package/src/tests/model-catalog-table.test.ts +249 -0
- package/src/tests/model-catalog-write-access.test.ts +387 -0
- package/src/tests/model-tier-resolution.test.ts +519 -0
- package/src/tests/models-catalog.test.ts +1 -1
- package/src/tests/multi-runtime-enabled-stats.test.ts +65 -1
- package/src/tests/package-publish.test.ts +137 -36
- package/src/tests/pricing-routes.test.ts +2 -2
- package/src/tests/rbac-engine.test.ts +42 -2
- package/src/tests/reasoning-effort.test.ts +109 -0
- package/src/tests/refresh-modelsdev-carryforward.test.ts +5 -5
- package/src/tests/runtime-model-catalog.test.ts +86 -0
- package/src/tests/script-connections.test.ts +5 -0
- package/src/tests/seed-scripts.test.ts +89 -0
- package/src/tests/session-costs.test.ts +14 -11
- package/src/tests/slack-message-text.test.ts +101 -0
- package/src/tests/slack-thread-buffer-context.test.ts +93 -0
- package/src/tests/task-cascade-fail.test.ts +148 -2
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/usage-cache.test.ts +166 -0
- package/src/tests/workflow-executors.test.ts +5 -2
- package/src/tests/workflow-system-one-decision-review.test.ts +1021 -0
- package/src/tests/workflow-system-one-decision-save-warnings.test.ts +365 -0
- package/src/tests/workflow-system-one-decision.test.ts +2063 -0
- package/src/tools/model-catalog/index.ts +137 -0
- package/src/tools/schedules/create-schedule.ts +16 -1
- package/src/tools/schedules/patch-schedule.ts +17 -0
- package/src/tools/schedules/update-schedule.ts +17 -0
- package/src/tools/send-task.ts +14 -0
- package/src/tools/task-action.ts +12 -0
- package/src/tools/tool-config.ts +3 -1
- package/src/tools/workflows/create-workflow.ts +9 -2
- package/src/tools/workflows/patch-workflow-node.ts +17 -7
- package/src/tools/workflows/patch-workflow.ts +9 -2
- package/src/tools/workflows/update-workflow.ts +9 -2
- package/src/types.ts +30 -1
- package/src/utils/context-window.ts +47 -53
- package/src/utils/harness-cli-version.ts +115 -0
- package/src/utils/harness-model-error.ts +33 -0
- package/src/utils/runtime-model-catalog.ts +137 -0
- package/src/workflows/approval-resolution.ts +39 -0
- package/src/workflows/definition.ts +7 -0
- package/src/workflows/engine.ts +69 -1
- package/src/workflows/executors/agent-task.ts +9 -0
- package/src/workflows/executors/base.ts +53 -0
- package/src/workflows/executors/human-in-the-loop.ts +8 -8
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/system-one-decision.ts +1232 -0
- package/src/workflows/executors/system-one-providers.ts +301 -0
- package/src/workflows/executors/workflow-llm.ts +13 -1
- package/src/workflows/readiness.ts +86 -0
- package/src/workflows/recovery.ts +12 -12
- package/src/workflows/resume.ts +23 -9
- package/templates/schedules/daily-blocker-digest/content.md +4 -5
- package/templates/skills/agent-browser/SKILL.md +6 -5
- package/templates/skills/agent-browser/content.md +6 -5
- package/templates/skills/heartbeat-runbook/config.json +1 -1
- package/templates/skills/heartbeat-runbook/content.md +2 -2
- package/templates/skills/swarm-scripts/SKILL.md +1 -1
- package/templates/skills/swarm-scripts/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +81 -1
- package/templates/workflows/claude-code-changelog-watch/content.md +2 -0
- package/templates/workflows/pr-review-status-sweep/content.md +2 -0
- package/dist/claude-sdk-session-t442qz6f.js +0 -39108
- package/dist/cli-0xjkj1j7.js +0 -61
- package/dist/cli-65hjmev5.js +0 -1141
- package/dist/cli-aw1jwp1r.js +0 -14075
- package/dist/cli-h967nb6r.js +0 -6603
- package/dist/cli-vapyk8sw.js +0 -20
- package/dist/cli-ws6rqycm.js +0 -247
- package/dist/pricing-refresh-v1cbr9te.js +0 -27
- package/src/providers/modelsdev-reasoning.json +0 -6453
- package/dist/{codex-login-x3jk4etn.js → codex-login-he9vrn90.js} +3 -3
- package/dist/{index-f24115ge.js → index-2wsdsnmm.js} +3 -3
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim-time model resolution (model-catalog phase 3).
|
|
3
|
+
*
|
|
4
|
+
* The API server resolves which concrete model a task runs with at the moment
|
|
5
|
+
* a worker claims it, against the catalog it owns, and records the answer on
|
|
6
|
+
* the task row (`resolvedModel`, `modelSource`, `modelAlias`).
|
|
7
|
+
*
|
|
8
|
+
* Precedence (first hit wins):
|
|
9
|
+
* 1. task `model` → modelSource "model"
|
|
10
|
+
* 2. the claiming worker's MODEL_TIER_* → "worker-env" (agents.modelTierOverrides)
|
|
11
|
+
* 3. swarm_config MODEL_TIER_<PROV>_<TIER> → "tier-config"
|
|
12
|
+
* 4. DEFAULT_MODEL_TIER_MAP → "tier-default"
|
|
13
|
+
*
|
|
14
|
+
* Any layer may hold a `latest:` alias. An alias that resolves to nothing
|
|
15
|
+
* (empty catalog, everything filtered) falls through to the next layer.
|
|
16
|
+
*
|
|
17
|
+
* Guardrails for `latest:` aliases:
|
|
18
|
+
* - `@stable` (default) drops preview/experimental ids and models younger
|
|
19
|
+
* than MODEL_LATEST_SOAK_DAYS (default 2).
|
|
20
|
+
* - Unpriced catalog models are never picked.
|
|
21
|
+
* - MODEL_AUTO_UPGRADE=false freezes each alias at its last recorded
|
|
22
|
+
* resolution (resolves normally only the first time).
|
|
23
|
+
* - Every change of an alias's resolution writes one
|
|
24
|
+
* `model_alias_resolutions` row and logs one notice after commit.
|
|
25
|
+
*
|
|
26
|
+
* See runbooks/model-tiers.md.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
harnessCatalogSection,
|
|
30
|
+
isAlias,
|
|
31
|
+
type ModelsDevCatalog,
|
|
32
|
+
type ModelsDevModel,
|
|
33
|
+
parseAlias,
|
|
34
|
+
resolveAlias,
|
|
35
|
+
} from "@desplega/model-catalog";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_MODEL_TIER_MAP,
|
|
39
|
+
MODEL_TIERS,
|
|
40
|
+
type ModelTier,
|
|
41
|
+
type ModelTierOverrides,
|
|
42
|
+
type ProviderName,
|
|
43
|
+
ProviderNameSchema,
|
|
44
|
+
parseModelTier,
|
|
45
|
+
} from "../types";
|
|
46
|
+
import { getDbClient } from "./db";
|
|
47
|
+
import {
|
|
48
|
+
CLI_PINNED_HARNESSES,
|
|
49
|
+
fallbackForUnsupportedModel,
|
|
50
|
+
getAgentHarnessCliVersion,
|
|
51
|
+
getHarnessModelSupport,
|
|
52
|
+
noticeCliUnsupported,
|
|
53
|
+
unsupportedModelMessage,
|
|
54
|
+
} from "./harness-model-support";
|
|
55
|
+
import {
|
|
56
|
+
buildCatalogEntries,
|
|
57
|
+
getCatalogGeneration,
|
|
58
|
+
listModelCatalog,
|
|
59
|
+
listModelCatalogOverlay,
|
|
60
|
+
type ModelCatalogEntry,
|
|
61
|
+
type ModelCatalogFacts,
|
|
62
|
+
} from "./model-catalog-store";
|
|
63
|
+
import { tierConfigKey } from "./model-tier-keys";
|
|
64
|
+
import { loadModelsDevCache } from "./modelsdev-cache";
|
|
65
|
+
|
|
66
|
+
export { isTierConfigKey, tierConfigKey, validateTierConfigValue } from "./model-tier-keys";
|
|
67
|
+
|
|
68
|
+
export type ModelSource =
|
|
69
|
+
| "model"
|
|
70
|
+
| "worker-env"
|
|
71
|
+
| "tier-config"
|
|
72
|
+
| "tier-default"
|
|
73
|
+
| "fallback:cli-unsupported";
|
|
74
|
+
|
|
75
|
+
export interface TaskModelResolution {
|
|
76
|
+
resolvedModel: string;
|
|
77
|
+
modelSource: ModelSource;
|
|
78
|
+
modelAlias: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const DEFAULT_MODEL_LATEST_SOAK_DAYS = 2;
|
|
82
|
+
|
|
83
|
+
// ─── Worker overrides (agents.modelTierOverrides) ────────────────────────────
|
|
84
|
+
|
|
85
|
+
const OverridesSchema = z.record(z.string(), z.record(z.string(), z.string()));
|
|
86
|
+
|
|
87
|
+
/** Keep only known providers/tiers and sane values. */
|
|
88
|
+
export function sanitizeModelTierOverrides(raw: unknown): ModelTierOverrides {
|
|
89
|
+
const parsed = OverridesSchema.safeParse(raw);
|
|
90
|
+
if (!parsed.success) return {};
|
|
91
|
+
const result: ModelTierOverrides = {};
|
|
92
|
+
for (const [provider, tiers] of Object.entries(parsed.data)) {
|
|
93
|
+
if (!ProviderNameSchema.safeParse(provider).success) continue;
|
|
94
|
+
const clean: Partial<Record<ModelTier, string>> = {};
|
|
95
|
+
for (const [tier, value] of Object.entries(tiers)) {
|
|
96
|
+
const t = parseModelTier(tier);
|
|
97
|
+
const v = value.trim();
|
|
98
|
+
if (t && t === tier && v && v.length <= 200 && !/\s/.test(v)) clean[t] = v;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(clean).length > 0) result[provider] = clean;
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse the `X-Model-Tier-Overrides` poll header (URL-encoded JSON). */
|
|
106
|
+
export function parseModelTierOverridesHeader(
|
|
107
|
+
header: string | null | undefined,
|
|
108
|
+
): ModelTierOverrides | undefined {
|
|
109
|
+
if (header === null || header === undefined || header === "") return undefined;
|
|
110
|
+
try {
|
|
111
|
+
return sanitizeModelTierOverrides(JSON.parse(decodeURIComponent(header)));
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getAgentModelTierOverrides(agentId: string): Promise<ModelTierOverrides> {
|
|
118
|
+
const row = await getDbClient().get<{ modelTierOverrides: string | null }>(
|
|
119
|
+
"SELECT modelTierOverrides FROM agents WHERE id = ?",
|
|
120
|
+
[agentId],
|
|
121
|
+
);
|
|
122
|
+
if (!row?.modelTierOverrides) return {};
|
|
123
|
+
try {
|
|
124
|
+
return sanitizeModelTierOverrides(JSON.parse(row.modelTierOverrides));
|
|
125
|
+
} catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Store the worker's overrides; no write when unchanged. Empty object clears. */
|
|
131
|
+
export async function setAgentModelTierOverrides(
|
|
132
|
+
agentId: string,
|
|
133
|
+
overrides: ModelTierOverrides,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const next = Object.keys(overrides).length > 0 ? JSON.stringify(overrides) : null;
|
|
136
|
+
await getDbClient().run(
|
|
137
|
+
"UPDATE agents SET modelTierOverrides = ? WHERE id = ? AND modelTierOverrides IS NOT ?",
|
|
138
|
+
[next, agentId, next],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Catalog for alias resolution ────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
const CATALOG_TTL_MS = 30_000;
|
|
145
|
+
let cachedCatalog: {
|
|
146
|
+
at: number;
|
|
147
|
+
/** `getCatalogGeneration()` read before the tables were, so a write mid-load still invalidates. */
|
|
148
|
+
generation: number;
|
|
149
|
+
catalog: ModelsDevCatalog;
|
|
150
|
+
priced: Set<string>;
|
|
151
|
+
} | null = null;
|
|
152
|
+
|
|
153
|
+
export function invalidateTierResolutionCatalog(): void {
|
|
154
|
+
cachedCatalog = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergeFacts(base: ModelCatalogFacts, overlay: ModelCatalogFacts): ModelCatalogFacts {
|
|
158
|
+
const merged: ModelCatalogFacts = { ...base };
|
|
159
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
160
|
+
if (value !== null && value !== undefined) {
|
|
161
|
+
(merged as Record<string, unknown>)[key] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return merged;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Catalog view for `resolveAlias`: `model_catalog` + overlay (overlay wins per
|
|
169
|
+
* non-null field). Empty table → the vendored models.dev snapshot.
|
|
170
|
+
*/
|
|
171
|
+
async function loadResolutionCatalog(): Promise<{
|
|
172
|
+
catalog: ModelsDevCatalog;
|
|
173
|
+
priced: Set<string>;
|
|
174
|
+
}> {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const generation = getCatalogGeneration();
|
|
177
|
+
if (
|
|
178
|
+
cachedCatalog &&
|
|
179
|
+
cachedCatalog.generation === generation &&
|
|
180
|
+
now - cachedCatalog.at < CATALOG_TTL_MS
|
|
181
|
+
) {
|
|
182
|
+
return cachedCatalog;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let entries: ModelCatalogEntry[] = await listModelCatalog();
|
|
186
|
+
if (entries.length === 0) {
|
|
187
|
+
const snapshot = loadModelsDevCache();
|
|
188
|
+
entries = snapshot ? buildCatalogEntries(snapshot, 0) : [];
|
|
189
|
+
}
|
|
190
|
+
const facts = new Map<string, { provider: string; modelId: string; facts: ModelCatalogFacts }>();
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
facts.set(`${entry.provider}/${entry.modelId}`, {
|
|
193
|
+
provider: entry.provider,
|
|
194
|
+
modelId: entry.modelId,
|
|
195
|
+
facts: entry,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
for (const overlay of await listModelCatalogOverlay()) {
|
|
199
|
+
const key = `${overlay.provider}/${overlay.modelId}`;
|
|
200
|
+
const existing = facts.get(key);
|
|
201
|
+
facts.set(key, {
|
|
202
|
+
provider: overlay.provider,
|
|
203
|
+
modelId: overlay.modelId,
|
|
204
|
+
facts: existing ? mergeFacts(existing.facts, overlay) : overlay,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const catalog: ModelsDevCatalog = {};
|
|
209
|
+
const priced = new Set<string>();
|
|
210
|
+
for (const { provider, modelId, facts: f } of facts.values()) {
|
|
211
|
+
catalog[provider] ??= { id: provider, name: provider, models: {} };
|
|
212
|
+
const model: ModelsDevModel = {
|
|
213
|
+
...(f.name ? { name: f.name } : {}),
|
|
214
|
+
...(f.releaseDate ? { release_date: f.releaseDate } : {}),
|
|
215
|
+
...(f.contextWindow ? { limit: { context: f.contextWindow } } : {}),
|
|
216
|
+
...(f.pricing ? { cost: f.pricing } : {}),
|
|
217
|
+
};
|
|
218
|
+
catalog[provider].models[modelId] = model;
|
|
219
|
+
const p = f.pricing;
|
|
220
|
+
if (p && (typeof p.input === "number" || typeof p.output === "number")) {
|
|
221
|
+
priced.add(`${provider}/${modelId}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
cachedCatalog = { at: now, generation, catalog, priced };
|
|
225
|
+
return cachedCatalog;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Alias resolution + change log ───────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function soakDays(): number {
|
|
231
|
+
const raw = process.env.MODEL_LATEST_SOAK_DAYS?.trim();
|
|
232
|
+
if (!raw) return DEFAULT_MODEL_LATEST_SOAK_DAYS;
|
|
233
|
+
const n = Number(raw);
|
|
234
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MODEL_LATEST_SOAK_DAYS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function autoUpgradeEnabled(): boolean {
|
|
238
|
+
const raw = process.env.MODEL_AUTO_UPGRADE?.trim().toLowerCase();
|
|
239
|
+
return !(raw === "false" || raw === "0");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function lastAliasResolution(alias: string): Promise<string | null> {
|
|
243
|
+
const row = await getDbClient().get<{ newModel: string }>(
|
|
244
|
+
"SELECT newModel FROM model_alias_resolutions WHERE alias = ? ORDER BY id DESC LIMIT 1",
|
|
245
|
+
[alias],
|
|
246
|
+
);
|
|
247
|
+
return row?.newModel ?? null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve a `latest:` alias with the guardrails above. Returns null when the
|
|
252
|
+
* alias is invalid or nothing in the catalog qualifies.
|
|
253
|
+
*/
|
|
254
|
+
export async function resolveLatestAlias(
|
|
255
|
+
alias: string,
|
|
256
|
+
now: Date = new Date(),
|
|
257
|
+
opts: { record?: boolean } = {},
|
|
258
|
+
): Promise<string | null> {
|
|
259
|
+
const parsed = parseAlias(alias);
|
|
260
|
+
if (!parsed) return null;
|
|
261
|
+
const key = alias.trim().toLowerCase();
|
|
262
|
+
const previous = await lastAliasResolution(key);
|
|
263
|
+
if (previous && !autoUpgradeEnabled()) return previous;
|
|
264
|
+
|
|
265
|
+
const { catalog, priced } = await loadResolutionCatalog();
|
|
266
|
+
const section = parsed.kind;
|
|
267
|
+
const resolved = resolveAlias(key, catalog, {
|
|
268
|
+
soakDays: soakDays(),
|
|
269
|
+
now,
|
|
270
|
+
isSupported: (id) => priced.has(`${section}/${id}`),
|
|
271
|
+
});
|
|
272
|
+
if (!resolved) return null;
|
|
273
|
+
|
|
274
|
+
if (resolved !== previous && opts.record !== false) {
|
|
275
|
+
await getDbClient().run(
|
|
276
|
+
"INSERT INTO model_alias_resolutions (alias, previousModel, newModel, changedAt) VALUES (?, ?, ?, ?)",
|
|
277
|
+
[key, previous, resolved, now.getTime()],
|
|
278
|
+
);
|
|
279
|
+
getDbClient().afterCommit(() => {
|
|
280
|
+
console.info(
|
|
281
|
+
`[model-tiers] alias ${key} now resolves to ${resolved}${previous ? ` (was ${previous})` : ""}`,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return resolved;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Task resolution ─────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
export interface ResolveTaskModelInput {
|
|
291
|
+
model?: string | null;
|
|
292
|
+
modelTier?: string | null;
|
|
293
|
+
harnessProvider: ProviderName | null | undefined;
|
|
294
|
+
workerOverrides?: ModelTierOverrides;
|
|
295
|
+
/** Where tier-config values are read from. Default `process.env` (global swarm_config is hydrated into it). */
|
|
296
|
+
env?: Record<string, string | undefined>;
|
|
297
|
+
now?: Date;
|
|
298
|
+
/** Default true. False resolves `latest:` aliases without writing `model_alias_resolutions` (previews). */
|
|
299
|
+
record?: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Ordered candidates by precedence; no IO. */
|
|
303
|
+
export function taskModelCandidates(
|
|
304
|
+
input: ResolveTaskModelInput,
|
|
305
|
+
): { value: string; source: ModelSource }[] {
|
|
306
|
+
const candidates: { value: string; source: ModelSource }[] = [];
|
|
307
|
+
const model = input.model?.trim();
|
|
308
|
+
if (model) candidates.push({ value: model, source: "model" });
|
|
309
|
+
const tier = parseModelTier(input.modelTier);
|
|
310
|
+
const provider = input.harnessProvider;
|
|
311
|
+
if (tier && provider) {
|
|
312
|
+
const worker = input.workerOverrides?.[provider]?.[tier]?.trim();
|
|
313
|
+
if (worker) candidates.push({ value: worker, source: "worker-env" });
|
|
314
|
+
const env = input.env ?? process.env;
|
|
315
|
+
const configured = env[tierConfigKey(provider, tier)]?.trim();
|
|
316
|
+
if (configured) candidates.push({ value: configured, source: "tier-config" });
|
|
317
|
+
const fallback = DEFAULT_MODEL_TIER_MAP[provider]?.[tier];
|
|
318
|
+
if (fallback) candidates.push({ value: fallback, source: "tier-default" });
|
|
319
|
+
}
|
|
320
|
+
return candidates;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolve a task's model at claim time. Null when the task names neither model nor tier. */
|
|
324
|
+
export async function resolveTaskModel(
|
|
325
|
+
input: ResolveTaskModelInput,
|
|
326
|
+
): Promise<TaskModelResolution | null> {
|
|
327
|
+
for (const candidate of taskModelCandidates(input)) {
|
|
328
|
+
if (!isAlias(candidate.value)) {
|
|
329
|
+
return { resolvedModel: candidate.value, modelSource: candidate.source, modelAlias: null };
|
|
330
|
+
}
|
|
331
|
+
const resolved = await resolveLatestAlias(candidate.value, input.now, {
|
|
332
|
+
record: input.record,
|
|
333
|
+
});
|
|
334
|
+
if (resolved) {
|
|
335
|
+
return {
|
|
336
|
+
resolvedModel: resolved,
|
|
337
|
+
modelSource: candidate.source,
|
|
338
|
+
modelAlias: candidate.value.trim().toLowerCase(),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
console.warn(
|
|
342
|
+
`[model-tiers] ${candidate.source} alias ${candidate.value} resolved to nothing; trying next layer`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Tier preview ────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export interface ModelTierPreview {
|
|
351
|
+
provider: ProviderName;
|
|
352
|
+
tier: ModelTier;
|
|
353
|
+
/** swarm_config / env key that overrides this tier globally. */
|
|
354
|
+
key: string;
|
|
355
|
+
/** The built-in DEFAULT_MODEL_TIER_MAP value. */
|
|
356
|
+
defaultValue: string;
|
|
357
|
+
/** The value stored for `key`, or null when unset. */
|
|
358
|
+
configured: string | null;
|
|
359
|
+
/** Layer that wins today: the configured value, else the built-in default. */
|
|
360
|
+
source: "tier-config" | "tier-default";
|
|
361
|
+
/** What that layer resolves to right now (`latest:` aliases resolved). Null when nothing qualifies. */
|
|
362
|
+
resolvedModel: string | null;
|
|
363
|
+
/** The `latest:` alias behind `resolvedModel`, when any. */
|
|
364
|
+
alias: string | null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Every provider x tier with its default, configured value and the model it
|
|
369
|
+
* resolves to now. Read-only: `latest:` aliases resolve without writing
|
|
370
|
+
* `model_alias_resolutions`. Ignores per-worker overrides and per-task models,
|
|
371
|
+
* so it answers "what does a `modelTier=<tier>` task get on a fresh <provider>
|
|
372
|
+
* worker".
|
|
373
|
+
*/
|
|
374
|
+
export async function previewModelTiers(
|
|
375
|
+
opts: { env?: Record<string, string | undefined>; now?: Date } = {},
|
|
376
|
+
): Promise<ModelTierPreview[]> {
|
|
377
|
+
const env = opts.env ?? process.env;
|
|
378
|
+
const rows: ModelTierPreview[] = [];
|
|
379
|
+
for (const provider of ProviderNameSchema.options) {
|
|
380
|
+
// A provider with no portable tier mapping (acp) has nothing to preview.
|
|
381
|
+
if (MODEL_TIERS.every((tier) => !DEFAULT_MODEL_TIER_MAP[provider][tier])) continue;
|
|
382
|
+
for (const tier of MODEL_TIERS) {
|
|
383
|
+
const key = tierConfigKey(provider, tier);
|
|
384
|
+
const configured = env[key]?.trim() || null;
|
|
385
|
+
const resolution = await resolveTaskModel({
|
|
386
|
+
modelTier: tier,
|
|
387
|
+
harnessProvider: provider,
|
|
388
|
+
env,
|
|
389
|
+
now: opts.now,
|
|
390
|
+
record: false,
|
|
391
|
+
});
|
|
392
|
+
rows.push({
|
|
393
|
+
provider,
|
|
394
|
+
tier,
|
|
395
|
+
key,
|
|
396
|
+
defaultValue: DEFAULT_MODEL_TIER_MAP[provider][tier],
|
|
397
|
+
configured,
|
|
398
|
+
source: resolution?.modelSource === "tier-config" ? "tier-config" : "tier-default",
|
|
399
|
+
resolvedModel: resolution?.resolvedModel ?? null,
|
|
400
|
+
alias: resolution?.modelAlias ?? null,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return rows;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Apply the claiming worker's CLI support (harness_model_support). Returns the
|
|
409
|
+
* resolution to use, or `{ unsupported }` when a concrete pinned task `model`
|
|
410
|
+
* is rejected by this CLI and must fail fast.
|
|
411
|
+
*
|
|
412
|
+
* Anything that came from an alias or a tier means "the newest usable model",
|
|
413
|
+
* so it falls back to the newest model of the same family the CLI has not
|
|
414
|
+
* rejected: a `latest:` alias on the task itself, a worker or global tier
|
|
415
|
+
* value, or a tier default (including the Claude CLI shortnames `opus`,
|
|
416
|
+
* `sonnet`, ...). With no usable sibling left the resolution stays and the run
|
|
417
|
+
* reports the CLI's own error. Only a concrete id the task pinned fails fast,
|
|
418
|
+
* because that is the one case where silently running another model would
|
|
419
|
+
* betray the request.
|
|
420
|
+
*/
|
|
421
|
+
async function applyCliSupport(
|
|
422
|
+
resolution: TaskModelResolution,
|
|
423
|
+
harness: string | null,
|
|
424
|
+
cliVersion: string | null,
|
|
425
|
+
): Promise<TaskModelResolution | { unsupported: string }> {
|
|
426
|
+
if (!harness || !cliVersion || !CLI_PINNED_HARNESSES.has(harness)) return resolution;
|
|
427
|
+
const status = await getHarnessModelSupport(harness, cliVersion, resolution.resolvedModel);
|
|
428
|
+
if (status !== "unsupported") return resolution;
|
|
429
|
+
if (resolution.modelSource === "model" && !resolution.modelAlias) {
|
|
430
|
+
return { unsupported: unsupportedModelMessage(harness, cliVersion, resolution.resolvedModel) };
|
|
431
|
+
}
|
|
432
|
+
const section = harnessCatalogSection(harness);
|
|
433
|
+
const { catalog, priced } = await loadResolutionCatalog();
|
|
434
|
+
// Same rule as alias resolution: a model with no price never becomes the pick, or the run
|
|
435
|
+
// would bypass cost accounting.
|
|
436
|
+
const candidates = Object.fromEntries(
|
|
437
|
+
Object.entries((section && catalog[section]?.models) || {}).filter(([id]) =>
|
|
438
|
+
priced.has(`${section}/${id}`),
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
const fallback = await fallbackForUnsupportedModel(
|
|
442
|
+
harness,
|
|
443
|
+
cliVersion,
|
|
444
|
+
resolution.resolvedModel,
|
|
445
|
+
candidates,
|
|
446
|
+
);
|
|
447
|
+
if (!fallback) return resolution;
|
|
448
|
+
noticeCliUnsupported(harness, cliVersion, resolution.resolvedModel, fallback);
|
|
449
|
+
return { ...resolution, resolvedModel: fallback, modelSource: "fallback:cli-unsupported" };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export type ClaimModelFields = Partial<TaskModelResolution> & {
|
|
453
|
+
/** Set when the task's explicit model is rejected by this worker's CLI; the worker fails the task. */
|
|
454
|
+
modelUnsupported?: string;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Resolve and record the model for a task the given agent is claiming. Runs
|
|
459
|
+
* inside the claim transaction. Returns the fields to merge into the trigger.
|
|
460
|
+
*/
|
|
461
|
+
export async function recordClaimModelResolution(
|
|
462
|
+
task: { id: string; model?: string | null; modelTier?: string | null },
|
|
463
|
+
agent: { id: string; harnessProvider?: ProviderName | null; provider?: ProviderName | null },
|
|
464
|
+
): Promise<ClaimModelFields> {
|
|
465
|
+
const harnessProvider = agent.harnessProvider ?? agent.provider ?? null;
|
|
466
|
+
const resolved = await resolveTaskModel({
|
|
467
|
+
model: task.model,
|
|
468
|
+
modelTier: task.modelTier,
|
|
469
|
+
harnessProvider,
|
|
470
|
+
workerOverrides: await getAgentModelTierOverrides(agent.id),
|
|
471
|
+
});
|
|
472
|
+
if (!resolved) return {};
|
|
473
|
+
const checked = await applyCliSupport(
|
|
474
|
+
resolved,
|
|
475
|
+
harnessProvider,
|
|
476
|
+
await getAgentHarnessCliVersion(agent.id),
|
|
477
|
+
);
|
|
478
|
+
if ("unsupported" in checked) {
|
|
479
|
+
return { modelUnsupported: checked.unsupported };
|
|
480
|
+
}
|
|
481
|
+
const resolution = checked;
|
|
482
|
+
await getDbClient().run(
|
|
483
|
+
"UPDATE agent_tasks SET resolvedModel = ?, modelSource = ?, modelAlias = ? WHERE id = ?",
|
|
484
|
+
[resolution.resolvedModel, resolution.modelSource, resolution.modelAlias, task.id],
|
|
485
|
+
);
|
|
486
|
+
return resolution.modelAlias
|
|
487
|
+
? resolution
|
|
488
|
+
: { resolvedModel: resolution.resolvedModel, modelSource: resolution.modelSource };
|
|
489
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates an EXPLICIT model id (a task, schedule, workflow node or agent
|
|
3
|
+
* runtime `model`) against the model catalog, so a typo fails at the call that
|
|
4
|
+
* made it instead of running as an unknown string ("claude-nonexistent-9").
|
|
5
|
+
*
|
|
6
|
+
* Known:
|
|
7
|
+
* - a `latest:<provider>/<family>` alias that is valid grammar and resolves to a catalog model;
|
|
8
|
+
* - a catalog id, bare (`claude-opus-5-5`, `gpt-5.6-sol`) or provider-qualified
|
|
9
|
+
* (`openrouter/deepseek/deepseek-v4.1-flash`, `amazon-bedrock/...`);
|
|
10
|
+
* - a Claude CLI shortname (`opus`, `sonnet`, ...), which the CLI resolves itself.
|
|
11
|
+
*
|
|
12
|
+
* Escape hatch: `allowCustomModel: true` on the call (tools, REST bodies, workflow node
|
|
13
|
+
* config, the agent runtime PATCH). Custom ids skip the check and are stored as given, for
|
|
14
|
+
* models the catalog cannot list yet (a fresh launch not in models.dev, a private
|
|
15
|
+
* deployment, an ACP-only model). Harnesses whose model namespace the catalog does not
|
|
16
|
+
* describe (`acp`, `devin`, `dsh`) skip the check when the caller names the harness.
|
|
17
|
+
*
|
|
18
|
+
* Claim time does not re-check: a task that was created before this check, or with the
|
|
19
|
+
* escape hatch, still runs.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
buildClaudeShortnameMap,
|
|
23
|
+
isAlias,
|
|
24
|
+
type ModelsDevCatalog,
|
|
25
|
+
parseAlias,
|
|
26
|
+
} from "@desplega/model-catalog";
|
|
27
|
+
import { getAgentById } from "./db";
|
|
28
|
+
import { loadModelsCatalog } from "./model-catalog-store";
|
|
29
|
+
import { resolveLatestAlias } from "./model-tier-resolution";
|
|
30
|
+
|
|
31
|
+
/** Harnesses whose model ids the catalog does not describe. */
|
|
32
|
+
const FREE_FORM_HARNESSES = new Set(["acp", "devin", "dsh"]);
|
|
33
|
+
|
|
34
|
+
/** Claude CLI context-window suffix (`sonnet[1m]`): the CLI reads it, the catalog does not list it. */
|
|
35
|
+
const CONTEXT_SUFFIX_RE = /\[1m\]$/i;
|
|
36
|
+
/** Bedrock cross-region inference profile prefix (`us.anthropic.claude-...`). */
|
|
37
|
+
const BEDROCK_REGION_PREFIX_RE = /^(?:us|eu|apac|global|us-gov)\./;
|
|
38
|
+
|
|
39
|
+
type CatalogSections = Record<string, { models?: Record<string, unknown> } | undefined>;
|
|
40
|
+
|
|
41
|
+
function hasModel(catalog: CatalogSections, provider: string, modelId: string): boolean {
|
|
42
|
+
const models = catalog[provider]?.models;
|
|
43
|
+
return Boolean(models && Object.hasOwn(models, modelId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `model` names something in the catalog (see the module doc). Pure. */
|
|
47
|
+
export function isKnownCatalogModel(model: string, catalog: CatalogSections): boolean {
|
|
48
|
+
const id = model.trim().replace(CONTEXT_SUFFIX_RE, "");
|
|
49
|
+
if (!id) return false;
|
|
50
|
+
const slash = id.indexOf("/");
|
|
51
|
+
if (slash > 0) {
|
|
52
|
+
const provider = id.slice(0, slash);
|
|
53
|
+
const rest = id.slice(slash + 1);
|
|
54
|
+
if (hasModel(catalog, provider, rest)) return true;
|
|
55
|
+
if (provider === "amazon-bedrock") {
|
|
56
|
+
return hasModel(catalog, provider, rest.replace(BEDROCK_REGION_PREFIX_RE, ""));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const provider of Object.keys(catalog)) {
|
|
60
|
+
if (hasModel(catalog, provider, id)) return true;
|
|
61
|
+
}
|
|
62
|
+
const anthropic = catalog.anthropic?.models as ModelsDevCatalog[string]["models"] | undefined;
|
|
63
|
+
return Object.hasOwn(buildClaudeShortnameMap(anthropic), id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ExplicitModelCheck {
|
|
67
|
+
/** The model the caller named. Empty or absent values pass: there is nothing to check. */
|
|
68
|
+
model: string | null | undefined;
|
|
69
|
+
/** Skip the check: the caller vouches for a custom id. */
|
|
70
|
+
allowCustomModel?: boolean;
|
|
71
|
+
/** The harness the model is for, when known (the assignee or the agent being configured). */
|
|
72
|
+
harnessProvider?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The error message for an unknown explicit model, or null when it is fine.
|
|
77
|
+
* An empty catalog (nothing to judge against) passes.
|
|
78
|
+
*/
|
|
79
|
+
export async function explicitModelError(check: ExplicitModelCheck): Promise<string | null> {
|
|
80
|
+
const model = check.model?.trim();
|
|
81
|
+
if (!model || check.allowCustomModel) return null;
|
|
82
|
+
if (check.harnessProvider && FREE_FORM_HARNESSES.has(check.harnessProvider)) return null;
|
|
83
|
+
|
|
84
|
+
if (isAlias(model)) {
|
|
85
|
+
if (!parseAlias(model)) {
|
|
86
|
+
return `Invalid model alias "${model}". Use latest:<provider>/<family>[@stable|@any], e.g. latest:anthropic/opus.`;
|
|
87
|
+
}
|
|
88
|
+
const resolved = await resolveLatestAlias(model, new Date(), { record: false });
|
|
89
|
+
return resolved
|
|
90
|
+
? null
|
|
91
|
+
: `Model alias "${model}" matches no model in the catalog. Check the provider and family, or use a concrete model id.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { providers } = await loadModelsCatalog();
|
|
95
|
+
const catalog = providers as CatalogSections;
|
|
96
|
+
if (Object.keys(catalog).length === 0) return null;
|
|
97
|
+
if (isKnownCatalogModel(model, catalog)) return null;
|
|
98
|
+
return `Unknown model "${model}": it is not in the model catalog. Use a catalog model id, a Claude CLI shortname (opus, sonnet, haiku, fable), a latest:<provider>/<family> alias, or modelTier. To run a custom model id anyway, set allowCustomModel: true (allow_custom_model on the agent runtime).`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `explicitModelError` for a model that will run on `agentId` (or on whichever
|
|
103
|
+
* worker claims it when there is none): the assignee's harness decides whether
|
|
104
|
+
* the catalog describes its models.
|
|
105
|
+
*/
|
|
106
|
+
export async function explicitModelErrorForAgent(
|
|
107
|
+
check: Omit<ExplicitModelCheck, "harnessProvider"> & { agentId?: string | null },
|
|
108
|
+
): Promise<string | null> {
|
|
109
|
+
if (!check.model?.trim() || check.allowCustomModel) return null;
|
|
110
|
+
const agent = check.agentId ? await getAgentById(check.agentId) : null;
|
|
111
|
+
return explicitModelError({ ...check, harnessProvider: agent?.harnessProvider ?? null });
|
|
112
|
+
}
|