@cruxy/cli 0.16.0 → 0.17.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.
@@ -2,6 +2,7 @@ import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
3
  import type { HookEvent } from "../hooks/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
+ import { type Router, type TaskClass } from "../routing/index.js";
5
6
  import type { ToolContext } from "../tools/index.js";
6
7
  import { ToolRegistry } from "../tools/index.js";
7
8
  /** The lifecycle-hook firing seam (C.19). Structural so the loop stays
@@ -57,6 +58,17 @@ export interface RunAgentArgs {
57
58
  * no-hooks path, so their tools never fire hooks.
58
59
  */
59
60
  hooks?: LifecycleHookRunner;
61
+ /**
62
+ * Multi-model routing (C.30): when set, the tier for `taskClass` is resolved
63
+ * ONCE up front (fail loud before any model turn) and its wire model overrides
64
+ * the provider default for every turn in this run; the tier is surfaced on the
65
+ * live line. Omitted → no override, the provider's default model is used
66
+ * (unchanged behavior).
67
+ */
68
+ router?: Router;
69
+ /** The declared task class for routing; defaults to `main-turn`. Ignored
70
+ * unless `router` is set. */
71
+ taskClass?: TaskClass;
60
72
  }
61
73
  /**
62
74
  * The budget seam for {@link runAgent}: implementations track their own caps
@@ -1,4 +1,5 @@
1
1
  import { CruxyError, providerUnsupported } from "../errors/index.js";
2
+ import { resolveTaskModel, } from "../routing/index.js";
2
3
  import { buildSystemPrompt } from "./prompts.js";
3
4
  /** Tools whose successful call is a file change (drives the `on-file-change`
4
5
  * hook). Kept in sync with the file-mutating tool set. */
