fix: remove hardcoded model defaults from categories and agents
BREAKING CHANGE: Model resolution overhauled - Created centralized model-resolver.ts with priority chain: userModel → inheritedModel → systemDefaultModel - Removed model field from all 7 DEFAULT_CATEGORIES entries - Removed DEFAULT_MODEL constants from 10 agents - Removed singleton agent exports (use factories instead) - Made CategoryConfigSchema.model optional - CLI no longer generates model overrides - Empty strings treated as unset (uses fallback) Users must now: 1. Use factory functions (createOracleAgent, etc.) instead of singletons 2. Provide model explicitly or use systemDefaultModel 3. Configure category models explicitly if needed Fixes model fallback bug where hardcoded defaults overrode user's OpenCode configured model.
This commit is contained in:
@@ -26,3 +26,4 @@ export * from "./session-cursor"
|
||||
export * from "./shell-env"
|
||||
export * from "./system-directive"
|
||||
export * from "./agent-tool-restrictions"
|
||||
export * from "./model-resolver"
|
||||
|
||||
@@ -370,9 +370,9 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
|
||||
test("returns true when all fields match category defaults", () => {
|
||||
// #given: Config with fields matching category defaults
|
||||
// Note: DEFAULT_CATEGORIES only has temperature, not model
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "google/gemini-3-pro-preview",
|
||||
temperature: 0.7,
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -44,7 +44,19 @@ export const HOOK_NAME_MAP: Record<string, string> = {
|
||||
"anthropic-auto-compact": "anthropic-context-window-limit-recovery",
|
||||
}
|
||||
|
||||
// Model to category mapping for auto-migration
|
||||
/**
|
||||
* @deprecated LEGACY MIGRATION ONLY
|
||||
*
|
||||
* This map exists solely for migrating old configs that used hardcoded model strings.
|
||||
* It maps legacy model strings to semantic category names, allowing users to migrate
|
||||
* from explicit model configs to category-based configs.
|
||||
*
|
||||
* DO NOT add new entries here. New agents should use:
|
||||
* - Category-based config (preferred): { category: "most-capable" }
|
||||
* - Or inherit from OpenCode's config.model
|
||||
*
|
||||
* This map will be removed in a future major version once migration period ends.
|
||||
*/
|
||||
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
||||
"google/gemini-3-pro-preview": "visual-engineering",
|
||||
"openai/gpt-5.2": "ultrabrain",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveModel, type ModelResolutionInput } from "./model-resolver";
|
||||
|
||||
describe("resolveModel", () => {
|
||||
describe("priority chain", () => {
|
||||
test("returns userModel when all three are set", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result).toBe("anthropic/claude-opus-4-5");
|
||||
});
|
||||
|
||||
test("returns inheritedModel when userModel is undefined", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result).toBe("openai/gpt-5.2");
|
||||
});
|
||||
|
||||
test("returns systemDefault when both userModel and inheritedModel are undefined", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: undefined,
|
||||
inheritedModel: undefined,
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result).toBe("google/gemini-3-pro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty string handling", () => {
|
||||
test("treats empty string as unset, uses fallback", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result).toBe("openai/gpt-5.2");
|
||||
});
|
||||
|
||||
test("treats whitespace-only string as unset, uses fallback", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: " ",
|
||||
inheritedModel: "",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result).toBe("google/gemini-3-pro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purity", () => {
|
||||
test("same input returns same output (referential transparency)", () => {
|
||||
// #given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
};
|
||||
|
||||
// #when
|
||||
const result1 = resolveModel(input);
|
||||
const result2 = resolveModel(input);
|
||||
|
||||
// #then
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Input for model resolution.
|
||||
* All model strings are optional except systemDefault which is the terminal fallback.
|
||||
*/
|
||||
export type ModelResolutionInput = {
|
||||
/** Model from user category config */
|
||||
userModel?: string;
|
||||
/** Model inherited from parent task/session */
|
||||
inheritedModel?: string;
|
||||
/** System default model from OpenCode config - always required */
|
||||
systemDefault: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a model string.
|
||||
* Trims whitespace and treats empty/whitespace-only as undefined.
|
||||
*/
|
||||
function normalizeModel(model?: string): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective model using priority chain:
|
||||
* userModel → inheritedModel → systemDefault
|
||||
*
|
||||
* Empty strings and whitespace-only strings are treated as unset.
|
||||
*/
|
||||
export function resolveModel(input: ModelResolutionInput): string {
|
||||
return (
|
||||
normalizeModel(input.userModel) ??
|
||||
normalizeModel(input.inheritedModel) ??
|
||||
input.systemDefault
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user