one-code-extension 0.1.4 → 0.3.1
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/LICENSE +20 -0
- package/README.md +222 -177
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +128 -0
- package/docs/guide/configuration.md +173 -0
- package/docs/guide/differences-from-claude-code.md +152 -0
- package/docs/guide/doctor.md +88 -0
- package/docs/guide/hooks.md +111 -0
- package/docs/guide/installation.md +126 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
- package/docs/guide/providers-and-models.md +196 -0
- package/docs/guide/reference.md +226 -0
- package/docs/guide/sessions-and-context.md +192 -0
- package/docs/guide/skills-plugins-and-mcp.md +178 -0
- package/docs/guide/subagents-and-workflows.md +195 -0
- package/docs/guide/tasks-and-background-work.md +113 -0
- package/docs/guide/terminal-interface.md +157 -0
- package/docs/guide/tools.md +177 -0
- package/docs/guide/troubleshooting.md +177 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -1,99 +1,515 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Which system-prompt register the active model gets. Claude Code
|
|
3
|
-
* capability-tiered prompt — a terse one for
|
|
4
|
-
*
|
|
2
|
+
* Which system-prompt register + tool surface the active model gets. Claude Code
|
|
3
|
+
* ships a capability-tiered prompt — a terse one for Opus, a much longer, more
|
|
4
|
+
* explicit one for Sonnet/Haiku (Anthropic removed 80%+ of the prompt for
|
|
5
5
|
* Claude-5-gen models with no measured loss). One Code serves every provider, so
|
|
6
|
-
* its non-frontier audience
|
|
7
|
-
*
|
|
8
|
-
* registers; `extensions/system-prompt` picks the prompt text from the result.
|
|
6
|
+
* its non-frontier audience extends *below* Haiku; a single lean prompt
|
|
7
|
+
* under-instructs it. This module maps a model to one of FOUR tiers:
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* frontier Opus/Fable only — terse register (cc-opus), bash covers search
|
|
10
|
+
* workhorse Sonnet-class + capable third-party — verbose register (cc-sonnet)
|
|
11
|
+
* cheap Haiku-class — verbose register (cc-haiku), matches CC exactly
|
|
12
|
+
* tiny sub-Haiku models — verbose + weak-model scaffolding + grep/find/ls
|
|
13
|
+
*
|
|
14
|
+
* `extensions/system-prompt` picks the prompt text from the tier;
|
|
15
|
+
* `extensions/search-tools` activates grep/find/ls only for `tiny`. Rationale +
|
|
16
|
+
* rejected alternatives: `docs/decisions/model-tiers.md`.
|
|
17
|
+
*
|
|
18
|
+
* The classification obeys the repo convention against id-substring matching as
|
|
19
|
+
* the LEAD signal (see `auto-mode/model-select.ts`): frontier is a version-gated
|
|
20
|
+
* first-party Anthropic allowlist. Below it, a curated anchor map or the name
|
|
21
|
+
* class sets the tier and the model's GENERATION (models.dev release dates,
|
|
22
|
+
* `model-facts.ts`) demotes it — price is not consulted once facts are known, so
|
|
23
|
+
* an unpriced but current flagship on a hosted catalog keeps the workhorse
|
|
24
|
+
* register. Rows without facts fall back to the name-class cap, the "pro"-class
|
|
25
|
+
* hint and the absolute price floor, where unpriced/opaque means maximum
|
|
26
|
+
* scaffolding. The Artificial Analysis index never sets a register; it feeds the
|
|
27
|
+
* optional measured SELECTION floor (`capability-index.ts`) — see
|
|
28
|
+
* `classifyModelTier` and `docs/decisions/model-tiers.md`.
|
|
15
29
|
*/
|
|
16
30
|
|
|
17
31
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
18
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
capabilityFloor,
|
|
34
|
+
type CapabilitySnapshot,
|
|
35
|
+
type FloorRole,
|
|
36
|
+
loadCapabilitySnapshot,
|
|
37
|
+
referenceScore,
|
|
38
|
+
REGISTER_CHEAP_RATIO,
|
|
39
|
+
REGISTER_TINY_RATIO,
|
|
40
|
+
scoreFor,
|
|
41
|
+
} from "./capability-index.ts";
|
|
42
|
+
import { isPriorGeneration, lacksToolCalls, modelGeneration } from "./model-facts.ts";
|
|
43
|
+
import { baseModelId, isDatedDuplicate, modelIdentity, modelsContainedToSession, modelSpec, pricedInput } from "./model-policy.ts";
|
|
44
|
+
import { oneCodeStateDir } from "./paths.ts";
|
|
45
|
+
|
|
46
|
+
export type PromptTier = "frontier" | "workhorse" | "cheap" | "tiny";
|
|
47
|
+
|
|
48
|
+
/** More-scaffolded = higher rank. Used to combine the price floor with the name cap. */
|
|
49
|
+
const TIER_RANK: Record<PromptTier, number> = { frontier: 0, workhorse: 1, cheap: 2, tiny: 3 };
|
|
50
|
+
function moreScaffolded(a: PromptTier, b: PromptTier): PromptTier {
|
|
51
|
+
return TIER_RANK[a] >= TIER_RANK[b] ? a : b;
|
|
52
|
+
}
|
|
19
53
|
|
|
20
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Cost-preference order for automatic secondary-model selection: cheapest
|
|
56
|
+
* *capable* tier first, stepping UP only when a tier is empty. `tiny` is absent
|
|
57
|
+
* on purpose — automatic selection never lands there (see
|
|
58
|
+
* `economicalContainedCandidates`).
|
|
59
|
+
*/
|
|
60
|
+
const COST_PREFERENCE: Record<Exclude<PromptTier, "tiny">, number> = { cheap: 0, workhorse: 1, frontier: 2 };
|
|
21
61
|
|
|
22
62
|
/**
|
|
23
|
-
* `CC_PROMPT_TIER=frontier|
|
|
24
|
-
* Env only — a project must not be able to downgrade its own
|
|
25
|
-
* same reasoning that keeps `autoMode` out of project settings.
|
|
63
|
+
* `CC_PROMPT_TIER=frontier|workhorse|cheap|tiny` forces a register; `auto`/unset
|
|
64
|
+
* classifies. Env only — a project must not be able to downgrade its own
|
|
65
|
+
* scaffolding, the same reasoning that keeps `autoMode` out of project settings.
|
|
26
66
|
*/
|
|
27
67
|
export function tierOverride(env: NodeJS.ProcessEnv = process.env): PromptTier | undefined {
|
|
28
68
|
const raw = env.CC_PROMPT_TIER?.trim().toLowerCase();
|
|
29
|
-
return raw === "frontier" || raw === "
|
|
69
|
+
return raw === "frontier" || raw === "workhorse" || raw === "cheap" || raw === "tiny" ? raw : undefined;
|
|
30
70
|
}
|
|
31
71
|
|
|
32
72
|
/**
|
|
33
|
-
* Anthropic first-party frontier gate: Opus/Fable ≥ 4.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
73
|
+
* Anthropic first-party frontier gate: Opus/Fable ≥ 4.7 ONLY (Sonnet is
|
|
74
|
+
* deliberately excluded — CC gives only Opus the terse register, and the
|
|
75
|
+
* intelligence index would wrongly promote flash models to frontier at max
|
|
76
|
+
* effort; see `docs/decisions/model-tiers.md`). Adapted from pi-ai's
|
|
77
|
+
* `defaultSupportsToolReferences` — never Haiku, and the `length < 8` guard stops
|
|
78
|
+
* a dated suffix (`claude-opus-4-8-20251101`) being read as the minor version.
|
|
79
|
+
* Version parse, not price: `claude-opus-4-1` ($15/M) costs more than
|
|
80
|
+
* `opus-4-8`/`opus-5` ($5/M), so cost ranking would misclassify it.
|
|
38
81
|
*/
|
|
39
82
|
function isAnthropicFrontier(model: Model<Api>): boolean {
|
|
40
83
|
if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
|
|
41
|
-
const version = model.id
|
|
84
|
+
const version = parseClaudeVersion(model.id);
|
|
42
85
|
if (!version) return false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
if (version.family === "sonnet") return false; // Sonnet is workhorse, never frontier
|
|
87
|
+
return version.major > 4 || (version.major === 4 && version.minor >= 7);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Structural parse of a first-party Claude model id (`claude-opus-4-8`,
|
|
92
|
+
* `claude-sonnet-5`, `claude-opus-4-5-20251101`): family, major, minor. A
|
|
93
|
+
* dated suffix is never read as the minor version (the `length < 8` guard).
|
|
94
|
+
* Undefined for anything else. Shared by the frontier gate here and the
|
|
95
|
+
* tool-reference gate in `deferred.ts`.
|
|
96
|
+
*/
|
|
97
|
+
export function parseClaudeVersion(id: string): { family: "opus" | "sonnet" | "fable"; major: number; minor: number } | undefined {
|
|
98
|
+
const version = id.match(/^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
|
|
99
|
+
if (!version) return undefined;
|
|
100
|
+
return {
|
|
101
|
+
family: version[1] as "opus" | "sonnet" | "fable",
|
|
102
|
+
major: Number(version[2]),
|
|
103
|
+
minor: version[3] && version[3].length < 8 ? Number(version[3]) : 0,
|
|
104
|
+
};
|
|
47
105
|
}
|
|
48
106
|
|
|
49
107
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
108
|
+
* Curated anchor map for well-known third-party families, checked in order
|
|
109
|
+
* (specific before general). A vetted allowlist — distinct from heuristic
|
|
110
|
+
* substring matching — that fixes cases where price/name alone misroute a
|
|
111
|
+
* flagship, and encodes the ratified overrides (GPT-5-mini→cheap,
|
|
112
|
+
* GPT-5.6-Luna→cheap, GPT-5.x-codex-spark→cheap; the GPT-5-full→workhorse one
|
|
113
|
+
* was retired 2026-09-11 once the coding index contradicted it). A match here is authoritative for the
|
|
114
|
+
* name class (the human already accounted for the name and its generation), so
|
|
115
|
+
* it wins outright — only the two-generations-behind rule still demotes it to
|
|
116
|
+
* tiny (`classifyModelTier`). Shrink it whenever the catalog-wide snapshot
|
|
117
|
+
* (`test/unit/model-tier-catalog.test.ts`) shows the generic rules reproduce an
|
|
118
|
+
* entry; refresh the reference table with `tools/model-tiers/model_tiers.py`.
|
|
56
119
|
*/
|
|
57
|
-
const
|
|
120
|
+
const ANCHOR_MAP: Array<[RegExp, PromptTier]> = [
|
|
121
|
+
// OpenAI GPT-5 family — order matters: variant suffixes before the generic.
|
|
122
|
+
[/(?:^|[-/.])gpt-5[.\d]*-?nano/i, "tiny"],
|
|
123
|
+
[/(?:^|[-/.])gpt-5[.\d]*-?mini/i, "cheap"], // override: price would say tiny
|
|
124
|
+
[/(?:^|[-/.])gpt-5[.\d]*-?luna/i, "cheap"], // override: OpenAI's cheap line despite a high benchmark
|
|
125
|
+
// Spark: OpenAI's fast/small Codex line; no Artificial Analysis row either way, so it
|
|
126
|
+
// is placed with the other lean lines rather than inheriting the family's workhorse
|
|
127
|
+
// name class (2026-09-11 — it had been the cheapest "workhorse" on every Codex session).
|
|
128
|
+
[/(?:^|[-/.])gpt-5[.\d]*-codex-spark/i, "cheap"],
|
|
129
|
+
// No blanket `gpt-5 → workhorse` anchor any more (2026-09-11): it existed to guard
|
|
130
|
+
// the base GPT-5 variant against a benchmark collision, but the coding index now
|
|
131
|
+
// reads base gpt-5 at 37.8 and gpt-5.1 at 49.4 — the anchor was the lie, not the
|
|
132
|
+
// score. Unsuffixed gpt-5.x rows take the name class (workhorse) and let their
|
|
133
|
+
// generation and measured score demote them.
|
|
134
|
+
[/(?:^|[-/.])o[34](?:[-/.]|$)/i, "cheap"], // o3, o3-pro, o4-mini
|
|
135
|
+
[/(?:^|[-/.])gpt-4/i, "tiny"], // gpt-4o, gpt-4.1, gpt-4-turbo — prior generation
|
|
136
|
+
// Anthropic reaching this path is gateway-proxied (first-party handled above);
|
|
137
|
+
// a proxied model can't be version-verified for frontier, so Opus/Sonnet → workhorse.
|
|
138
|
+
[/claude[-/.].*haiku/i, "cheap"],
|
|
139
|
+
[/claude[-/.].*(?:sonnet|opus)/i, "workhorse"],
|
|
140
|
+
// Google Gemini needs no anchor: `lite` is a tiny name, `pro` a capable one, and
|
|
141
|
+
// bare flash is cheap by name class (generation decides 2.5 vs 3.x) — removed
|
|
142
|
+
// 2026-09-10 once the catalog-wide snapshot showed the generic rules reproduce it.
|
|
143
|
+
// Other capable flagships whose price may dip below the workhorse floor.
|
|
144
|
+
[/(?:^|[-/.])grok-4/i, "workhorse"],
|
|
145
|
+
// DeepSeek — priced ~10x below the vendors the price floors were calibrated
|
|
146
|
+
// on, so on the no-facts path the floors alone put every V4 Flash row in tiny
|
|
147
|
+
// and let R1-0528 ($0.50, on the cheap boundary) win automatic selection
|
|
148
|
+
// (2026-09-10). With release-date facts the generic rules reproduce the V4
|
|
149
|
+
// rows; the block stays for rows models.dev does not know yet (V4.1 Flash on
|
|
150
|
+
// its release day) and for R1/V3.x/`deepseek-chat`, which a one-generation
|
|
151
|
+
// demotion would leave at cheap rather than tiny.
|
|
152
|
+
[/(?:^|[-/.])deepseek[-.]v4[.\d]*-flash/i, "cheap"],
|
|
153
|
+
[/(?:^|[-/.])deepseek[-.]v4[.\d]*-pro/i, "workhorse"],
|
|
154
|
+
[/(?:^|[-/.])deepseek[-.](?:r1|chat|v3)(?:[-.:]|$)/i, "tiny"], // `deepseek.v3` is Bedrock's spelling
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
function anchorTier(id: string): PromptTier | undefined {
|
|
158
|
+
const base = baseModelId(id);
|
|
159
|
+
for (const [pattern, tier] of ANCHOR_MAP) if (pattern.test(base)) return tier;
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
58
162
|
|
|
59
163
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
164
|
+
* Name-class CAP: a lean/fast model class raises scaffolding (never lowers it).
|
|
165
|
+
* Delimited tokens — a bare `/mini/` matches inside "geMINI", dragging every
|
|
166
|
+
* Gemini model to cheap. `\d+b` catches size tags (8b, 70b) as the strongest
|
|
167
|
+
* (tiny) signal. Returns the ceiling tier the name justifies.
|
|
64
168
|
*/
|
|
65
|
-
const
|
|
169
|
+
const TINY_NAME_HINT = /(?:^|[-/.])(?:nano|micro|lite|tiny|xs|distill|instant|\d+b)(?:[-/.]|$)/i;
|
|
170
|
+
const CHEAP_NAME_HINT = /(?:^|[-/.])(?:flash|mini|small|air)(?:[-/.]|$)/i;
|
|
171
|
+
function nameClassCap(id: string): PromptTier {
|
|
172
|
+
const base = baseModelId(id); // `flash:batch` must still read as flash
|
|
173
|
+
if (TINY_NAME_HINT.test(base)) return "tiny";
|
|
174
|
+
if (CHEAP_NAME_HINT.test(base)) return "cheap";
|
|
175
|
+
return "workhorse";
|
|
176
|
+
}
|
|
66
177
|
|
|
67
178
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* (more scaffolding never hurts a cheap model), per the "unknown → low" decision.
|
|
179
|
+
* A capable-class name marks the flagship of a family (deepseek-v4-pro,
|
|
180
|
+
* qwen3-max) — keeps a cheap or unpriced flagship at workhorse rather than being
|
|
181
|
+
* dragged down by the price floor. Delimited so it fires on `-pro`/`max-` but not
|
|
182
|
+
* `prometheus`/`improved`. Only applies when no lean-name cap contradicts it.
|
|
73
183
|
*/
|
|
74
|
-
const
|
|
184
|
+
const CAPABLE_NAME_HINT = /(?:^|[-/.])(?:pro|max|ultra|large)(?:[-/.]|$)/i;
|
|
185
|
+
|
|
186
|
+
/** Input price (USD/1M) floors: capable flagships sit ≥ $1/M; mini/flash under $0.5. */
|
|
187
|
+
const WORKHORSE_MIN_INPUT_COST = 1.0;
|
|
188
|
+
const CHEAP_MIN_INPUT_COST = 0.5;
|
|
189
|
+
|
|
190
|
+
/** Price/containment floor for an unanchored third-party model, biased low. */
|
|
191
|
+
function priceFloorTier(model: Model<Api>): PromptTier {
|
|
192
|
+
if (modelIdentity(model).confidence === "opaque") return "tiny"; // local/self-hosted → max scaffolding
|
|
193
|
+
const price = pricedInput(model);
|
|
194
|
+
if (price === undefined) return "tiny"; // unpriced/unknown → maximum scaffolding
|
|
195
|
+
if (price >= WORKHORSE_MIN_INPUT_COST) return "workhorse";
|
|
196
|
+
if (price >= CHEAP_MIN_INPUT_COST) return "cheap";
|
|
197
|
+
return "tiny";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One step more scaffolded: workhorse → cheap → tiny (frontier never enters). */
|
|
201
|
+
function demote(tier: PromptTier): PromptTier {
|
|
202
|
+
return tier === "workhorse" ? "cheap" : "tiny";
|
|
203
|
+
}
|
|
75
204
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
205
|
+
export interface TierClassification {
|
|
206
|
+
tier: PromptTier;
|
|
207
|
+
/** Which rule decided, for the catalog-wide snapshot and the doctor report. */
|
|
208
|
+
reason: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The tier and the rule that produced it. `resolveModelTier` is the plain form.
|
|
213
|
+
*
|
|
214
|
+
* Non-frontier, non-first-party models are classified in this order:
|
|
215
|
+
* 1. the curated anchor map (verifiable providers only) sets the name-class tier;
|
|
216
|
+
* 2. otherwise the name class does (pro/max/unmarked → workhorse, flash/mini →
|
|
217
|
+
* cheap, nano/lite/size-tag → tiny) — when the model has release-date facts.
|
|
218
|
+
* Then its GENERATION demotes: one tier when it trails its vendor family's
|
|
219
|
+
* newest release by more than a year (name-class path only — an anchor is
|
|
220
|
+
* human-reviewed), to tiny beyond two for both. Price is not
|
|
221
|
+
* consulted: it encodes neither capability nor generation (an old model
|
|
222
|
+
* keeps its old price), and absolute dollar floors misread every vendor
|
|
223
|
+
* priced below the US majors — `model-facts.ts`.
|
|
224
|
+
* 3. without facts, the pre-facts heuristics: name-class cap, the capable-name
|
|
225
|
+
* hint (verifiable providers only), and the absolute price floor, combined
|
|
226
|
+
* as the more scaffolded of the two.
|
|
227
|
+
*/
|
|
228
|
+
export function classifyModelTier(model: Model<Api> | undefined, env: NodeJS.ProcessEnv = process.env): TierClassification {
|
|
229
|
+
const named = classifyByName(model, env);
|
|
230
|
+
// The measured cap: a name can only lie UPWARD about capability that a score
|
|
231
|
+
// then corrects (gpt-5 at 37.8 is not a Sonnet-class model whatever its
|
|
232
|
+
// anchor said), and downward it is already the safer register, so the score
|
|
233
|
+
// only ever adds scaffolding. Frontier (a first-party allowlist), Anthropic
|
|
234
|
+
// first-party (the scale's own anchors), a forced tier and an already-tiny
|
|
235
|
+
// row are left alone.
|
|
236
|
+
if (!model || named.reason === "CC_PROMPT_TIER" || named.tier === "frontier" || named.tier === "tiny" || model.provider === "anthropic") return named;
|
|
237
|
+
const measured = measuredRegisterCap(model);
|
|
238
|
+
if (!measured) return named;
|
|
239
|
+
const tier = moreScaffolded(named.tier, measured.cap);
|
|
240
|
+
if (tier === named.tier) return named;
|
|
241
|
+
return { tier, reason: `${named.reason} · measured coding ${measured.coding} below the ${measured.cap} line (${measured.threshold.toFixed(1)})` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The register cap the Artificial Analysis snapshot supports for a model, if
|
|
246
|
+
* any: `cheap` below REGISTER_CHEAP_RATIO × Sonnet 5, `tiny` below
|
|
247
|
+
* REGISTER_TINY_RATIO × Sonnet 5, judged on the default (thinking-on) variant.
|
|
248
|
+
* Undefined without a snapshot, a confirmed score, or a reference score — the
|
|
249
|
+
* name-led tier then stands, as before the snapshot existed.
|
|
250
|
+
*/
|
|
251
|
+
export function measuredRegisterCap(model: Model<Api>): { cap: "cheap" | "tiny"; coding: number; threshold: number } | undefined {
|
|
252
|
+
const snapshot = currentCapabilitySnapshot();
|
|
253
|
+
if (!snapshot) return undefined;
|
|
254
|
+
const score = scoreFor(snapshot, model, "default");
|
|
255
|
+
const reference = referenceScore(snapshot, "default");
|
|
256
|
+
if (!score || !reference) return undefined;
|
|
257
|
+
const tinyLine = reference.coding * REGISTER_TINY_RATIO;
|
|
258
|
+
if (score.coding < tinyLine) return { cap: "tiny", coding: score.coding, threshold: tinyLine };
|
|
259
|
+
const cheapLine = reference.coding * REGISTER_CHEAP_RATIO;
|
|
260
|
+
if (score.coding < cheapLine) return { cap: "cheap", coding: score.coding, threshold: cheapLine };
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The name-led classification (anchors, name class, generation, price) before the measured cap. */
|
|
265
|
+
function classifyByName(model: Model<Api> | undefined, env: NodeJS.ProcessEnv): TierClassification {
|
|
80
266
|
const forced = tierOverride(env);
|
|
81
|
-
if (forced) return forced;
|
|
82
|
-
if (!model) return "
|
|
267
|
+
if (forced) return { tier: forced, reason: "CC_PROMPT_TIER" };
|
|
268
|
+
if (!model) return { tier: "tiny", reason: "no model" }; // unknown model → maximum scaffolding
|
|
83
269
|
|
|
84
|
-
if (isAnthropicFrontier(model)) return "frontier";
|
|
85
|
-
if (model.provider === "anthropic")
|
|
270
|
+
if (isAnthropicFrontier(model)) return { tier: "frontier", reason: "anthropic frontier allowlist" };
|
|
271
|
+
if (model.provider === "anthropic") {
|
|
272
|
+
// First-party non-frontier: Haiku → cheap; Sonnet / Opus 4.1–4.6 / other →
|
|
273
|
+
// workhorse. Version-named ids already encode generation, and the tiny
|
|
274
|
+
// register would be wrong for a still-sold Opus, so no date demotion here;
|
|
275
|
+
// automatic selection still skips prior-generation rows.
|
|
276
|
+
return model.id.includes("haiku") ? { tier: "cheap", reason: "anthropic haiku" } : { tier: "workhorse", reason: "anthropic first-party" };
|
|
277
|
+
}
|
|
86
278
|
|
|
87
|
-
// A
|
|
88
|
-
//
|
|
89
|
-
|
|
279
|
+
// A curated anchor is authoritative for the NAME CLASS — but only for a
|
|
280
|
+
// verifiable provider. A local/self-hosted (opaque) provider's id could be
|
|
281
|
+
// anything, so it must NOT reach the anchor map (a model aliased
|
|
282
|
+
// "claude-sonnet-5" on ollama is not the real thing) nor the capable-name
|
|
283
|
+
// hint; it falls through to the generic heuristics, where the opaque check
|
|
284
|
+
// biases it to maximum scaffolding.
|
|
285
|
+
const opaque = modelIdentity(model).confidence === "opaque";
|
|
286
|
+
const anchor = opaque ? undefined : anchorTier(model.id);
|
|
90
287
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
288
|
+
const generation = opaque ? undefined : modelGeneration(model);
|
|
289
|
+
if (generation !== undefined) {
|
|
290
|
+
const nameTier = anchor ?? nameClassCap(model.id); // never frontier: anchors and name classes stop at workhorse
|
|
291
|
+
if (generation === "ancient") return { tier: "tiny", reason: `${anchor ? "anchor" : "name class"} · two generations behind` };
|
|
292
|
+
// A curated anchor already weighed the model's generation (it is reviewed
|
|
293
|
+
// against the catalog diff), so only the name-class path is demoted here.
|
|
294
|
+
if (generation === "prior" && !anchor) return { tier: demote(nameTier), reason: "name class · one generation behind" };
|
|
295
|
+
return { tier: nameTier, reason: anchor ? "anchor" : "name class" };
|
|
296
|
+
}
|
|
297
|
+
if (anchor) return { tier: anchor, reason: "anchor" };
|
|
298
|
+
|
|
299
|
+
const cap = nameClassCap(model.id);
|
|
300
|
+
// A "pro"/"max"-class name keeps a cheap/unpriced flagship at workhorse; a lean
|
|
301
|
+
// name (cap below workhorse) always wins, so only consult it when cap allows.
|
|
302
|
+
if (!opaque && cap === "workhorse" && CAPABLE_NAME_HINT.test(baseModelId(model.id))) return { tier: "workhorse", reason: "capable name, no facts" };
|
|
303
|
+
const floor = priceFloorTier(model);
|
|
304
|
+
const tier = moreScaffolded(floor, cap);
|
|
305
|
+
return { tier, reason: opaque ? "opaque provider" : pricedInput(model) === undefined ? "unpriced, no facts" : "price floor, no facts" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function resolveModelTier(model: Model<Api> | undefined, env: NodeJS.ProcessEnv = process.env): PromptTier {
|
|
309
|
+
return classifyModelTier(model, env).tier;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The ordered candidate chain for an automatic *secondary* model — the same
|
|
315
|
+
* mechanism the auto-mode classifier and subagents both consume, so a session
|
|
316
|
+
* screens and delegates on one economical same-provider model.
|
|
317
|
+
*
|
|
318
|
+
* The chain is:
|
|
319
|
+
* - **contained** to the session's provider (and route/family on gateways):
|
|
320
|
+
* the classifier reads the user's prompts + CLAUDE.md and subagents inherit
|
|
321
|
+
* the parent transcript, so leaving the session's provider silently is a
|
|
322
|
+
* privacy leak — only an explicit setting may cross (see the two model-select
|
|
323
|
+
* modules);
|
|
324
|
+
* - never `tiny`: a sub-Haiku model is a weak security boundary and a weak
|
|
325
|
+
* coding worker, so automatic selection stops at `cheap` and steps UP
|
|
326
|
+
* (cheap → workhorse → frontier), never down. This is the capability floor
|
|
327
|
+
* `docs/decisions/auto-mode.md` recorded as still-missing;
|
|
328
|
+
* - current generation only, and able to call tools: with models.dev facts
|
|
329
|
+
* known (`model-facts.ts`), a row more than a year behind its vendor family's
|
|
330
|
+
* newest release is excluded whatever its price or name, as is a row marked
|
|
331
|
+
* as lacking tool calls. Rows without facts pass this gate unchanged;
|
|
332
|
+
* - priced only: an unpriced/opaque row is treated as `tiny` by
|
|
333
|
+
* `resolveModelTier`, so this excludes it too. On a provider with no usable
|
|
334
|
+
* prices the chain is empty and callers degrade to the session model
|
|
335
|
+
* (correct, merely not cheap).
|
|
336
|
+
*
|
|
337
|
+
* Sorted by cost preference (cheapest capable *tier* first) then input price, so
|
|
338
|
+
* the head is "the cheapest capable model this provider offers." The session
|
|
339
|
+
* model may appear in the chain; the budget-gated `cheaperContainedCandidates`
|
|
340
|
+
* drops it. Budget ceilings and strictly-cheaper-than-session are caller policy —
|
|
341
|
+
* this function ranks, it does not gate. A caller that already computed the
|
|
342
|
+
* containment set may pass it as `contained` to skip the O(catalog) recompute.
|
|
343
|
+
*/
|
|
344
|
+
export function economicalContainedCandidates(
|
|
345
|
+
available: Model<Api>[],
|
|
346
|
+
sessionModel: Model<Api>,
|
|
347
|
+
contained?: Model<Api>[],
|
|
348
|
+
): Model<Api>[] {
|
|
349
|
+
const pool = contained ?? modelsContainedToSession(available, sessionModel);
|
|
350
|
+
return pool
|
|
351
|
+
// A dated snapshot whose undated alias is also listed is the same model
|
|
352
|
+
// twice; rank the alias so every automatic pick (classifier, subagent,
|
|
353
|
+
// reader, presets) names the model the way the user sees it in /model.
|
|
354
|
+
.filter((model) => !isDatedDuplicate(model, pool))
|
|
355
|
+
// Facts, when known: a model one generation behind its family is never
|
|
356
|
+
// picked automatically however it prices (the R1-over-V4-Flash incident),
|
|
357
|
+
// and a subagent or screener must be able to call tools at all.
|
|
358
|
+
.filter((model) => !isPriorGeneration(model) && !lacksToolCalls(model))
|
|
359
|
+
// Classify by the model's INTRINSIC tier — never `process.env`: CC_PROMPT_TIER
|
|
360
|
+
// forces the *session's* prompt-scaffolding register, and honoring it here
|
|
361
|
+
// would collapse every candidate to one tier and let a `tiny` model through
|
|
362
|
+
// the security floor. Selection must judge each model on its own merits.
|
|
363
|
+
.map((model) => ({ model, tier: resolveModelTier(model, INTRINSIC_TIER_ENV), price: pricedInput(model) }))
|
|
364
|
+
.filter(
|
|
365
|
+
(entry): entry is { model: Model<Api>; tier: Exclude<PromptTier, "tiny">; price: number } =>
|
|
366
|
+
entry.tier !== "tiny" && entry.price !== undefined,
|
|
367
|
+
)
|
|
368
|
+
.sort((a, b) => COST_PREFERENCE[a.tier] - COST_PREFERENCE[b.tier] || a.price - b.price)
|
|
369
|
+
.map((entry) => entry.model);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** No CC_PROMPT_TIER override: automatic model *selection* always uses intrinsic tiers. */
|
|
373
|
+
const INTRINSIC_TIER_ENV: NodeJS.ProcessEnv = Object.freeze({}) as NodeJS.ProcessEnv;
|
|
374
|
+
|
|
375
|
+
/** A model's own tier, CC_PROMPT_TIER ignored — what selection judges a candidate by. */
|
|
376
|
+
export function intrinsicTier(model: Model<Api>): PromptTier {
|
|
377
|
+
return resolveModelTier(model, INTRINSIC_TIER_ENV);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Whether `tier` is at least as capable as `floor` (frontier ≥ workhorse ≥ cheap ≥ tiny). */
|
|
381
|
+
export function atLeastTier(tier: PromptTier, floor: PromptTier): boolean {
|
|
382
|
+
return TIER_RANK[tier] <= TIER_RANK[floor];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The budget-gated form the automatic secondary-model pickers all share: same as
|
|
387
|
+
* `economicalContainedCandidates`, minus the session model itself and anything
|
|
388
|
+
* dearer than it. `strict` requires *strictly* cheaper (subagents never upgrade a
|
|
389
|
+
* cheap session); non-strict allows equal price (the classifier and reader
|
|
390
|
+
* tolerate a same-price screener). With the session price unknown there is no
|
|
391
|
+
* demonstrable saving, so `strict` yields nothing while non-strict keeps the
|
|
392
|
+
* tier-ranked list. `contained` is forwarded to skip the containment recompute.
|
|
393
|
+
*/
|
|
394
|
+
export function cheaperContainedCandidates(
|
|
395
|
+
available: Model<Api>[],
|
|
396
|
+
sessionModel: Model<Api>,
|
|
397
|
+
opts: { strict?: boolean; contained?: Model<Api>[]; role?: FloorRole } = {},
|
|
398
|
+
): Model<Api>[] {
|
|
399
|
+
const sessionSpec = modelSpec(sessionModel);
|
|
400
|
+
const sessionPrice = pricedInput(sessionModel);
|
|
401
|
+
const cheaper = economicalContainedCandidates(available, sessionModel, opts.contained).filter((model) => {
|
|
402
|
+
if (modelSpec(model) === sessionSpec) return false;
|
|
403
|
+
if (sessionPrice === undefined) return !opts.strict;
|
|
404
|
+
const price = pricedInput(model);
|
|
405
|
+
return price !== undefined && (opts.strict ? price < sessionPrice : price <= sessionPrice);
|
|
406
|
+
});
|
|
407
|
+
return opts.role ? rankByCapability(cheaper, sessionModel, opts.role).map((entry) => entry.model) : cheaper;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The weakest tier an AUTOMATIC pick may run on for a session: workhorse when
|
|
412
|
+
* the session itself is workhorse or frontier (Claude Code's `min(main, sonnet)`
|
|
413
|
+
* classifier rule), cheap otherwise (a cheap session has nothing cheaper and
|
|
414
|
+
* capable; `tiny` is excluded upstream regardless). Shared by the auto-mode
|
|
415
|
+
* classifier and the subagent default since 2026-09-11: a delegated worker
|
|
416
|
+
* writes code and calls tools for many turns, and a weak one spends the saving
|
|
417
|
+
* on retries — so both roles are held to one floor and, on most providers, one
|
|
418
|
+
* model (docs/decisions/model-policy.md).
|
|
419
|
+
*/
|
|
420
|
+
export function automaticTierFloor(sessionModel: Model<Api>): PromptTier {
|
|
421
|
+
const tier = intrinsicTier(sessionModel);
|
|
422
|
+
return tier === "frontier" || tier === "workhorse" ? "workhorse" : "cheap";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The floor-gated form the classifier and the subagent default share: the
|
|
427
|
+
* cheaper contained candidates (`cheaperContainedCandidates`) that either
|
|
428
|
+
* measurably reach the role's capability floor (an Artificial Analysis
|
|
429
|
+
* snapshot, when one exists — `capability-index.ts`) or, unscored, sit at or
|
|
430
|
+
* above `automaticTierFloor(sessionModel)` by name class. Measured failures are
|
|
431
|
+
* already dropped upstream. The head is "the cheapest model this provider
|
|
432
|
+
* offers that is capable enough for the session".
|
|
433
|
+
*/
|
|
434
|
+
export function capableContainedCandidates(
|
|
435
|
+
available: Model<Api>[],
|
|
436
|
+
sessionModel: Model<Api>,
|
|
437
|
+
role: FloorRole,
|
|
438
|
+
opts: { strict?: boolean; contained?: Model<Api>[] } = {},
|
|
439
|
+
): Model<Api>[] {
|
|
440
|
+
const floor = automaticTierFloor(sessionModel);
|
|
441
|
+
return rankedContainedCandidates(available, sessionModel, role, opts)
|
|
442
|
+
.filter(({ model, measured }) => measured === "pass" || atLeastTier(intrinsicTier(model), floor))
|
|
443
|
+
.map((entry) => entry.model);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
interface RankedCandidate {
|
|
447
|
+
model: Model<Api>;
|
|
448
|
+
/** "pass": measurably reaches the floor; "unscored": no snapshot or no confirmed score (the caller's name-class rule decides). */
|
|
449
|
+
measured: "pass" | "unscored";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `cheaperContainedCandidates` with the measured verdict kept on each entry, so a
|
|
454
|
+
* caller that must tell a measured pass from an unscored candidate (the
|
|
455
|
+
* classifier's name-class fallback) reads it instead of recomputing it.
|
|
456
|
+
*/
|
|
457
|
+
function rankedContainedCandidates(
|
|
458
|
+
available: Model<Api>[],
|
|
459
|
+
sessionModel: Model<Api>,
|
|
460
|
+
role: FloorRole,
|
|
461
|
+
opts: { strict?: boolean; contained?: Model<Api>[] } = {},
|
|
462
|
+
): RankedCandidate[] {
|
|
463
|
+
return rankByCapability(cheaperContainedCandidates(available, sessionModel, opts), sessionModel, role);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The measured capability floor (`capability-index.ts`) over a tier-ranked
|
|
468
|
+
* candidate list: candidates the Artificial Analysis snapshot shows reaching
|
|
469
|
+
* min(session, Sonnet 5) come first, cheapest first — capability per dollar is
|
|
470
|
+
* the question here, and a flash-class model that measurably matches the
|
|
471
|
+
* session model should beat a dearer "workhorse"-named one. Measured failures
|
|
472
|
+
* are dropped. Unscored candidates (no key, no confirmed match) follow in their
|
|
473
|
+
* tier order, for the caller's name-class rule to judge. The score never admits
|
|
474
|
+
* anything the tier gate already refused (tiny, prior generation, no tools).
|
|
475
|
+
*/
|
|
476
|
+
function rankByCapability(candidates: Model<Api>[], sessionModel: Model<Api>, role: FloorRole): RankedCandidate[] {
|
|
477
|
+
const snapshot = currentCapabilitySnapshot();
|
|
478
|
+
if (!snapshot) return candidates.map((model) => ({ model, measured: "unscored" }));
|
|
479
|
+
const measured: RankedCandidate[] = [];
|
|
480
|
+
const unscored: RankedCandidate[] = [];
|
|
481
|
+
for (const model of candidates) {
|
|
482
|
+
const verdict = capabilityFloor(snapshot, model, sessionModel, role).verdict;
|
|
483
|
+
if (verdict === "pass") measured.push({ model, measured: "pass" });
|
|
484
|
+
else if (verdict === "unscored") unscored.push({ model, measured: "unscored" });
|
|
485
|
+
}
|
|
486
|
+
const price = (entry: RankedCandidate) => pricedInput(entry.model) ?? Number.POSITIVE_INFINITY;
|
|
487
|
+
return [...measured.sort((a, b) => price(a) - price(b)), ...unscored];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The cached Artificial Analysis snapshot, if a key has ever produced one (`capability-index.ts`). */
|
|
491
|
+
export function currentCapabilitySnapshot(): CapabilitySnapshot | undefined {
|
|
492
|
+
return loadCapabilitySnapshot(oneCodeStateDir());
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface EconomicalModelChoice {
|
|
496
|
+
model: Model<Api>;
|
|
497
|
+
via: "tier" | "session";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The cheapest capable same-provider model, else the session model itself. Used
|
|
502
|
+
* for low-stakes one-shot jobs over untrusted content (the web_fetch reader,
|
|
503
|
+
* the recap) — the content goes to the model, so containment applies. Never
|
|
504
|
+
* dearer than the session model, and never a `tiny`-tier model
|
|
505
|
+
* (`economicalContainedCandidates` enforces both). Not used for compaction,
|
|
506
|
+
* which runs on the session model to reuse the provider prompt cache.
|
|
507
|
+
*/
|
|
508
|
+
export function pickEconomicalContainedModel(
|
|
509
|
+
available: Model<Api>[],
|
|
510
|
+
sessionModel: Model<Api> | undefined,
|
|
511
|
+
): EconomicalModelChoice | undefined {
|
|
512
|
+
if (!sessionModel) return undefined;
|
|
513
|
+
const cheaper = cheaperContainedCandidates(available, sessionModel, { role: "reader" })[0];
|
|
514
|
+
return cheaper ? { model: cheaper, via: "tier" } : { model: sessionModel, via: "session" };
|
|
99
515
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models this account cannot use, learned at runtime and shared across the
|
|
3
|
+
* extensions that pick models automatically.
|
|
4
|
+
*
|
|
5
|
+
* The catalog says what a provider *offers*; only a request says what this
|
|
6
|
+
* account may *run* (`openai-codex/gpt-5.3-codex-spark` is "not supported when
|
|
7
|
+
* using Codex with a ChatGPT account"). The auto-mode classifier already learns
|
|
8
|
+
* that on its first call and steps to the next candidate (`ClassifierState.
|
|
9
|
+
* rejected`). Since subagents share the classifier's selection floor
|
|
10
|
+
* (docs/decisions/model-policy.md, 2026-09-11) they land on the same first
|
|
11
|
+
* pick — so what one role learns must reach the other, or every session's
|
|
12
|
+
* first subagent fails on a model the classifier already gave up on.
|
|
13
|
+
*
|
|
14
|
+
* Extensions are jiti-isolated (no shared module state), so the fact travels on
|
|
15
|
+
* `pi.events`: whoever learns it emits `MODEL_UNUSABLE_CHANNEL`; the auto-mode
|
|
16
|
+
* gate adds it to its `rejected` set, the subagent and workflow extensions drop
|
|
17
|
+
* it from the catalog they resolve against. Session-scoped by construction
|
|
18
|
+
* (process memory) — an entitlement can change between sessions.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
22
|
+
import { modelSpec } from "./model-policy.ts";
|
|
23
|
+
|
|
24
|
+
export const MODEL_UNUSABLE_CHANNEL = "one-code:model-unusable";
|
|
25
|
+
|
|
26
|
+
export interface ModelUnusableEvent {
|
|
27
|
+
/** `provider/id` of the model the account cannot run. */
|
|
28
|
+
model: string;
|
|
29
|
+
/** The provider's own words, for the notice. */
|
|
30
|
+
reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The catalog minus the models this session has found unusable. */
|
|
34
|
+
export function withoutUnusable(available: Model<Api>[], unusable: ReadonlySet<string>): Model<Api>[] {
|
|
35
|
+
if (unusable.size === 0) return available;
|
|
36
|
+
return available.filter((model) => !unusable.has(modelSpec(model)));
|
|
37
|
+
}
|