main
1import { describe, it } from "node:test";
2import assert from "node:assert/strict";
3import { resolveSubagentModel } from "./model-routing.ts";
4
5const catalog = [
6 { provider: "github-copilot", modelId: "claude-opus-5" },
7 { provider: "github-copilot", modelId: "claude-opus-4-8" },
8 { provider: "github-copilot", modelId: "claude-opus-4-7" },
9 { provider: "anthropic-vertex", modelId: "claude-opus-5" },
10 { provider: "anthropic-vertex", modelId: "claude-opus-4-8" },
11 { provider: "anthropic-vertex", modelId: "claude-opus-4-7" },
12 { provider: "google", modelId: "claude-opus-4-8" },
13];
14const ready = new Set(catalog.map(({ provider, modelId }) => `${provider}\u0000${modelId}`));
15
16const baseOptions = {
17 parentProvider: "github-copilot",
18 providerPreference: ["anthropic-vertex", "google"],
19 requestedModel: "claude-opus-5",
20 modelFallbacks: { "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7"] },
21 providerModelMasks: {},
22 catalog,
23 ready,
24};
25
26function resolve(overrides: Partial<typeof baseOptions> = {}) {
27 return resolveSubagentModel({ ...baseOptions, ...overrides });
28}
29
30describe("resolveSubagentModel", () => {
31 it("prefers the parent provider for the requested model", () => {
32 assert.deepEqual(resolve(), {
33 selected: { provider: "github-copilot", modelId: "claude-opus-5" },
34 rejected: [],
35 });
36 });
37
38 it("skips a masked parent provider/model pair before selection", () => {
39 assert.deepEqual(
40 resolve({
41 providerModelMasks: { "github-copilot": ["claude-opus-5"] },
42 }),
43 {
44 selected: { provider: "github-copilot", modelId: "claude-opus-4-8" },
45 rejected: [{ provider: "github-copilot", modelId: "claude-opus-5", reason: "masked" }],
46 },
47 );
48 });
49
50 it("uses an allowed same-tier fallback on the parent provider", () => {
51 const result = resolve({
52 providerModelMasks: { "github-copilot": ["claude-opus-5"] },
53 });
54 assert.deepEqual(result.selected, { provider: "github-copilot", modelId: "claude-opus-4-8" });
55 });
56
57 it("uses the configured Vertex mask without masking the same Copilot model", () => {
58 const result = resolve({
59 parentProvider: "anthropic-vertex",
60 providerModelMasks: { "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"] },
61 });
62 assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-7" });
63 assert.deepEqual(result.rejected, [
64 { provider: "anthropic-vertex", modelId: "claude-opus-5", reason: "masked" },
65 { provider: "anthropic-vertex", modelId: "claude-opus-4-8", reason: "masked" },
66 ]);
67 });
68
69 it("uses only a pinned provider instead of the parent provider", () => {
70 const result = resolve({ pinnedProvider: "google" });
71 assert.deepEqual(result.selected, { provider: "google", modelId: "claude-opus-4-8" });
72 });
73
74 it("does not fall back to another provider for a pinned provider", () => {
75 const result = resolve({
76 pinnedProvider: "google",
77 catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
78 ready: new Set(["github-copilot\u0000claude-opus-5"]),
79 });
80 assert.equal(result.selected, null);
81 assert.ok(result.rejected.every((entry) => entry.provider === "google"));
82 });
83
84 it("moves to configured providers after parent candidates are unavailable", () => {
85 const result = resolve({
86 catalog: [{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" }],
87 ready: new Set(["anthropic-vertex\u0000claude-opus-4-8"]),
88 });
89 assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-8" });
90 });
91
92 it("does not invent a cross-tier fallback", () => {
93 const result = resolve({
94 catalog: [{ provider: "github-copilot", modelId: "claude-sonnet-5" }],
95 ready: new Set(["github-copilot\u0000claude-sonnet-5"]),
96 });
97 assert.equal(result.selected, null);
98 assert.ok(result.rejected.every((entry) => entry.modelId.startsWith("claude-opus-")));
99 });
100
101 it("reports catalog and credential rejection reasons", () => {
102 const result = resolve({
103 catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
104 ready: new Set(),
105 });
106 assert.deepEqual(result.selected, null);
107 assert.ok(result.rejected.some((entry) => entry.reason === "missing-credentials"));
108 assert.ok(result.rejected.some((entry) => entry.reason === "not-in-catalog"));
109 });
110
111 it("retains static preference behavior when no parent provider or new settings exist", () => {
112 const result = resolveSubagentModel({
113 parentProvider: undefined,
114 providerPreference: ["anthropic-vertex", "github-copilot"],
115 requestedModel: "claude-opus-5",
116 modelFallbacks: {},
117 providerModelMasks: {},
118 catalog,
119 ready,
120 });
121 assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-5" });
122 });
123
124 it("selects a dated catalog model for an undated requested ID", () => {
125 const result = resolveSubagentModel({
126 parentProvider: "anthropic-vertex",
127 providerPreference: [],
128 requestedModel: "claude-haiku-4-5",
129 modelFallbacks: {},
130 providerModelMasks: {},
131 catalog: [{ provider: "anthropic-vertex", modelId: "claude-haiku-4-5@20251001" }],
132 ready: new Set(["anthropic-vertex\u0000claude-haiku-4-5@20251001"]),
133 });
134 assert.deepEqual(result.selected, {
135 provider: "anthropic-vertex",
136 modelId: "claude-haiku-4-5@20251001",
137 });
138 });
139});