@yycholla/pi-dynamic-workflows 3.3.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/LICENSE +22 -0
- package/README.md +325 -0
- package/assets/readme/hero.png +0 -0
- package/assets/readme/hero.svg +70 -0
- package/assets/readme/package-cover.png +0 -0
- package/assets/readme/package-cover.svg +66 -0
- package/assets/readme/workflow.png +0 -0
- package/assets/readme/workflow.svg +77 -0
- package/dist/accept-workflow-guidance.d.ts +14 -0
- package/dist/accept-workflow-guidance.js +53 -0
- package/dist/adversarial-review.d.ts +30 -0
- package/dist/adversarial-review.js +107 -0
- package/dist/agent-history.d.ts +20 -0
- package/dist/agent-history.js +122 -0
- package/dist/agent-registry.d.ts +83 -0
- package/dist/agent-registry.js +190 -0
- package/dist/agent.d.ts +364 -0
- package/dist/agent.js +714 -0
- package/dist/builtin-commands.d.ts +19 -0
- package/dist/builtin-commands.js +251 -0
- package/dist/builtin-workflows.d.ts +45 -0
- package/dist/builtin-workflows.js +121 -0
- package/dist/code-review.d.ts +26 -0
- package/dist/code-review.js +181 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +44 -0
- package/dist/deep-research.d.ts +30 -0
- package/dist/deep-research.js +124 -0
- package/dist/display.d.ts +134 -0
- package/dist/display.js +248 -0
- package/dist/effort-command.d.ts +28 -0
- package/dist/effort-command.js +68 -0
- package/dist/enums.d.ts +69 -0
- package/dist/enums.js +78 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +140 -0
- package/dist/extension-reload.d.ts +37 -0
- package/dist/extension-reload.js +78 -0
- package/dist/fs-persistence.d.ts +63 -0
- package/dist/fs-persistence.js +102 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +35 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +66 -0
- package/dist/model-routing.d.ts +30 -0
- package/dist/model-routing.js +50 -0
- package/dist/model-spec.d.ts +29 -0
- package/dist/model-spec.js +252 -0
- package/dist/model-tier-config.d.ts +133 -0
- package/dist/model-tier-config.js +249 -0
- package/dist/run-persistence.d.ts +180 -0
- package/dist/run-persistence.js +294 -0
- package/dist/saved-commands.d.ts +28 -0
- package/dist/saved-commands.js +100 -0
- package/dist/shared-store.d.ts +98 -0
- package/dist/shared-store.js +212 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/task-panel.d.ts +61 -0
- package/dist/task-panel.js +422 -0
- package/dist/usage-limit-scheduler.d.ts +145 -0
- package/dist/usage-limit-scheduler.js +368 -0
- package/dist/web-tools.d.ts +20 -0
- package/dist/web-tools.js +120 -0
- package/dist/workflow-authoring-coverage.d.ts +70 -0
- package/dist/workflow-authoring-coverage.js +421 -0
- package/dist/workflow-authoring-reference.d.ts +20 -0
- package/dist/workflow-authoring-reference.js +156 -0
- package/dist/workflow-capability-contract.d.ts +131 -0
- package/dist/workflow-capability-contract.js +604 -0
- package/dist/workflow-commands.d.ts +18 -0
- package/dist/workflow-commands.js +260 -0
- package/dist/workflow-comprehension.d.ts +133 -0
- package/dist/workflow-comprehension.js +1321 -0
- package/dist/workflow-context-measurement.d.ts +72 -0
- package/dist/workflow-context-measurement.js +213 -0
- package/dist/workflow-control-tool.d.ts +30 -0
- package/dist/workflow-control-tool.js +176 -0
- package/dist/workflow-delivery-choice.d.ts +20 -0
- package/dist/workflow-delivery-choice.js +48 -0
- package/dist/workflow-editor.d.ts +93 -0
- package/dist/workflow-editor.js +363 -0
- package/dist/workflow-manager.d.ts +492 -0
- package/dist/workflow-manager.js +1124 -0
- package/dist/workflow-paths.d.ts +22 -0
- package/dist/workflow-paths.js +46 -0
- package/dist/workflow-release-gate.d.ts +39 -0
- package/dist/workflow-release-gate.js +309 -0
- package/dist/workflow-saved.d.ts +38 -0
- package/dist/workflow-saved.js +126 -0
- package/dist/workflow-settings.d.ts +70 -0
- package/dist/workflow-settings.js +131 -0
- package/dist/workflow-tool.d.ts +71 -0
- package/dist/workflow-tool.js +367 -0
- package/dist/workflow-ui.d.ts +182 -0
- package/dist/workflow-ui.js +1587 -0
- package/dist/workflow.d.ts +333 -0
- package/dist/workflow.js +1151 -0
- package/dist/workflows-models-command.d.ts +31 -0
- package/dist/workflows-models-command.js +156 -0
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/extensions/workflow.ts +151 -0
- package/package.json +104 -0
- package/skills/workflow-authoring/SKILL.md +30 -0
- package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
- package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
- package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
- package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
- package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
- package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
- package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
- package/skills/workflow-authoring/examples/structured-output.js +37 -0
- package/skills/workflow-authoring/examples/tournament.js +81 -0
- package/skills/workflow-authoring/examples/validated-gate.js +63 -0
- package/skills/workflow-authoring/references/capabilities.md +41 -0
- package/skills/workflow-authoring/references/capability-details.md +357 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +27 -0
- package/skills/workflow-authoring/references/focused-recipes.md +11 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +33 -0
- package/skills/workflow-authoring/references/pattern-selection.md +15 -0
- package/skills/workflow-authoring/references/quality-helpers.md +8 -0
- package/skills/workflow-authoring/references/registry-ownership.md +15 -0
- package/skills/workflow-authoring/references/retry-helper.md +5 -0
- package/skills/workflow-authoring/references/review.md +43 -0
- package/skills/workflow-authoring/references/runtime.md +27 -0
- package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
- package/skills/workflow-authoring/references/versions.md +13 -0
- package/skills/workflow-patterns/SKILL.md +51 -0
- package/src/accept-workflow-guidance.ts +71 -0
- package/src/adversarial-review.ts +120 -0
- package/src/agent-history.ts +157 -0
- package/src/agent-registry.ts +221 -0
- package/src/agent.ts +929 -0
- package/src/builtin-commands.ts +286 -0
- package/src/builtin-workflows.ts +155 -0
- package/src/code-review.ts +183 -0
- package/src/config.ts +55 -0
- package/src/deep-research.ts +135 -0
- package/src/display.ts +367 -0
- package/src/effort-command.ts +87 -0
- package/src/enums.ts +77 -0
- package/src/errors.ts +199 -0
- package/src/extension-reload.ts +100 -0
- package/src/fs-persistence.ts +124 -0
- package/src/index.ts +176 -0
- package/src/logger.ts +88 -0
- package/src/model-routing.ts +73 -0
- package/src/model-spec.ts +309 -0
- package/src/model-tier-config.ts +296 -0
- package/src/run-persistence.ts +484 -0
- package/src/saved-commands.ts +115 -0
- package/src/shared-store.ts +228 -0
- package/src/structured-output.ts +47 -0
- package/src/task-panel.ts +490 -0
- package/src/usage-limit-scheduler.ts +432 -0
- package/src/web-tools.ts +124 -0
- package/src/workflow-authoring-coverage.ts +486 -0
- package/src/workflow-authoring-reference.ts +186 -0
- package/src/workflow-capability-contract.ts +806 -0
- package/src/workflow-commands.ts +287 -0
- package/src/workflow-comprehension.ts +1673 -0
- package/src/workflow-context-measurement.ts +262 -0
- package/src/workflow-control-tool.ts +238 -0
- package/src/workflow-delivery-choice.ts +69 -0
- package/src/workflow-editor.ts +444 -0
- package/src/workflow-manager.ts +1405 -0
- package/src/workflow-paths.ts +63 -0
- package/src/workflow-release-gate.ts +529 -0
- package/src/workflow-saved.ts +180 -0
- package/src/workflow-settings.ts +194 -0
- package/src/workflow-tool.ts +464 -0
- package/src/workflow-ui.ts +1789 -0
- package/src/workflow.ts +1615 -0
- package/src/workflows-models-command.ts +211 -0
- package/src/worktree.ts +76 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-stage model routing for workflows.
|
|
3
|
+
* Allows different phases to use different models.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ModelRoute {
|
|
7
|
+
/** Phase name pattern (regex or exact match). */
|
|
8
|
+
phasePattern: string;
|
|
9
|
+
/** Model to use for this phase. */
|
|
10
|
+
model: string;
|
|
11
|
+
/** Whether to use regex matching. */
|
|
12
|
+
useRegex?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ModelRoutingConfig {
|
|
16
|
+
/** Default model for all phases. */
|
|
17
|
+
defaultModel?: string;
|
|
18
|
+
/** Per-phase model overrides. */
|
|
19
|
+
routes: ModelRoute[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve which model to use for a given phase.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveModelForPhase(phase: string | undefined, config: ModelRoutingConfig): string | undefined {
|
|
26
|
+
if (!phase || !config.routes.length) {
|
|
27
|
+
return config.defaultModel;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const route of config.routes) {
|
|
31
|
+
if (route.useRegex) {
|
|
32
|
+
try {
|
|
33
|
+
const regex = new RegExp(route.phasePattern, "i");
|
|
34
|
+
if (regex.test(phase)) {
|
|
35
|
+
return route.model;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Invalid regex, skip
|
|
39
|
+
}
|
|
40
|
+
} else if (phase === route.phasePattern) {
|
|
41
|
+
// Exact, case-sensitive match — phase titles are author-controlled literals,
|
|
42
|
+
// so fuzzy substring matching only caused mis-routes (e.g. "analyze" matching
|
|
43
|
+
// "analyze-deep" or vice-versa). Use the regex branch for fuzzy needs.
|
|
44
|
+
return route.model;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return config.defaultModel;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse model routing from workflow meta: per-phase models from meta.phases[].model
|
|
53
|
+
* and a top-level default from meta.model (used when no phase route matches).
|
|
54
|
+
*/
|
|
55
|
+
export function parseModelRoutingFromMeta(
|
|
56
|
+
phases?: Array<{ title: string; model?: string }>,
|
|
57
|
+
defaultModel?: string,
|
|
58
|
+
): ModelRoutingConfig {
|
|
59
|
+
const routes: ModelRoute[] = [];
|
|
60
|
+
|
|
61
|
+
if (phases) {
|
|
62
|
+
for (const phase of phases) {
|
|
63
|
+
if (phase.model) {
|
|
64
|
+
routes.push({
|
|
65
|
+
phasePattern: phase.title,
|
|
66
|
+
model: phase.model,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { defaultModel, routes };
|
|
73
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
5
|
+
|
|
6
|
+
export type ModelThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
7
|
+
|
|
8
|
+
export interface ResolvedModelSpec {
|
|
9
|
+
requestedSpec: string;
|
|
10
|
+
model?: Model<Api>;
|
|
11
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
12
|
+
resolvedSpec?: string;
|
|
13
|
+
warning?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ParseModelPatternOptions {
|
|
18
|
+
allowInvalidThinkingLevelFallback?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ParsedModelPattern {
|
|
22
|
+
model?: Model<Api>;
|
|
23
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
24
|
+
warning?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MODEL_PER_PROVIDER: Record<string, string> = {
|
|
28
|
+
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
|
|
29
|
+
anthropic: "claude-opus-4-8",
|
|
30
|
+
openai: "gpt-5.4",
|
|
31
|
+
"azure-openai-responses": "gpt-5.4",
|
|
32
|
+
"openai-codex": "gpt-5.5",
|
|
33
|
+
deepseek: "deepseek-v4-pro",
|
|
34
|
+
google: "gemini-3.1-pro-preview",
|
|
35
|
+
"google-vertex": "gemini-3.1-pro-preview",
|
|
36
|
+
"github-copilot": "gpt-5.4",
|
|
37
|
+
openrouter: "moonshotai/kimi-k2.6",
|
|
38
|
+
"vercel-ai-gateway": "zai/glm-5.1",
|
|
39
|
+
zai: "glm-5.1",
|
|
40
|
+
mistral: "devstral-medium-latest",
|
|
41
|
+
minimax: "MiniMax-M2.7",
|
|
42
|
+
"minimax-cn": "MiniMax-M2.7",
|
|
43
|
+
moonshotai: "kimi-k2.6",
|
|
44
|
+
"moonshotai-cn": "kimi-k2.6",
|
|
45
|
+
huggingface: "moonshotai/Kimi-K2.6",
|
|
46
|
+
fireworks: "accounts/fireworks/models/kimi-k2p6",
|
|
47
|
+
together: "moonshotai/Kimi-K2.6",
|
|
48
|
+
opencode: "kimi-k2.6",
|
|
49
|
+
"opencode-go": "kimi-k2.6",
|
|
50
|
+
"kimi-coding": "kimi-for-coding",
|
|
51
|
+
"cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6",
|
|
52
|
+
"cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6",
|
|
53
|
+
xiaomi: "mimo-v2.5-pro",
|
|
54
|
+
"xiaomi-token-plan-cn": "mimo-v2.5-pro",
|
|
55
|
+
"xiaomi-token-plan-ams": "mimo-v2.5-pro",
|
|
56
|
+
"xiaomi-token-plan-sgp": "mimo-v2.5-pro",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function isThinkingLevel(value: string): value is ModelThinkingLevel {
|
|
60
|
+
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatModelSpecWithThinking(modelSpec: string, thinkingLevel: ModelThinkingLevel | undefined): string {
|
|
64
|
+
return thinkingLevel ? `${modelSpec}:${thinkingLevel}` : modelSpec;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function canonicalModelSpec(model: Model<Api>): string {
|
|
68
|
+
return `${model.provider}/${model.id}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Split a stored tier spec for display/editing. Exact known model specs win, so
|
|
73
|
+
* model ids that legitimately contain colons are not mistaken for thinking.
|
|
74
|
+
*/
|
|
75
|
+
export function splitModelSpecThinking(
|
|
76
|
+
spec: string | undefined,
|
|
77
|
+
knownModelSpecs?: readonly string[],
|
|
78
|
+
): { modelSpec: string; thinkingLevel?: ModelThinkingLevel } {
|
|
79
|
+
const trimmed = spec?.trim() ?? "";
|
|
80
|
+
if (!trimmed) return { modelSpec: "", thinkingLevel: undefined };
|
|
81
|
+
|
|
82
|
+
const known = knownModelSpecs ? new Set(knownModelSpecs) : undefined;
|
|
83
|
+
if (known?.has(trimmed)) return { modelSpec: trimmed, thinkingLevel: undefined };
|
|
84
|
+
|
|
85
|
+
const lastColon = trimmed.lastIndexOf(":");
|
|
86
|
+
if (lastColon === -1) return { modelSpec: trimmed, thinkingLevel: undefined };
|
|
87
|
+
|
|
88
|
+
const prefix = trimmed.slice(0, lastColon);
|
|
89
|
+
const suffix = trimmed.slice(lastColon + 1);
|
|
90
|
+
if (!prefix || !isThinkingLevel(suffix)) return { modelSpec: trimmed, thinkingLevel: undefined };
|
|
91
|
+
if (known && !known.has(prefix)) return { modelSpec: trimmed, thinkingLevel: undefined };
|
|
92
|
+
return { modelSpec: prefix, thinkingLevel: suffix };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isAlias(id: string): boolean {
|
|
96
|
+
if (id.endsWith("-latest")) return true;
|
|
97
|
+
return !/-\d{8}$/.test(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findExactModelReferenceMatch(modelReference: string, availableModels: Model<Api>[]): Model<Api> | undefined {
|
|
101
|
+
const trimmedReference = modelReference.trim();
|
|
102
|
+
if (!trimmedReference) return undefined;
|
|
103
|
+
const normalizedReference = trimmedReference.toLowerCase();
|
|
104
|
+
|
|
105
|
+
const canonicalMatches = availableModels.filter(
|
|
106
|
+
(model) => canonicalModelSpec(model).toLowerCase() === normalizedReference,
|
|
107
|
+
);
|
|
108
|
+
if (canonicalMatches.length === 1) return canonicalMatches[0];
|
|
109
|
+
if (canonicalMatches.length > 1) return undefined;
|
|
110
|
+
|
|
111
|
+
const slashIndex = trimmedReference.indexOf("/");
|
|
112
|
+
if (slashIndex !== -1) {
|
|
113
|
+
const provider = trimmedReference.slice(0, slashIndex).trim();
|
|
114
|
+
const modelId = trimmedReference.slice(slashIndex + 1).trim();
|
|
115
|
+
if (provider && modelId) {
|
|
116
|
+
const providerMatches = availableModels.filter(
|
|
117
|
+
(model) =>
|
|
118
|
+
model.provider.toLowerCase() === provider.toLowerCase() && model.id.toLowerCase() === modelId.toLowerCase(),
|
|
119
|
+
);
|
|
120
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
121
|
+
if (providerMatches.length > 1) return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
|
|
126
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
|
|
130
|
+
const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels);
|
|
131
|
+
if (exactMatch) return exactMatch;
|
|
132
|
+
|
|
133
|
+
const normalizedPattern = modelPattern.toLowerCase();
|
|
134
|
+
const matches = availableModels.filter(
|
|
135
|
+
(model) =>
|
|
136
|
+
model.id.toLowerCase().includes(normalizedPattern) || model.name?.toLowerCase().includes(normalizedPattern),
|
|
137
|
+
);
|
|
138
|
+
if (matches.length === 0) return undefined;
|
|
139
|
+
|
|
140
|
+
const aliases = matches.filter((model) => isAlias(model.id));
|
|
141
|
+
if (aliases.length > 0) {
|
|
142
|
+
aliases.sort((a, b) => b.id.localeCompare(a.id));
|
|
143
|
+
return aliases[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const datedVersions = matches.filter((model) => !isAlias(model.id));
|
|
147
|
+
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
|
|
148
|
+
return datedVersions[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseModelPattern(
|
|
152
|
+
pattern: string,
|
|
153
|
+
availableModels: Model<Api>[],
|
|
154
|
+
options?: ParseModelPatternOptions,
|
|
155
|
+
): ParsedModelPattern {
|
|
156
|
+
const exactMatch = tryMatchModel(pattern, availableModels);
|
|
157
|
+
if (exactMatch) return { model: exactMatch };
|
|
158
|
+
|
|
159
|
+
const lastColonIndex = pattern.lastIndexOf(":");
|
|
160
|
+
if (lastColonIndex === -1) return {};
|
|
161
|
+
|
|
162
|
+
const prefix = pattern.slice(0, lastColonIndex);
|
|
163
|
+
const suffix = pattern.slice(lastColonIndex + 1);
|
|
164
|
+
if (isThinkingLevel(suffix)) {
|
|
165
|
+
const result = parseModelPattern(prefix, availableModels, options);
|
|
166
|
+
if (!result.model) return result;
|
|
167
|
+
return {
|
|
168
|
+
model: result.model,
|
|
169
|
+
thinkingLevel: result.warning ? undefined : suffix,
|
|
170
|
+
warning: result.warning,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (options?.allowInvalidThinkingLevelFallback === false) return {};
|
|
175
|
+
|
|
176
|
+
const result = parseModelPattern(prefix, availableModels, options);
|
|
177
|
+
if (!result.model) return result;
|
|
178
|
+
return {
|
|
179
|
+
model: result.model,
|
|
180
|
+
warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildFallbackModel(provider: string, modelId: string, availableModels: Model<Api>[]): Model<Api> | undefined {
|
|
185
|
+
const providerModels = availableModels.filter((model) => model.provider === provider);
|
|
186
|
+
if (providerModels.length === 0) return undefined;
|
|
187
|
+
const defaultId = DEFAULT_MODEL_PER_PROVIDER[provider];
|
|
188
|
+
const baseModel = defaultId
|
|
189
|
+
? (providerModels.find((model) => model.id === defaultId) ?? providerModels[0])
|
|
190
|
+
: providerModels[0];
|
|
191
|
+
return { ...baseModel, id: modelId, name: modelId };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a workflow model-tier/agent model string with the same user-facing
|
|
196
|
+
* grammar as Pi CLI `--model`: `provider/modelId[:thinking]`, bare model ids,
|
|
197
|
+
* fuzzy patterns, and exact colon-containing model ids.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveModelSpecWithThinking(
|
|
200
|
+
spec: string,
|
|
201
|
+
modelRegistry: Pick<ModelRegistry, "getAll">,
|
|
202
|
+
): ResolvedModelSpec {
|
|
203
|
+
const requestedSpec = spec.trim();
|
|
204
|
+
if (!requestedSpec) return { requestedSpec, error: "No model spec provided." };
|
|
205
|
+
|
|
206
|
+
const availableModels = modelRegistry.getAll();
|
|
207
|
+
if (availableModels.length === 0) {
|
|
208
|
+
return {
|
|
209
|
+
requestedSpec,
|
|
210
|
+
error: "No models available. Check your installation or add models to models.json.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const providerMap = new Map<string, string>();
|
|
215
|
+
for (const model of availableModels) {
|
|
216
|
+
providerMap.set(model.provider.toLowerCase(), model.provider);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let provider: string | undefined;
|
|
220
|
+
let pattern = requestedSpec;
|
|
221
|
+
let inferredProvider = false;
|
|
222
|
+
const slashIndex = requestedSpec.indexOf("/");
|
|
223
|
+
if (slashIndex !== -1) {
|
|
224
|
+
const maybeProvider = requestedSpec.slice(0, slashIndex);
|
|
225
|
+
const canonicalProvider = providerMap.get(maybeProvider.toLowerCase());
|
|
226
|
+
if (canonicalProvider) {
|
|
227
|
+
provider = canonicalProvider;
|
|
228
|
+
pattern = requestedSpec.slice(slashIndex + 1);
|
|
229
|
+
inferredProvider = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!provider) {
|
|
234
|
+
const exact = findExactModelReferenceMatch(requestedSpec, availableModels);
|
|
235
|
+
if (exact) {
|
|
236
|
+
return { requestedSpec, model: exact, resolvedSpec: canonicalModelSpec(exact) };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const candidates = provider ? availableModels.filter((model) => model.provider === provider) : availableModels;
|
|
241
|
+
const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, {
|
|
242
|
+
allowInvalidThinkingLevelFallback: false,
|
|
243
|
+
});
|
|
244
|
+
if (model) {
|
|
245
|
+
return {
|
|
246
|
+
requestedSpec,
|
|
247
|
+
model,
|
|
248
|
+
thinkingLevel,
|
|
249
|
+
warning,
|
|
250
|
+
resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(model), thinkingLevel),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (inferredProvider) {
|
|
255
|
+
const exact = findExactModelReferenceMatch(requestedSpec, availableModels);
|
|
256
|
+
if (exact) {
|
|
257
|
+
return { requestedSpec, model: exact, resolvedSpec: canonicalModelSpec(exact) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fallback = parseModelPattern(requestedSpec, availableModels, {
|
|
261
|
+
allowInvalidThinkingLevelFallback: false,
|
|
262
|
+
});
|
|
263
|
+
if (fallback.model) {
|
|
264
|
+
return {
|
|
265
|
+
requestedSpec,
|
|
266
|
+
model: fallback.model,
|
|
267
|
+
thinkingLevel: fallback.thinkingLevel,
|
|
268
|
+
warning: fallback.warning,
|
|
269
|
+
resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(fallback.model), fallback.thinkingLevel),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (provider) {
|
|
275
|
+
let fallbackPattern = pattern;
|
|
276
|
+
let fallbackThinking: ModelThinkingLevel | undefined;
|
|
277
|
+
const lastColon = pattern.lastIndexOf(":");
|
|
278
|
+
if (lastColon !== -1) {
|
|
279
|
+
const suffix = pattern.slice(lastColon + 1);
|
|
280
|
+
if (isThinkingLevel(suffix)) {
|
|
281
|
+
fallbackPattern = pattern.slice(0, lastColon);
|
|
282
|
+
fallbackThinking = suffix;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels);
|
|
287
|
+
if (fallbackModel) {
|
|
288
|
+
const modelWithReasoning =
|
|
289
|
+
fallbackThinking && fallbackThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel;
|
|
290
|
+
const fallbackWarning = warning
|
|
291
|
+
? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`
|
|
292
|
+
: `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`;
|
|
293
|
+
return {
|
|
294
|
+
requestedSpec,
|
|
295
|
+
model: modelWithReasoning,
|
|
296
|
+
thinkingLevel: fallbackThinking,
|
|
297
|
+
warning: fallbackWarning,
|
|
298
|
+
resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(modelWithReasoning), fallbackThinking),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const display = provider ? `${provider}/${pattern}` : requestedSpec;
|
|
304
|
+
return {
|
|
305
|
+
requestedSpec,
|
|
306
|
+
warning,
|
|
307
|
+
error: `Model "${display}" not found. Use /workflows-models to choose an available model.`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model tier configuration for workflow subagent model routing.
|
|
3
|
+
*
|
|
4
|
+
* A tier is a named slot (small/medium/big) holding exactly ONE model spec
|
|
5
|
+
* string (e.g. "openai/gpt-4.1-mini" or "openai-codex/gpt-5.5:xhigh").
|
|
6
|
+
* When an agent() call specifies opts.tier, that single model is resolved with
|
|
7
|
+
* Pi CLI-style parsing and used as the subagent's model/thinking level (unless
|
|
8
|
+
* an explicit opts.model is given, which always wins — see agent.ts).
|
|
9
|
+
*
|
|
10
|
+
* This augments the phase-pattern routing in model-routing.ts: phase routing
|
|
11
|
+
* maps workflow phases → models via the script's meta; tiers give scripts a
|
|
12
|
+
* coarse, user-configurable small/medium/big knob that is independent of any
|
|
13
|
+
* concrete provider/model id.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { listAvailableModels } from "./agent.js";
|
|
20
|
+
import { MODEL_TIERS_FILE } from "./config.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Model tier configuration. Maps tier names (e.g. "small", "medium", "big")
|
|
28
|
+
* to a single model spec string (e.g. "gpt-4.1-mini", "openai/gpt-4.1-mini",
|
|
29
|
+
* or "openai-codex/gpt-5.5:xhigh").
|
|
30
|
+
*/
|
|
31
|
+
export interface ModelTierConfig {
|
|
32
|
+
tiers: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The minimal projection of a model that tier ranking needs. Deliberately NOT
|
|
37
|
+
* the SDK's full `Model` type: tier logic depends only on these three fields,
|
|
38
|
+
* so it stays decoupled from the SDK (no `@earendil-works/pi-ai` import here)
|
|
39
|
+
* and is trivially unit-testable with plain objects. `agent.ts`'s
|
|
40
|
+
* `listAvailableModels()` produces these from the live registry.
|
|
41
|
+
*/
|
|
42
|
+
export interface RankableModel {
|
|
43
|
+
/** Canonical "provider/id" spec string. */
|
|
44
|
+
spec: string;
|
|
45
|
+
/** Per-token output price, if the registry reports one. Missing or 0 = unknown. */
|
|
46
|
+
costOutput?: number;
|
|
47
|
+
/** Context window size, if the registry reports one. */
|
|
48
|
+
contextWindow?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Configuration path
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Path to the model tiers JSON config file (~/.pi/workflows/model-tiers.json). */
|
|
56
|
+
export function getModelTierConfigPath(): string {
|
|
57
|
+
return join(homedir(), MODEL_TIERS_FILE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Capability signal
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Substrings that identify small/cheap models (case-insensitive), used only as
|
|
66
|
+
* a fallback capability hint when price signals are absent or tied.
|
|
67
|
+
*/
|
|
68
|
+
export const SMALL_MODEL_HINTS = ["mini", "flash", "haiku", "nano", "small"] as const;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Substrings that identify large/capable models (case-insensitive), used only
|
|
72
|
+
* as a fallback capability hint when price signals are absent or tied.
|
|
73
|
+
*/
|
|
74
|
+
export const BIG_MODEL_HINTS = ["opus", "pro", "ultra", "large", "plus"] as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fallback capability hint from a model's name: -1 for a small/cheap name, +1
|
|
78
|
+
* for a large/capable name, 0 otherwise. If a name matches both sets, the small
|
|
79
|
+
* hint wins (we never want a "mini"-labelled model to outrank a neutral or
|
|
80
|
+
* clearly-large one). This is only a FALLBACK: `rankByCapability` prefers the
|
|
81
|
+
* registry's price signal, which is robust to new vendor names (e.g. "fable",
|
|
82
|
+
* "mimo") that match no hint and would otherwise all score 0.
|
|
83
|
+
*/
|
|
84
|
+
export function hintScore(spec: string): number {
|
|
85
|
+
const lower = spec.toLowerCase();
|
|
86
|
+
if (SMALL_MODEL_HINTS.some((hint) => lower.includes(hint))) return -1;
|
|
87
|
+
if (BIG_MODEL_HINTS.some((hint) => lower.includes(hint))) return 1;
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Rank models from least → most capable.
|
|
93
|
+
*
|
|
94
|
+
* PRIMARY signal is output price (higher price ≈ more capable): within a single
|
|
95
|
+
* registry, price tracks the vendor's capability tier far more robustly than
|
|
96
|
+
* model-name substrings, and it works for models whose names match no hint.
|
|
97
|
+
*
|
|
98
|
+
* Models with an UNKNOWN price (missing or 0 — common for self-hosted
|
|
99
|
+
* `models.json` entries) are NOT treated as "cheapest = weakest". Instead they
|
|
100
|
+
* are projected onto the known price range via their substring hint: a
|
|
101
|
+
* big-hint name lands at the top of the range, a small-hint name at the bottom,
|
|
102
|
+
* a neutral name at the middle. When NO model has a known price at all, this
|
|
103
|
+
* degrades to pure hint ordering (the previous behavior).
|
|
104
|
+
*
|
|
105
|
+
* The comparison is a single total order (projected cost → hint → contextWindow
|
|
106
|
+
* → stable registry index), so the sort is transitive and stable.
|
|
107
|
+
*/
|
|
108
|
+
export function rankByCapability(models: readonly RankableModel[]): RankableModel[] {
|
|
109
|
+
const knownCosts = models
|
|
110
|
+
.map((m) => m.costOutput)
|
|
111
|
+
.filter((c): c is number => typeof c === "number" && c > 0)
|
|
112
|
+
.sort((a, b) => a - b);
|
|
113
|
+
const hasPriceSignal = knownCosts.length > 0;
|
|
114
|
+
const min = knownCosts[0];
|
|
115
|
+
const max = knownCosts[knownCosts.length - 1];
|
|
116
|
+
const median = knownCosts[Math.floor(knownCosts.length / 2)];
|
|
117
|
+
|
|
118
|
+
// Project every model onto the price axis. Undefined only when there is no
|
|
119
|
+
// price signal anywhere (all models unpriced) — then the sort falls through
|
|
120
|
+
// to the hint comparison below.
|
|
121
|
+
const costKey = (m: RankableModel): number | undefined => {
|
|
122
|
+
if (typeof m.costOutput === "number" && m.costOutput > 0) return m.costOutput;
|
|
123
|
+
if (!hasPriceSignal) return undefined;
|
|
124
|
+
const hint = hintScore(m.spec);
|
|
125
|
+
return hint > 0 ? max : hint < 0 ? min : median;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return models
|
|
129
|
+
.map((m, index) => ({ m, index, cost: costKey(m), hint: hintScore(m.spec), ctx: m.contextWindow ?? 0 }))
|
|
130
|
+
.sort((a, b) => {
|
|
131
|
+
if (a.cost !== undefined && b.cost !== undefined && a.cost !== b.cost) return a.cost - b.cost;
|
|
132
|
+
if (a.hint !== b.hint) return a.hint - b.hint;
|
|
133
|
+
if (a.ctx !== b.ctx) return a.ctx - b.ctx;
|
|
134
|
+
return a.index - b.index;
|
|
135
|
+
})
|
|
136
|
+
.map((entry) => entry.m);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Defaults
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a default tier config. When the available model registry is known,
|
|
145
|
+
* spread it across tiers so small/medium/big routing is meaningful out of the
|
|
146
|
+
* box. When the registry is empty or unavailable, fall back to the current Pi
|
|
147
|
+
* model so fresh installs still get usable tier values.
|
|
148
|
+
*
|
|
149
|
+
* Models are first ranked least → most capable via `rankByCapability` (price
|
|
150
|
+
* first, name-substring hint as fallback). Tiers are then assigned from this
|
|
151
|
+
* single ranked pool with exclusion — each model is used for at most one tier —
|
|
152
|
+
* so distinct tiers never collapse onto the same model and a weaker model can
|
|
153
|
+
* never outrank a stronger one (no inversion):
|
|
154
|
+
*
|
|
155
|
+
* - big = the most capable model (last in the ranking)
|
|
156
|
+
* - small = the least capable model (first in the ranking)
|
|
157
|
+
* - medium = the middle-ranked model
|
|
158
|
+
*
|
|
159
|
+
* When fewer than 3 distinct models are available, this degrades gracefully by
|
|
160
|
+
* reusing the *strongest* available model for the higher tier(s):
|
|
161
|
+
*
|
|
162
|
+
* - 2 models: small = weaker, medium = big = stronger
|
|
163
|
+
* - 1 / 0 models: small = medium = big = that model (or the current model /
|
|
164
|
+
* "" fallback)
|
|
165
|
+
*
|
|
166
|
+
* `availableModels` is injectable for testing and for callers that already
|
|
167
|
+
* fetched the registry. When omitted, this reads from the live registry.
|
|
168
|
+
*/
|
|
169
|
+
export function buildDefaultTierConfig(
|
|
170
|
+
currentModelSpec?: string,
|
|
171
|
+
availableModels?: readonly RankableModel[],
|
|
172
|
+
): ModelTierConfig {
|
|
173
|
+
const models = availableModels ?? listAvailableModels();
|
|
174
|
+
const ranked = rankByCapability(models).map((m) => m.spec);
|
|
175
|
+
|
|
176
|
+
if (ranked.length >= 3) {
|
|
177
|
+
const small = ranked[0];
|
|
178
|
+
const big = ranked[ranked.length - 1];
|
|
179
|
+
const medium = ranked[Math.floor(ranked.length / 2)];
|
|
180
|
+
return { tiers: { small, medium, big } };
|
|
181
|
+
}
|
|
182
|
+
if (ranked.length === 2) {
|
|
183
|
+
const [weaker, stronger] = ranked;
|
|
184
|
+
return { tiers: { small: weaker, medium: stronger, big: stronger } };
|
|
185
|
+
}
|
|
186
|
+
const fallback = ranked[0] ?? currentModelSpec ?? "";
|
|
187
|
+
return {
|
|
188
|
+
tiers: {
|
|
189
|
+
small: fallback,
|
|
190
|
+
medium: fallback,
|
|
191
|
+
big: fallback,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One-time notice shown when an agent requests `opts.tier` but no
|
|
198
|
+
* model-tiers.json is configured — in that state tiers silently fall back to
|
|
199
|
+
* the session model (see `resolveAgentModelSpec` in agent.ts), which is easy to
|
|
200
|
+
* miss. This surfaces the fallback and the mapping the user *would* get by
|
|
201
|
+
* configuring, using the same `buildDefaultTierConfig` ranking so the hint is
|
|
202
|
+
* actionable. Pure/string-only so the caller owns how it's emitted.
|
|
203
|
+
*/
|
|
204
|
+
export function formatTierFallbackNotice(
|
|
205
|
+
mainModel: string | undefined,
|
|
206
|
+
availableModels: readonly RankableModel[],
|
|
207
|
+
): string {
|
|
208
|
+
const fallback = mainModel ?? "the session default model";
|
|
209
|
+
const suggested = buildDefaultTierConfig(mainModel, availableModels);
|
|
210
|
+
const mapping = sortedTierNames(suggested)
|
|
211
|
+
.map((tier) => `${tier}=${suggested.tiers[tier] || "?"}`)
|
|
212
|
+
.join(" ");
|
|
213
|
+
return (
|
|
214
|
+
`[workflow] An agent requested opts.tier but no model-tiers.json is configured, so tiers currently ` +
|
|
215
|
+
`fall back to ${fallback}. Run /workflows-models to configure them` +
|
|
216
|
+
(mapping ? `. Suggested mapping from your available models: ${mapping}` : ".")
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Load / Save
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* True iff `value` is a usable tiers map: a plain (non-array) object with at
|
|
226
|
+
* least one entry, every key and value a non-empty string. Anything else
|
|
227
|
+
* (an array, `{}`, or a tier mapped to `""`) is treated as absent rather than
|
|
228
|
+
* a truthy-but-broken config — resolveTierModel would silently resolve such
|
|
229
|
+
* entries to `undefined`/`""` while the caller's "no model-tiers.json
|
|
230
|
+
* configured" warning only fires on an exactly-null config.
|
|
231
|
+
*/
|
|
232
|
+
function isValidTiersMap(value: unknown): value is Record<string, string> {
|
|
233
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
234
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
235
|
+
if (entries.length === 0) return false;
|
|
236
|
+
return entries.every(([key, val]) => key.trim().length > 0 && typeof val === "string" && val.trim().length > 0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Load the model tier config from disk. Returns null if the file does not
|
|
241
|
+
* exist or is unparseable (callers fall back to a default).
|
|
242
|
+
*/
|
|
243
|
+
export function loadModelTierConfig(configPath?: string): ModelTierConfig | null {
|
|
244
|
+
const path = configPath ?? getModelTierConfigPath();
|
|
245
|
+
if (!existsSync(path)) return null;
|
|
246
|
+
try {
|
|
247
|
+
const raw = readFileSync(path, "utf-8");
|
|
248
|
+
const parsed = JSON.parse(raw);
|
|
249
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
250
|
+
if (!isValidTiersMap(parsed.tiers)) return null;
|
|
251
|
+
return parsed as ModelTierConfig;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Save a model tier config to disk. Creates parent directories if needed.
|
|
259
|
+
*
|
|
260
|
+
* Refuses a degenerate `tiers` (e.g. all-empty-string, from buildDefaultTierConfig
|
|
261
|
+
* with an empty model registry) using the same isValidTiersMap check the loader
|
|
262
|
+
* uses — otherwise the write side could produce exactly the shape loadModelTierConfig
|
|
263
|
+
* now rejects on the next read, silently discarding the "saved" config.
|
|
264
|
+
*/
|
|
265
|
+
export function saveModelTierConfig(config: ModelTierConfig, configPath?: string): void {
|
|
266
|
+
if (!isValidTiersMap(config?.tiers)) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Refusing to save a degenerate model tier config: tiers must be a non-empty map of tier name to a non-empty model spec string.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const path = configPath ?? getModelTierConfigPath();
|
|
272
|
+
const dir = dirname(path);
|
|
273
|
+
if (!existsSync(dir)) {
|
|
274
|
+
mkdirSync(dir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Resolve / helpers
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Resolve a tier name to its configured model spec, or undefined if the tier
|
|
285
|
+
* is not configured.
|
|
286
|
+
*/
|
|
287
|
+
export function resolveTierModel(tier: string, config: ModelTierConfig): string | undefined {
|
|
288
|
+
return config.tiers[tier];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Return all tier names sorted: small < medium < big, then alphabetically. */
|
|
292
|
+
export function sortedTierNames(config: ModelTierConfig): string[] {
|
|
293
|
+
const names = Object.keys(config.tiers);
|
|
294
|
+
const rank: Record<string, number> = { small: 0, medium: 1, big: 2 };
|
|
295
|
+
return names.sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99) || a.localeCompare(b));
|
|
296
|
+
}
|