@@ -17,9 +18,16 @@ export async function runAgent(args) {
17
18
  if (!provider.supportsTools) {
18
19
  throw providerUnsupported(config.model.provider);
19
20
  }
21
+ // Resolve the routing tier ONCE, before any model turn (C.30). A tier the
22
+ // gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
23
+ // misrouted run never reaches provider.stream (no request sent with the wrong
24
+ // tier), and never silently falls back to a different one.
25
+ const routed = args.router
26
+ ? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
27
+ : null;
20
28
  renderer?.beginTurn();
21
29
  try {
22
- return await driveLoop(args, renderer);
30
+ return await driveLoop(args, renderer, routed);
23
31
  }
24
32
  finally {
25
33
  // Always leave the terminal clean: no orphaned status line, no held text —
@@ -28,7 +36,7 @@ export async function runAgent(args) {
28
36
  }
29
37
  }
30
38
  /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
31
- async function driveLoop(args, renderer) {
39
+ async function driveLoop(args, renderer, routed) {
32
40
  const { provider, registry, config, ctx } = args;
33
41
  const { logger } = ctx;
34
42
  // Work on a copy so we never mutate the caller's array as a side effect; the
@@ -80,11 +88,13 @@ async function driveLoop(args, renderer) {
80
88
  tokens: usage.input_tokens + usage.output_tokens > 0
81
89
  ? { input: usage.input_tokens, output: usage.output_tokens }
82
90
  : undefined,
91
+ tier: routed?.tier,
83
92
  });
84
93
  for await (const ev of provider.stream({
85
94
  system,
86
95
  messages,
87
96
  tools,
97
+ ...(routed ? { model: routed.model } : {}),
88
98
  })) {
89
99
  switch (ev.type) {
90
100
  case "text_delta":
@@ -1,6 +1,7 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
3
  import type { StreamRenderer } from "../render/index.js";
4
+ import { type Router } from "../routing/index.js";
4
5
  import type { ToolContext } from "../tools/index.js";
5
6
  import type { ToolRegistry } from "../tools/index.js";
6
7
  import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
@@ -42,6 +43,11 @@ export interface SessionArgs {
42
43
  * `before-tool`/`after-tool`/`on-file-change`.
43
44
  */
44
45
  hooks?: LifecycleHookRunner;
46
+ /**
47
+ * Multi-model routing (C.30). When set, main turns route on `main-turn` and
48
+ * context compaction on `summarize`; omitted → the provider default (unchanged).
49
+ */
50
+ router?: Router;
45
51
  }
46
52
  /**
47
53
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -1,4 +1,5 @@
1
1
  import { loadProjectInstructions } from "../config/index.js";
2
+ import { resolveTaskModel } from "../routing/index.js";
2
3
  import { runAgent, } from "./loop.js";
3
4
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
4
5
  /**
@@ -102,7 +103,8 @@ export class Session {
102
103
  })
103
104
  : await runAgent({
104
105
  messages: this.messages,
105
- ...this.args,
106
+ ...this.args, // carries `router` through to the loop
107
+ taskClass: "main-turn",
106
108
  // After the spread so a mid-session `/reload` wins over the initial value.
107
109
  projectInstructions: this.projectInstructions,
108
110
  planMode: false, // the plan directive belongs only to the runner's propose phase
@@ -226,9 +228,15 @@ export class Session {
226
228
  const transcript = renderTranscript(prefix);
227
229
  const usage = { input_tokens: 0, output_tokens: 0 };
228
230
  let text = "";
231
+ // Compaction is mechanical work — route it on `summarize` (C.30) when routing
232
+ // is active; omitted → the provider default, unchanged.
233
+ const routed = this.args.router
234
+ ? resolveTaskModel(this.args.router, "summarize")
235
+ : null;
229
236
  for await (const ev of this.args.provider.stream({
230
237
  system: SUMMARY_SYSTEM,
231
238
  messages: [{ role: "user", content: transcript }],
239
+ ...(routed ? { model: routed.model } : {}),
232
240
  })) {
233
241
  switch (ev.type) {
234
242
  case "text_delta":
@@ -1 +1 @@
1
- export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
1
+ export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
@@ -1 +1 @@
1
- export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, } from "./voice.js";
1
+ export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, } from "./voice.js";
@@ -59,6 +59,26 @@ export declare const FORBIDDEN_TERMS: readonly ForbiddenTerm[];
59
59
  * absent: they are real bring-your-own-provider config values, not model names.
60
60
  */
61
61
  export declare const FORBIDDEN_MODEL_NAMES: RegExp;
62
+ /** The neutral token an upstream model name is replaced with in user-facing copy. */
63
+ export declare const MODEL_NAME_PLACEHOLDER = "\u00ABmodel\u00BB";
64
+ /**
65
+ * Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
66
+ * string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
67
+ * run to a single token.
68
+ *
69
+ * The U.8 gag is structural *inside the process* (the routing package can't
70
+ * produce an upstream id — see `resolveModelId`), but a gateway/provider error
71
+ * body is an arbitrary external string that bypasses that guarantee. This is the
72
+ * scrub applied at the error-construction boundary so no upstream id reaches a
73
+ * user-facing `CruxyError.cause`.
74
+ *
75
+ * Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
76
+ * inside an unrelated word/path — so legitimate output (`/home/claude`, the
77
+ * `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
78
+ * Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
79
+ * provider config values survive too.
80
+ */
81
+ export declare function scrubModelNames(text: string): string;
62
82
  /** One lexicon violation found in a scanned string. */
63
83
  export interface LexiconViolation {
64
84
  /** The forbidden term (or "model-name" for a gag violation). */
@@ -55,6 +55,60 @@ export const FORBIDDEN_TERMS = [
55
55
  * absent: they are real bring-your-own-provider config values, not model names.
56
56
  */
57
57
  export const FORBIDDEN_MODEL_NAMES = /\b(claude|sonnet|opus|haiku|gpt-?\d|gemini|llama|mistral)\b/i;
58
+ /** The neutral token an upstream model name is replaced with in user-facing copy. */
59
+ export const MODEL_NAME_PLACEHOLDER = "«model»";
60
+ /**
61
+ * A model-id-SHAPED run in free text: a self-identifying `gpt-4`/`gpt-4o`, or a
62
+ * model word carrying a hyphen/dot tail (`claude-sonnet-4-6`, `gemini-1.5-pro`).
63
+ * Deliberately GENEROUS — it also matches bare words and non-model hyphenated
64
+ * tokens; {@link isModelId} does the real filtering in the scrub callback, and
65
+ * matching the whole run (not each word) is what collapses `claude-sonnet-4-6`
66
+ * to ONE placeholder instead of `«model»-«model»-4-6`. The leading
67
+ * `(?<![/\\\w])` stops it firing inside a longer word (`gptools`) or on a path
68
+ * segment (`/home/claude/…`) — both of which the bare `\b` form corrupts.
69
+ */
70
+ const MODEL_ID_CANDIDATE = /(?<![/\\\w])(?:gpt-?\d[a-z0-9]*(?:[-.][a-z0-9]+)*|(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)(?:[-.][a-z0-9]+)*)/gi;
71
+ /** The bare model words, for the digitless-compound test in {@link isModelId}. */
72
+ const MODEL_WORD = /(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)/gi;
73
+ /**
74
+ * Is a {@link MODEL_ID_CANDIDATE} run an actual upstream model reference (scrub),
75
+ * or a legitimate token that merely contains a model word (keep)? A reference is:
76
+ * a self-identifying `gpt-<n>`; OR a hyphen/dot run carrying a version digit
77
+ * (`claude-sonnet-4-6`, `gemini-1.5-pro`); OR a digitless compound of ≥2 distinct
78
+ * model words (`claude-sonnet`). A bare word (`opus`, `haiku`), a path segment
79
+ * (`/home/claude`), or a plain hyphenated token (`claude-bot`) is none of these,
80
+ * so it survives verbatim — the gag scrubs model IDS, never legitimate output.
81
+ */
82
+ function isModelId(run) {
83
+ if (/^gpt-?\d/i.test(run))
84
+ return true; // gpt-4, gpt4, gpt-4o
85
+ if (!/[-.]/.test(run))
86
+ return false; // bare word — never a model reference here
87
+ if (/\d/.test(run))
88
+ return true; // version digit ⇒ an id
89
+ const words = run.match(MODEL_WORD) ?? [];
90
+ return new Set(words.map((w) => w.toLowerCase())).size >= 2; // claude-sonnet
91
+ }
92
+ /**
93
+ * Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
94
+ * string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
95
+ * run to a single token.
96
+ *
97
+ * The U.8 gag is structural *inside the process* (the routing package can't
98
+ * produce an upstream id — see `resolveModelId`), but a gateway/provider error
99
+ * body is an arbitrary external string that bypasses that guarantee. This is the
100
+ * scrub applied at the error-construction boundary so no upstream id reaches a
101
+ * user-facing `CruxyError.cause`.
102
+ *
103
+ * Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
104
+ * inside an unrelated word/path — so legitimate output (`/home/claude`, the
105
+ * `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
106
+ * Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
107
+ * provider config values survive too.
108
+ */
109
+ export function scrubModelNames(text) {
110
+ return text.replace(MODEL_ID_CANDIDATE, (run) => isModelId(run) ? MODEL_NAME_PLACEHOLDER : run);
111
+ }
58
112
  /**
59
113
  * Scan one user-facing string for lexicon violations — deprecated synonyms and
60
114
  * upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
@@ -5,6 +5,7 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
5
5
  import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService } from "../../approval/index.js";
8
+ import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
8
9
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
9
10
  /**
10
11
  * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
@@ -37,6 +38,13 @@ export function prCommand() {
37
38
  temperature: config.model.temperature,
38
39
  gatewayUrl: config.cruxy.gatewayUrl,
39
40
  });
41
+ // Multi-model routing (C.30): PR/commit text is `commit-msg` work. Null
42
+ // unless routing is configured → the provider default, unchanged. A
43
+ // misrouted tier fails loud here before any request.
44
+ const router = routerForConfig(config);
45
+ const genModel = router
46
+ ? resolveTaskModel(router, "commit-msg").model
47
+ : undefined;
40
48
  // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
41
49
  const forge = createForgeProvider(resolveForgeToken());
42
50
  const guidance = await loadCommitGuidance(cwd);
@@ -53,7 +61,7 @@ export function prCommand() {
53
61
  ...i,
54
62
  scopes: guidance.scopes,
55
63
  skillBody: guidance.skillBody,
56
- }),
64
+ }, { model: genModel }),
57
65
  });
58
66
  logger.info(t.muted("generating pull request content…"));
59
67
  const outcome = await service.openPullRequest({
@@ -7,6 +7,7 @@ import { shouldUseColor } from "../errors/index.js";
7
7
  import { buildDefaultRegistry } from "../tools/index.js";
8
8
  import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
+ import { routerForConfig } from "../routing/index.js";
10
11
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
11
12
  /**
12
13
  * Wrap a PromptIO so the live region yields before any prompt text lands
@@ -96,6 +97,10 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
96
97
  temperature: config.model.temperature,
97
98
  gatewayUrl: config.cruxy.gatewayUrl,
98
99
  });
100
+ // Multi-model routing (C.30): null unless routing is configured on a cruxy
101
+ // session, so the default path threads `undefined` and behaves exactly as
102
+ // before. One router is shared by the main loop, subagents, and plan mode.
103
+ const router = routerForConfig(config) ?? undefined;
99
104
  const execRegistry = buildDefaultRegistry();
100
105
  const git = getGitInfo(cwd);
101
106
  const projectInstructions = loadProjectInstructions(cwd);
@@ -114,6 +119,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
114
119
  const orchestrator = new SubagentOrchestrator({
115
120
  provider,
116
121
  config,
122
+ router,
117
123
  parentRegistry: execRegistry,
118
124
  cwd,
119
125
  logger,
@@ -156,6 +162,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
156
162
  git,
157
163
  projectInstructions,
158
164
  renderer: turnRenderer,
165
+ router,
159
166
  });
160
167
  return new Session({
161
168
  provider,
@@ -167,6 +174,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
167
174
  planMode: true,
168
175
  planRunner,
169
176
  hooks,
177
+ router,
170
178
  });
171
179
  }
172
180
  const approval = new ApprovalService({
@@ -183,5 +191,6 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
183
191
  git,
184
192
  projectInstructions,
185
193
  hooks,
194
+ router,
186
195
  });
187
196
  }
@@ -365,6 +365,27 @@ export declare const HooksConfigSchema: z.ZodObject<{
365
365
  trustPrompt?: boolean | undefined;
366
366
  }>;
367
367
  export type HooksConfig = z.infer<typeof HooksConfigSchema>;
368
+ /**
369
+ * Multi-model routing (C.30): map declared task classes to tiers so mechanical
370
+ * work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
371
+ * with an empty `map` and no `default`, every task class routes to the session's
372
+ * single tier and behavior is unchanged (routing stays inert until configured).
373
+ * Only tier names appear here; upstream model names never do (U.8). Keys are the
374
+ * fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
375
+ */
376
+ export declare const RoutingConfigSchema: z.ZodObject<{
377
+ /** Tier for any task class not in `map`. Unset → the tier implied by
378
+ * `model.model` (a real tier, or the auto-fallback tier). */
379
+ default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
380
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
381
+ map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
382
+ }, "strict", z.ZodTypeAny, {
383
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
384
+ default?: "kavi" | "vaani" | "mira" | undefined;
385
+ }, {
386
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
387
+ default?: "kavi" | "vaani" | "mira" | undefined;
388
+ }>;
368
389
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
369
390
  export declare const McpServerSchema: z.ZodObject<{
370
391
  command: z.ZodOptional<z.ZodString>;
@@ -697,6 +718,19 @@ export declare const CruxyConfigSchema: z.ZodObject<{
697
718
  enabled?: boolean | undefined;
698
719
  trustPrompt?: boolean | undefined;
699
720
  }>>;
721
+ routing: z.ZodDefault<z.ZodObject<{
722
+ /** Tier for any task class not in `map`. Unset → the tier implied by
723
+ * `model.model` (a real tier, or the auto-fallback tier). */
724
+ default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
725
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
726
+ map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
727
+ }, "strict", z.ZodTypeAny, {
728
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
729
+ default?: "kavi" | "vaani" | "mira" | undefined;
730
+ }, {
731
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
732
+ default?: "kavi" | "vaani" | "mira" | undefined;
733
+ }>>;
700
734
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
701
735
  command: z.ZodOptional<z.ZodString>;
702
736
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -732,6 +766,14 @@ export declare const CruxyConfigSchema: z.ZodObject<{
732
766
  approval: {
733
767
  mode: "prompt";
734
768
  };
769
+ subagent: {
770
+ maxDepth: number;
771
+ defaultBudget: {
772
+ maxTokens: number;
773
+ maxIterations: number;
774
+ timeoutMs?: number | undefined;
775
+ };
776
+ };
735
777
  model: {
736
778
  provider: "cruxy" | "anthropic" | "openai" | "custom";
737
779
  model: string;
@@ -777,14 +819,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
777
819
  overlapLines: number;
778
820
  };
779
821
  };
780
- subagent: {
781
- maxDepth: number;
782
- defaultBudget: {
783
- maxTokens: number;
784
- maxIterations: number;
785
- timeoutMs?: number | undefined;
786
- };
787
- };
788
822
  test: {
789
823
  maxIterations: number;
790
824
  captureBytes: number;
@@ -794,6 +828,10 @@ export declare const CruxyConfigSchema: z.ZodObject<{
794
828
  enabled: boolean;
795
829
  trustPrompt: boolean;
796
830
  };
831
+ routing: {
832
+ map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
833
+ default?: "kavi" | "vaani" | "mira" | undefined;
834
+ };
797
835
  mcpServers: Record<string, {
798
836
  command?: string | undefined;
799
837
  args?: string[] | undefined;
@@ -821,6 +859,14 @@ export declare const CruxyConfigSchema: z.ZodObject<{
821
859
  approval?: {
822
860
  mode?: "prompt" | undefined;
823
861
  } | undefined;
862
+ subagent?: {
863
+ maxDepth?: number | undefined;
864
+ defaultBudget?: {
865
+ maxTokens?: number | undefined;
866
+ maxIterations?: number | undefined;
867
+ timeoutMs?: number | undefined;
868
+ } | undefined;
869
+ } | undefined;
824
870
  model?: {
825
871
  provider?: "cruxy" | "anthropic" | "openai" | "custom" | undefined;
826
872
  model?: string | undefined;
@@ -866,14 +912,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
866
912
  overlapLines?: number | undefined;
867
913
  } | undefined;
868
914
  } | undefined;
869
- subagent?: {
870
- maxDepth?: number | undefined;
871
- defaultBudget?: {
872
- maxTokens?: number | undefined;
873
- maxIterations?: number | undefined;
874
- timeoutMs?: number | undefined;
875
- } | undefined;
876
- } | undefined;
877
915
  test?: {
878
916
  maxIterations?: number | undefined;
879
917
  command?: string | undefined;
@@ -883,6 +921,10 @@ export declare const CruxyConfigSchema: z.ZodObject<{
883
921
  enabled?: boolean | undefined;
884
922
  trustPrompt?: boolean | undefined;
885
923
  } | undefined;
924
+ routing?: {
925
+ map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
926
+ default?: "kavi" | "vaani" | "mira" | undefined;
927
+ } | undefined;
886
928
  mcpServers?: Record<string, {
887
929
  command?: string | undefined;
888
930
  args?: string[] | undefined;
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { LOG_LEVELS } from "../utils/logger.js";
3
+ import { MODEL_TIERS } from "../brand/voice.js";
4
+ import { TASK_CLASSES } from "../routing/types.js";
3
5
  export const ProviderSchema = z.enum([
4
6
  "cruxy",
5
7
  "anthropic",
@@ -254,6 +256,23 @@ export const HooksConfigSchema = z
254
256
  trustPrompt: z.boolean().default(true),
255
257
  })
256
258
  .strict();
259
+ /**
260
+ * Multi-model routing (C.30): map declared task classes to tiers so mechanical
261
+ * work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
262
+ * with an empty `map` and no `default`, every task class routes to the session's
263
+ * single tier and behavior is unchanged (routing stays inert until configured).
264
+ * Only tier names appear here; upstream model names never do (U.8). Keys are the
265
+ * fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
266
+ */
267
+ export const RoutingConfigSchema = z
268
+ .object({
269
+ /** Tier for any task class not in `map`. Unset → the tier implied by
270
+ * `model.model` (a real tier, or the auto-fallback tier). */
271
+ default: z.enum(MODEL_TIERS).optional(),
272
+ /** Per-task-class tier overrides; anything omitted takes `default`. */
273
+ map: z.record(z.enum(TASK_CLASSES), z.enum(MODEL_TIERS)).default({}),
274
+ })
275
+ .strict();
257
276
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
258
277
  export const McpServerSchema = z
259
278
  .object({
@@ -278,6 +297,7 @@ export const CruxyConfigSchema = z
278
297
  test: TestConfigSchema.default({}),
279
298
  sandbox: SandboxConfigSchema.default({}),
280
299
  hooks: HooksConfigSchema.default({}),
300
+ routing: RoutingConfigSchema.default({}),
281
301
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
282
302
  logLevel: z.enum(LOG_LEVELS).default("info"),
283
303
  })
@@ -11,6 +11,15 @@ export declare function usageError(title: string, nextSteps?: string[]): CruxyEr
11
11
  export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
12
12
  export declare function configKeyUnknown(key: string): CruxyError;
13
13
  export declare function providerUnsupported(provider: string): CruxyError;
14
+ /**
15
+ * A task class is routed (C.30) to a tier the gateway does not offer. A usage
16
+ * error the user fixes in config — cruxy fails loud here rather than silently
17
+ * substituting a different tier (which would hand a user a model they never
18
+ * asked for). Distinct from runtime unavailability (overload/budget), which
19
+ * stays on the U.5 api codes. Params are tier/class NAMES only — never an
20
+ * upstream model id — so the message is gag-safe by construction (U.8).
21
+ */
22
+ export declare function routingTierUnavailable(tier: string, taskClass: string, offered: string[]): CruxyError;
14
23
  export declare function configParse(path: string, underlying?: unknown): CruxyError;
15
24
  export declare function configInvalid(issues: string, path?: string): CruxyError;
16
25
  export declare function authMissingKey(provider: string, envVar: string): CruxyError;
@@ -1,4 +1,5 @@
1
1
  import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
+ import { scrubModelNames } from "../brand/index.js";
2
3
  import { CruxyError, ErrorCode } from "./types.js";
3
4
  /**
4
5
  * Helper constructors for {@link CruxyError}. Each encodes the title, the human
@@ -15,6 +16,18 @@ export function messageOf(underlying) {
15
16
  return undefined;
16
17
  return String(underlying);
17
18
  }
19
+ /**
20
+ * {@link messageOf}, gag-scrubbed (U.8) for a gateway/provider-originated error.
21
+ * A gateway error body is an arbitrary EXTERNAL string, so it bypasses the
22
+ * structural tier gag — this is the boundary that scrubs any upstream model name
23
+ * out of it before it can reach a user-facing `cause`. Used by the provider-error
24
+ * constructors below (everything `classifyProviderError` routes through). The raw
25
+ * message stays on `underlying`, shown verbatim only under `--verbose`.
26
+ */
27
+ function scrubbedMessageOf(underlying) {
28
+ const msg = messageOf(underlying);
29
+ return msg === undefined ? undefined : scrubModelNames(msg);
30
+ }
18
31
  // ── usage (exit 2) ────────────────────────────────────────────────────────────
19
32
  export function usageError(title, nextSteps) {
20
33
  return new CruxyError({
@@ -57,6 +70,28 @@ export function providerUnsupported(provider) {
57
70
  meta: { provider },
58
71
  });
59
72
  }
73
+ /**
74
+ * A task class is routed (C.30) to a tier the gateway does not offer. A usage
75
+ * error the user fixes in config — cruxy fails loud here rather than silently
76
+ * substituting a different tier (which would hand a user a model they never
77
+ * asked for). Distinct from runtime unavailability (overload/budget), which
78
+ * stays on the U.5 api codes. Params are tier/class NAMES only — never an
79
+ * upstream model id — so the message is gag-safe by construction (U.8).
80
+ */
81
+ export function routingTierUnavailable(tier, taskClass, offered) {
82
+ return new CruxyError({
83
+ code: ErrorCode.RoutingTierUnavailable,
84
+ title: `the "${tier}" tier is not available for the "${taskClass}" task`,
85
+ cause: `routing maps "${taskClass}" to the "${tier}" tier, which this gateway does not currently offer`,
86
+ nextSteps: [
87
+ `route it to an available tier, e.g. \`cruxy config set routing.map.${taskClass} ${offered[0] ?? "vaani"}\``,
88
+ offered.length
89
+ ? `available tiers: ${offered.join(", ")}`
90
+ : "no tiers are currently available",
91
+ ],
92
+ meta: { tier, taskClass, offered },
93
+ });
94
+ }
60
95
  // ── config (exit 3) ───────────────────────────────────────────────────────────
61
96
  export function configParse(path, underlying) {
62
97
  return new CruxyError({
@@ -99,7 +134,7 @@ export function authInvalid(underlying) {
99
134
  return new CruxyError({
100
135
  code: ErrorCode.AuthInvalid,
101
136
  title: "the provider rejected your credentials",
102
- cause: messageOf(underlying),
137
+ cause: scrubbedMessageOf(underlying),
103
138
  nextSteps: [
104
139
  "verify your API key is correct and active",
105
140
  "re-export the key and try again",
@@ -112,7 +147,7 @@ export function gatewayUnreachable(underlying) {
112
147
  return new CruxyError({
113
148
  code: ErrorCode.GatewayUnreachable,
114
149
  title: "could not reach the model gateway",
115
- cause: messageOf(underlying),
150
+ cause: scrubbedMessageOf(underlying),
116
151
  nextSteps: [
117
152
  "check your internet connection",
118
153
  "verify the gateway URL with `cruxy config get cruxy.gatewayUrl`",
@@ -129,7 +164,7 @@ export function apiError(underlying) {
129
164
  title: status
130
165
  ? `the model provider returned an error (HTTP ${status})`
131
166
  : "the model provider returned an error",
132
- cause: messageOf(underlying),
167
+ cause: scrubbedMessageOf(underlying),
133
168
  nextSteps: [
134
169
  "retry in a moment; if it persists, check the provider's status",
135
170
  ],
@@ -142,7 +177,7 @@ export function apiRateLimit(underlying) {
142
177
  return new CruxyError({
143
178
  code: ErrorCode.ApiRateLimit,
144
179
  title: "rate limited by the model provider",
145
- cause: messageOf(underlying),
180
+ cause: scrubbedMessageOf(underlying),
146
181
  nextSteps: [
147
182
  retryAfterMs
148
183
  ? `wait ~${Math.ceil(retryAfterMs / 1000)}s and retry`
@@ -156,7 +191,7 @@ export function apiOverloaded(underlying) {
156
191
  return new CruxyError({
157
192
  code: ErrorCode.ApiOverloaded,
158
193
  title: "the model provider is overloaded",
159
- cause: messageOf(underlying),
194
+ cause: scrubbedMessageOf(underlying),
160
195
  nextSteps: ["retry in a few moments"],
161
196
  underlying,
162
197
  });
@@ -165,7 +200,7 @@ export function budgetExhausted(underlying) {
165
200
  return new CruxyError({
166
201
  code: ErrorCode.BudgetExhausted,
167
202
  title: "your Cruxy budget is exhausted",
168
- cause: messageOf(underlying),
203
+ cause: scrubbedMessageOf(underlying),
169
204
  nextSteps: ["top up or raise your budget, then retry"],
170
205
  underlying,
171
206
  });
@@ -21,6 +21,9 @@ export declare const ErrorCode: {
21
21
  readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
22
22
  readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
23
23
  readonly CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND";
24
+ /** Routing (C.30): a task class is mapped to a tier the gateway does not
25
+ * offer — fix the config, NEVER a silent substitution to another tier. */
26
+ readonly RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE";
24
27
  readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
25
28
  readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
26
29
  readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
@@ -23,6 +23,9 @@ export const ErrorCode = {
23
23
  PlanInvalid: "CRUXY_E_PLAN_INVALID",
24
24
  PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
25
25
  CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND",
26
+ /** Routing (C.30): a task class is mapped to a tier the gateway does not
27
+ * offer — fix the config, NEVER a silent substitution to another tier. */
28
+ RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE",
26
29
  // config (exit 3)
27
30
  ConfigParse: "CRUXY_E_CONFIG_PARSE",
28
31
  ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
@@ -97,6 +100,7 @@ const EXIT_CODES = {
97
100
  [ErrorCode.PlanInvalid]: 2,
98
101
  [ErrorCode.PlanRevisionLimit]: 2,
99
102
  [ErrorCode.CheckpointNotFound]: 2,
103
+ [ErrorCode.RoutingTierUnavailable]: 2,
100
104
  [ErrorCode.ConfigParse]: 3,
101
105
  [ErrorCode.ConfigInvalid]: 3,
102
106
  [ErrorCode.AuthMissingKey]: 4,
@@ -4,6 +4,7 @@ import type { PromptIO } from "../approval/index.js";
4
4
  import { ToolRegistry, type ToolContext } from "../tools/index.js";
5
5
  import { type AgentResult } from "../agent/loop.js";
6
6
  import type { StreamRenderer } from "../render/index.js";
7
+ import type { Router } from "../routing/index.js";
7
8
  import { PlanExecutionPolicy } from "./policy.js";
8
9
  /**
9
10
  * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
@@ -36,5 +37,10 @@ export interface PlanSessionArgs {
36
37
  renderer?: StreamRenderer;
37
38
  /** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
38
39
  maxRevisions?: number;
40
+ /**
41
+ * Multi-model routing (C.30). When set, the propose phase routes on `plan`
42
+ * and step execution on `main-turn`; omitted → the provider default.
43
+ */
44
+ router?: Router;
39
45
  }
40
46
  export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
@@ -75,6 +75,8 @@ export async function runPlanSession(args) {
75
75
  projectInstructions: args.projectInstructions,
76
76
  renderer: args.renderer,
77
77
  planMode: true,
78
+ router: args.router,
79
+ taskClass: "plan",
78
80
  }));
79
81
  if (!holder.plan) {
80
82
  throw planInvalid("the model ended its turn without calling submit_plan");
@@ -106,6 +108,8 @@ export async function runPlanSession(args) {
106
108
  git: args.git,
107
109
  projectInstructions: args.projectInstructions,
108
110
  renderer: args.renderer,
111
+ router: args.router,
112
+ taskClass: "main-turn",
109
113
  }));
110
114
  };
111
115
  await executePlan(plan, {
@@ -39,9 +39,12 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
39
39
  case "thinking": {
40
40
  const t = phase.tokens;
41
41
  // Honest numbers only: no usage yet → no figure at all.
42
- return t && t.input + t.output > 0
42
+ const base = t && t.input + t.output > 0
43
43
  ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
44
44
  : `thinking${e}`;
45
+ // The real routing tier (C.30), appended honestly when present — same
46
+ // ` · ` joiner as the token counts.
47
+ return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
45
48
  }
46
49
  case "calling-tool":
47
50
  return `${phase.label}${e}`;
@@ -53,10 +53,16 @@ export interface TokenUsage {
53
53
  * One phase is live at a time — it is a register, not a queue.
54
54
  */
55
55
  export type RenderPhase =
56
- /** Waiting on the model. `tokens` = usage accumulated so far, omitted at 0. */
56
+ /**
57
+ * Waiting on the model. `tokens` = usage accumulated so far, omitted at 0.
58
+ * `tier` = the routing tier this turn runs on (C.30), shown only when routing
59
+ * is active — an honest signal of the real tier, never a fabricated one, and
60
+ * always a tier name (never an upstream model id, U.8).
61
+ */
57
62
  {
58
63
  kind: "thinking";
59
64
  tokens?: TokenUsage;
65
+ tier?: string;
60
66
  }
61
67
  /** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
62
68
  | {
@@ -0,0 +1,2 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
3
+ // `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
4
+ // mapping is internal to routing, so it can never be reached from a user-facing
5
+ // render path (the internal-mapping-isolation guarantee, U.8).
@@ -0,0 +1,17 @@
1
+ import type { Tier } from "./types.js";
2
+ /**
3
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
4
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
5
+ * barrel) so it can never be called from a user-facing render path.
6
+ *
7
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
8
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
9
+ * unchanged: the output is always a tier, and no upstream model name can
10
+ * originate here. That property is what keeps the U.8 gag structural rather than
11
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
12
+ * test, which asserts this output is always a MODEL_TIERS member).
13
+ *
14
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
15
+ * that mapping would live here and here only.
16
+ */
17
+ export declare function resolveModelId(tier: Tier): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
3
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
4
+ * barrel) so it can never be called from a user-facing render path.
5
+ *
6
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
7
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
8
+ * unchanged: the output is always a tier, and no upstream model name can
9
+ * originate here. That property is what keeps the U.8 gag structural rather than
10
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
11
+ * test, which asserts this output is always a MODEL_TIERS member).
12
+ *
13
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
14
+ * that mapping would live here and here only.
15
+ */
16
+ export function resolveModelId(tier) {
17
+ return tier;
18
+ }
@@ -0,0 +1,47 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { Router, RoutingConfig, TaskClass, Tier } from "./types.js";
3
+ /**
4
+ * The tier a config resolves to when nothing else pins one down — mirrors the
5
+ * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
6
+ * cruxy session lands on exactly the tier it does today.
7
+ */
8
+ export declare const DEFAULT_TIER: Tier;
9
+ /**
10
+ * The config-driven {@link Router}: maps a declared task class to a tier from
11
+ * `{ default, map }`, and fails loud when the resolved tier is not offered. It
12
+ * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
13
+ */
14
+ export declare class ConfigRouter implements Router {
15
+ private readonly cfg;
16
+ private readonly offered;
17
+ /**
18
+ * @param cfg the resolved routing table (default tier + per-task map)
19
+ * @param offered the tiers this gateway/plan actually provides; a resolved
20
+ * tier outside this set fails loud. Defaults to all tiers — the
21
+ * seam a future entitlement check narrows (never a silent
22
+ * downgrade).
23
+ */
24
+ constructor(cfg: RoutingConfig, offered?: Iterable<Tier>);
25
+ select(taskClass: TaskClass): Tier;
26
+ }
27
+ /**
28
+ * Build a router from resolved config, or `null` when routing should stay
29
+ * inert. Routing is:
30
+ *
31
+ * - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
32
+ * providers get `null` (no override, their `model.model` is used unchanged);
33
+ * - opt-in — with no `routing.default` and an empty `routing.map`, this returns
34
+ * `null` so behavior (and the wire body, and the state line) is byte-identical
35
+ * to today. Multi-tier routing activates only once the user configures it.
36
+ */
37
+ export declare function routerForConfig(config: CruxyConfig): Router | null;
38
+ /**
39
+ * Resolve a declared task class to `{ tier, model }`: the tier for honest
40
+ * surfacing (the U.4 state line), the wire model id for the request. The model
41
+ * id comes from the internal {@link resolveModelId} — callers never touch that
42
+ * mapping directly, so it stays the single source of truth.
43
+ */
44
+ export declare function resolveTaskModel(router: Router, taskClass: TaskClass): {
45
+ tier: Tier;
46
+ model: string;
47
+ };
@@ -0,0 +1,84 @@
1
+ import { MODEL_TIERS } from "../brand/voice.js";
2
+ import { routingTierUnavailable } from "../errors/index.js";
3
+ import { resolveModelId } from "./resolve.js";
4
+ /**
5
+ * The tier a config resolves to when nothing else pins one down — mirrors the
6
+ * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
7
+ * cruxy session lands on exactly the tier it does today.
8
+ */
9
+ export const DEFAULT_TIER = "vaani";
10
+ /**
11
+ * The config-driven {@link Router}: maps a declared task class to a tier from
12
+ * `{ default, map }`, and fails loud when the resolved tier is not offered. It
13
+ * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
14
+ */
15
+ export class ConfigRouter {
16
+ cfg;
17
+ offered;
18
+ /**
19
+ * @param cfg the resolved routing table (default tier + per-task map)
20
+ * @param offered the tiers this gateway/plan actually provides; a resolved
21
+ * tier outside this set fails loud. Defaults to all tiers — the
22
+ * seam a future entitlement check narrows (never a silent
23
+ * downgrade).
24
+ */
25
+ constructor(cfg, offered = MODEL_TIERS) {
26
+ this.cfg = cfg;
27
+ this.offered = new Set(offered);
28
+ }
29
+ select(taskClass) {
30
+ // Explicit override, else the default — an unmapped/unknown class is not an
31
+ // error, it just takes the default (never a crash, never the cheapest).
32
+ const tier = this.cfg.map[taskClass] ?? this.cfg.default;
33
+ // Fail loud: a configured tier the gateway does not offer is a usage error
34
+ // to fix, NOT a silent substitution to some other tier (a user who asked for
35
+ // mira reasoning must never be quietly handed kavi).
36
+ if (!this.offered.has(tier)) {
37
+ throw routingTierUnavailable(tier, taskClass, [...this.offered]);
38
+ }
39
+ return tier;
40
+ }
41
+ }
42
+ /**
43
+ * The base tier implied by the session's `model.model`: a real tier passes
44
+ * through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
45
+ * Used so that when a user has pinned a single tier, an opt-in routing table
46
+ * that omits `routing.default` still defaults to THEIR tier, not a fixed one.
47
+ */
48
+ function baseTierFromModel(model) {
49
+ return MODEL_TIERS.includes(model)
50
+ ? model
51
+ : DEFAULT_TIER;
52
+ }
53
+ /**
54
+ * Build a router from resolved config, or `null` when routing should stay
55
+ * inert. Routing is:
56
+ *
57
+ * - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
58
+ * providers get `null` (no override, their `model.model` is used unchanged);
59
+ * - opt-in — with no `routing.default` and an empty `routing.map`, this returns
60
+ * `null` so behavior (and the wire body, and the state line) is byte-identical
61
+ * to today. Multi-tier routing activates only once the user configures it.
62
+ */
63
+ export function routerForConfig(config) {
64
+ if (config.model.provider !== "cruxy")
65
+ return null;
66
+ const { default: def, map } = config.routing;
67
+ const configured = def !== undefined || Object.keys(map).length > 0;
68
+ if (!configured)
69
+ return null;
70
+ return new ConfigRouter({
71
+ default: def ?? baseTierFromModel(config.model.model),
72
+ map,
73
+ });
74
+ }
75
+ /**
76
+ * Resolve a declared task class to `{ tier, model }`: the tier for honest
77
+ * surfacing (the U.4 state line), the wire model id for the request. The model
78
+ * id comes from the internal {@link resolveModelId} — callers never touch that
79
+ * mapping directly, so it stays the single source of truth.
80
+ */
81
+ export function resolveTaskModel(router, taskClass) {
82
+ const tier = router.select(taskClass);
83
+ return { tier, model: resolveModelId(tier) };
84
+ }
@@ -0,0 +1,42 @@
1
+ import { MODEL_TIERS } from "../brand/voice.js";
2
+ /**
3
+ * Multi-model routing (C.30): route each unit of work to the right tier instead
4
+ * of running one model for everything. The caller DECLARES a {@link TaskClass}
5
+ * at the call site; a {@link Router} maps that class to a {@link Tier} via config
6
+ * — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
7
+ * mapping is internal (see `resolve.ts`); only tier names ever appear in config,
8
+ * logs, errors, or the state line (the U.8 tier gag).
9
+ */
10
+ /**
11
+ * The unit-of-work classes a caller can declare. Each is an explicit intent —
12
+ * NOT a difficulty the router infers. Unknown/unset resolves to the router's
13
+ * default tier, never a crash.
14
+ */
15
+ export declare const TASK_CLASSES: readonly ["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"];
16
+ export type TaskClass = (typeof TASK_CLASSES)[number];
17
+ /** A routing tier — the ONLY model vocabulary the user ever sees (U.8). */
18
+ export type Tier = (typeof MODEL_TIERS)[number];
19
+ /**
20
+ * Selects a tier for a declared task class. Deliberately one method: the caller
21
+ * passes intent, the router returns a tier from its configured mapping. No
22
+ * prompt-content inspection, ever — difficulty detection is explicitly out of
23
+ * scope (a caller declares; the router does not guess).
24
+ */
25
+ export interface Router {
26
+ /**
27
+ * Map a task class to its tier. Falls back to the configured default when the
28
+ * class has no explicit mapping; throws `CRUXY_E_ROUTING_TIER_UNAVAILABLE`
29
+ * (fail loud, never a silent substitution) when the resolved tier is not
30
+ * offered.
31
+ */
32
+ select(taskClass: TaskClass): Tier;
33
+ }
34
+ /**
35
+ * The declarative routing table: a `default` tier plus per-task overrides. Lives
36
+ * in config (`routing.default`, `routing.map`). An empty map means every class
37
+ * resolves to `default` — a single tier, no traffic splitting.
38
+ */
39
+ export interface RoutingConfig {
40
+ default: Tier;
41
+ map: Partial<Record<TaskClass, Tier>>;
42
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Multi-model routing (C.30): route each unit of work to the right tier instead
3
+ * of running one model for everything. The caller DECLARES a {@link TaskClass}
4
+ * at the call site; a {@link Router} maps that class to a {@link Tier} via config
5
+ * — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
6
+ * mapping is internal (see `resolve.ts`); only tier names ever appear in config,
7
+ * logs, errors, or the state line (the U.8 tier gag).
8
+ */
9
+ /**
10
+ * The unit-of-work classes a caller can declare. Each is an explicit intent —
11
+ * NOT a difficulty the router infers. Unknown/unset resolves to the router's
12
+ * default tier, never a crash.
13
+ */
14
+ export const TASK_CLASSES = [
15
+ /** An interactive main-agent turn. */
16
+ "main-turn",
17
+ /** A spawned subagent task (C.14). */
18
+ "subagent",
19
+ /** Plan proposal / revision (C.31). */
20
+ "plan",
21
+ /** Commit / pull-request text generation. */
22
+ "commit-msg",
23
+ /** A cheap one-shot classification. */
24
+ "classify",
25
+ /** Context compaction / summarization. */
26
+ "summarize",
27
+ ];
@@ -2,6 +2,7 @@ import type { Provider } from "@cruxy/sdk";
2
2
  import type { ApprovalDecision } from "../approval/types.js";
3
3
  import type { CruxyConfig } from "../config/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
+ import type { Router } from "../routing/index.js";
5
6
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
6
7
  import type { SandboxService } from "../sandbox/index.js";
7
8
  import type { SubagentResult, SubagentSpec } from "./types.js";
@@ -15,6 +16,11 @@ import type { SubagentResult, SubagentSpec } from "./types.js";
15
16
  export interface SubagentOrchestratorDeps {
16
17
  provider: Provider;
17
18
  config: CruxyConfig;
19
+ /**
20
+ * Multi-model routing (C.30). When set, child runs route on their spec's task
21
+ * class (default `subagent`); omitted → the provider default (unchanged).
22
+ */
23
+ router?: Router;
18
24
  /** The parent's registry — the ceiling every child scope derives from. */
19
25
  parentRegistry: ToolRegistry;
20
26
  cwd: string;
@@ -91,6 +91,8 @@ export class SubagentOrchestrator {
91
91
  projectInstructions: deps.projectInstructions,
92
92
  subagent: true,
93
93
  budget,
94
+ router: deps.router,
95
+ taskClass: spec.taskClass ?? "subagent",
94
96
  });
95
97
  }
96
98
  catch (err) {
@@ -1,4 +1,5 @@
1
1
  import type { Usage } from "@cruxy/sdk";
2
+ import type { TaskClass } from "../routing/index.js";
2
3
  /**
3
4
  * Types for subagent orchestration (C.14): the main agent delegates a bounded
4
5
  * subtask to a child agent that runs the SAME loop with its own fresh history,
@@ -35,6 +36,11 @@ export interface SubagentSpec {
35
36
  * spawn can narrow its budget, never raise it past the configured ceilings.
36
37
  */
37
38
  budget?: Partial<BudgetLimits>;
39
+ /**
40
+ * Routing task class for this spawn (C.30); defaults to `subagent`. A
41
+ * declaration at the spawn call site — not something the router guesses.
42
+ */
43
+ taskClass?: TaskClass;
38
44
  }
39
45
  /**
40
46
  * What the parent gets back — compact structured data, never the transcript.
@@ -59,7 +59,9 @@ export declare function fillContent(input: GenerateInput): GeneratedContent;
59
59
  * rules, then normalize + redact. Resilient: if the model's reply isn't the
60
60
  * expected JSON, the first line becomes the subject and the rest the body.
61
61
  */
62
- export declare function generateWithLlm(provider: Provider, input: GenerateInput): Promise<GeneratedContent>;
62
+ export declare function generateWithLlm(provider: Provider, input: GenerateInput, opts?: {
63
+ model?: string;
64
+ }): Promise<GeneratedContent>;
63
65
  interface ParsedGenerated {
64
66
  branchName?: string;
65
67
  commitSubject?: string;
@@ -154,7 +154,7 @@ export function fillContent(input) {
154
154
  * rules, then normalize + redact. Resilient: if the model's reply isn't the
155
155
  * expected JSON, the first line becomes the subject and the rest the body.
156
156
  */
157
- export async function generateWithLlm(provider, input) {
157
+ export async function generateWithLlm(provider, input, opts = {}) {
158
158
  const redactedDiff = redactSecrets(input.diff);
159
159
  const system = buildSystemPrompt(input);
160
160
  const user = buildUserPrompt({ ...input, diff: redactedDiff });
@@ -162,6 +162,9 @@ export async function generateWithLlm(provider, input) {
162
162
  for await (const ev of provider.stream({
163
163
  system,
164
164
  messages: [{ role: "user", content: user }],
165
+ // The commit-msg tier's wire model (C.30), resolved by the caller; omitted →
166
+ // the provider default.
167
+ ...(opts.model ? { model: opts.model } : {}),
165
168
  })) {
166
169
  if (ev.type === "text_delta")
167
170
  text += ev.text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "tinyglobby": "^0.2.10",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.1.0"
39
+ "@cruxy/sdk": "0.2.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/better-sqlite3": "^7.6.13",