Skip to content

Commit 29585f9

Browse files
committed
refactor(orchestrator): centralize worker model resolution
1 parent 67c439b commit 29585f9

4 files changed

Lines changed: 196 additions & 87 deletions

File tree

packages/orchestrator/src/models/hydrate.ts

Lines changed: 14 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,9 @@ import {
33
fetchModelInfo,
44
fetchOpencodeConfig,
55
fetchProviders,
6-
filterProviders,
7-
flattenProviders,
86
parseFullModelID,
9-
pickDocsModel,
10-
pickFastModel,
11-
pickVisionModel,
12-
resolveModelRef,
137
} from "./catalog";
8+
import { resolveFallbackModel, resolveWorkerModel } from "./resolve";
149

1510
export type ProfileModelHydrationChange = {
1611
profileId: string;
@@ -34,88 +29,24 @@ export async function hydrateProfileModelsFromOpencode(input: {
3429
]);
3530

3631
const providersAll = providersRes.providers;
37-
// For auto-selection (node:vision, node:fast, etc.), prefer configured providers.
38-
// But allow ALL providers for explicit model references since the user chose them.
39-
const providersUsable = filterProviders(providersAll, "configured");
40-
const catalog = flattenProviders(providersUsable);
41-
42-
// Collect provider IDs explicitly referenced in profile models (user intent = use them)
43-
const explicitlyReferencedProviders = new Set<string>();
44-
for (const profile of Object.values(input.profiles)) {
45-
const model = profile.model.trim();
46-
if (model.includes("/") && !model.startsWith("auto") && !model.startsWith("node")) {
47-
const providerID = model.split("/")[0];
48-
explicitlyReferencedProviders.add(providerID);
49-
}
50-
}
51-
52-
const fallbackCandidate =
53-
cfg?.model ||
54-
(providersRes.defaults?.opencode ? `opencode/${providersRes.defaults.opencode}` : undefined) ||
55-
"opencode/gpt-5-nano";
56-
57-
const resolvedFallback = resolveModelRef(fallbackCandidate, providersAll);
58-
const fallbackModel = "error" in resolvedFallback ? fallbackCandidate : resolvedFallback.full;
32+
const fallbackModel = resolveFallbackModel({
33+
config: cfg,
34+
providers: providersAll,
35+
providerDefaults: providersRes.defaults,
36+
});
5937

6038
const changes: ProfileModelHydrationChange[] = [];
6139

62-
const resolveAuto = (profile: WorkerProfile): { model: string; reason: string } => {
63-
const tag = profile.model;
64-
const isVision = profile.supportsVision || /(?:auto|node):vision/i.test(tag);
65-
const isDocs = /(?:auto|node):docs/i.test(tag);
66-
const isFast = /(?:auto|node):fast/i.test(tag);
67-
68-
if (isFast && cfg?.small_model) {
69-
const resolvedSmall = resolveModelRef(cfg.small_model, providersAll);
70-
if (!("error" in resolvedSmall)) {
71-
return { model: resolvedSmall.full, reason: `auto-selected from small_model (${tag})` };
72-
}
73-
}
74-
75-
const picked = isVision
76-
? pickVisionModel(catalog)
77-
: isDocs
78-
? pickDocsModel(catalog)
79-
: isFast
80-
? pickFastModel(catalog)
81-
: undefined;
82-
83-
if (picked) {
84-
return { model: picked.full, reason: `auto-selected from configured models (${tag})` };
85-
}
86-
87-
// Vision workers should never silently downgrade to a text-only model.
88-
if (isVision) {
89-
throw new Error(
90-
`No vision-capable models found for "${profile.id}" (model tag: "${tag}"). ` +
91-
`Configure a vision model in OpenCode or set the profile model explicitly.`
92-
);
93-
}
94-
95-
return { model: fallbackModel, reason: `fallback to default model (${tag})` };
96-
};
97-
9840
const next: Record<string, WorkerProfile> = {};
9941
for (const [id, profile] of Object.entries(input.profiles)) {
100-
let desired = profile.model;
101-
let reason = "";
102-
103-
const modelSpec = profile.model.trim();
104-
const isNodeTag = modelSpec.startsWith("auto") || modelSpec.startsWith("node");
105-
106-
if (isNodeTag) {
107-
const resolved = resolveAuto(profile);
108-
desired = resolved.model;
109-
reason = resolved.reason;
110-
} else {
111-
// User explicitly specified a model - trust their choice and use ALL providers
112-
const resolved = resolveModelRef(profile.model, providersAll);
113-
if ("error" in resolved) {
114-
const suffix = resolved.suggestions?.length ? `\nSuggestions:\n- ${resolved.suggestions.join("\n- ")}` : "";
115-
throw new Error(`Invalid model for profile "${profile.id}": ${resolved.error}${suffix}`);
116-
}
117-
desired = resolved.full;
118-
}
42+
const resolved = resolveWorkerModel({
43+
profile,
44+
config: cfg,
45+
providers: providersAll,
46+
providerDefaults: providersRes.defaults,
47+
});
48+
const desired = resolved.resolvedModel;
49+
const reason = resolved.reason;
11950

12051
if (profile.supportsVision) {
12152
const parsed = parseFullModelID(desired);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Config, Provider } from "@opencode-ai/sdk";
2+
import type { WorkerProfile } from "../types";
3+
import {
4+
filterProviders,
5+
flattenProviders,
6+
pickDocsModel,
7+
pickFastModel,
8+
pickVisionModel,
9+
resolveModelRef,
10+
} from "./catalog";
11+
12+
export type WorkerModelResolution = {
13+
resolvedModel: string;
14+
modelRef: string;
15+
reason: string;
16+
};
17+
18+
export type ResolveWorkerModelInput = {
19+
profile: WorkerProfile;
20+
overrideModelRef?: string;
21+
config?: Config;
22+
providers: Provider[];
23+
providerDefaults?: Record<string, string>;
24+
};
25+
26+
export type ResolveFallbackModelInput = {
27+
config?: Config;
28+
providers: Provider[];
29+
providerDefaults?: Record<string, string>;
30+
};
31+
32+
export function resolveFallbackModel(input: ResolveFallbackModelInput): string {
33+
const fallbackCandidate =
34+
input.config?.model ||
35+
(input.providerDefaults?.opencode ? `opencode/${input.providerDefaults.opencode}` : undefined) ||
36+
"opencode/gpt-5-nano";
37+
const resolvedFallback = resolveModelRef(fallbackCandidate, input.providers);
38+
return "error" in resolvedFallback ? fallbackCandidate : resolvedFallback.full;
39+
}
40+
41+
export function resolveWorkerModel(input: ResolveWorkerModelInput): WorkerModelResolution {
42+
const modelRef = (input.overrideModelRef ?? input.profile.model).trim();
43+
const providersAll = input.providers;
44+
const providersUsable = filterProviders(providersAll, "configured");
45+
const catalog = flattenProviders(providersUsable);
46+
const fallbackModel = resolveFallbackModel({
47+
config: input.config,
48+
providers: providersAll,
49+
providerDefaults: input.providerDefaults,
50+
});
51+
52+
const isNodeTag = modelRef.startsWith("auto") || modelRef.startsWith("node");
53+
if (isNodeTag) {
54+
const isVision = input.profile.supportsVision || /(?:auto|node):vision/i.test(modelRef);
55+
const isDocs = /(?:auto|node):docs/i.test(modelRef);
56+
const isFast = /(?:auto|node):fast/i.test(modelRef);
57+
58+
if (isFast && input.config?.small_model) {
59+
const resolvedSmall = resolveModelRef(input.config.small_model, providersAll);
60+
if (!("error" in resolvedSmall)) {
61+
return { resolvedModel: resolvedSmall.full, modelRef, reason: `auto-selected from small_model (${modelRef})` };
62+
}
63+
}
64+
65+
const picked = isVision
66+
? pickVisionModel(catalog)
67+
: isDocs
68+
? pickDocsModel(catalog)
69+
: isFast
70+
? pickFastModel(catalog)
71+
: undefined;
72+
73+
if (picked) {
74+
return { resolvedModel: picked.full, modelRef, reason: `auto-selected from configured models (${modelRef})` };
75+
}
76+
77+
if (isVision) {
78+
throw new Error(
79+
`No vision-capable models found for "${input.profile.id}" (model tag: "${modelRef}"). ` +
80+
`Configure a vision model in OpenCode or set the profile model explicitly.`
81+
);
82+
}
83+
84+
return { resolvedModel: fallbackModel, modelRef, reason: `fallback to default model (${modelRef})` };
85+
}
86+
87+
const resolved = resolveModelRef(modelRef, providersAll);
88+
if ("error" in resolved) {
89+
const suffix = resolved.suggestions?.length ? `\nSuggestions:\n- ${resolved.suggestions.join("\n- ")}` : "";
90+
throw new Error(`Invalid model for profile "${input.profile.id}": ${resolved.error}${suffix}`);
91+
}
92+
93+
const reason = resolved.full === modelRef ? "configured" : `resolved from ${modelRef}`;
94+
return { resolvedModel: resolved.full, modelRef, reason };
95+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { Config, Provider } from "@opencode-ai/sdk";
3+
import type { WorkerProfile } from "../../src/types";
4+
import { resolveWorkerModel } from "../../src/models/resolve";
5+
6+
const makeProvider = (input: {
7+
id: string;
8+
source: Provider["source"];
9+
models?: Record<string, unknown>;
10+
key?: string;
11+
}): Provider => {
12+
return {
13+
id: input.id,
14+
source: input.source,
15+
models: input.models ?? {},
16+
key: input.key,
17+
} as Provider;
18+
};
19+
20+
const makeProfile = (model: string): WorkerProfile => {
21+
return {
22+
id: "fast",
23+
name: "Fast",
24+
model,
25+
purpose: "Test",
26+
whenToUse: "Test",
27+
};
28+
};
29+
30+
describe("resolveWorkerModel", () => {
31+
test("prefers config small_model for node:fast", () => {
32+
const configured = makeProvider({
33+
id: "cfg",
34+
source: "config",
35+
models: {
36+
"fast-small": { name: "fast-small", cost: { input: 5, output: 0, cache: { read: 0, write: 0 } } },
37+
"fast-ultra": { name: "fast-ultra", limit: { context: 128000, output: 0 } },
38+
},
39+
});
40+
41+
const resolved = resolveWorkerModel({
42+
profile: makeProfile("node:fast"),
43+
config: { model: "cfg/fast-ultra", small_model: "cfg/fast-small" } as Config,
44+
providers: [configured],
45+
});
46+
47+
expect(resolved.resolvedModel).toBe("cfg/fast-small");
48+
expect(resolved.reason).toBe("auto-selected from small_model (node:fast)");
49+
});
50+
51+
test("falls back to catalog when small_model is invalid", () => {
52+
const configured = makeProvider({
53+
id: "cfg",
54+
source: "config",
55+
models: { "fast-ultra": { name: "fast-ultra", limit: { context: 128000, output: 0 } } },
56+
});
57+
58+
const resolved = resolveWorkerModel({
59+
profile: makeProfile("node:fast"),
60+
config: { model: "cfg/fast-ultra", small_model: "cfg/missing" } as Config,
61+
providers: [configured],
62+
});
63+
64+
expect(resolved.resolvedModel).toBe("cfg/fast-ultra");
65+
expect(resolved.reason).toBe("auto-selected from configured models (node:fast)");
66+
});
67+
68+
test("accepts explicit api provider models without key", () => {
69+
const api = makeProvider({
70+
id: "api",
71+
source: "api",
72+
models: { "fast-ultra": { name: "fast-ultra" } },
73+
});
74+
75+
const resolved = resolveWorkerModel({
76+
profile: makeProfile("api/fast-ultra"),
77+
providers: [api],
78+
});
79+
80+
expect(resolved.resolvedModel).toBe("api/fast-ultra");
81+
expect(resolved.reason).toBe("configured");
82+
});
83+
});

tasks/task-05.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ git push origin main
7676

7777
## Completion record (fill in when done)
7878

79-
- Commit: `<sha>`
79+
- Commit: `HEAD`
8080
- After references (update with final line numbers):
81-
- `packages/orchestrator/src/models/resolve.ts:<line>`
82-
- `packages/orchestrator/src/models/hydrate.ts:<line>`
83-
- `packages/orchestrator/test/unit/<new test file>:<line>`
81+
- `packages/orchestrator/src/models/resolve.ts:41`
82+
- `packages/orchestrator/src/models/hydrate.ts:42`
83+
- `packages/orchestrator/test/unit/models-resolve-worker.test.ts:1`

0 commit comments

Comments
 (0)