fix(FN-2662): honor project model overrides and stabilize tests

This commit is contained in:
gsxdsm
2026-04-28 18:21:17 -07:00
parent 964c55da77
commit 2029968d23
31 changed files with 980 additions and 361 deletions

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "../model-resolution.js";
describe("model-resolution", () => {
it("prefers the project default override over the global default", () => {
expect(
resolveProjectDefaultModel({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
).toEqual({ provider: "openai", modelId: "gpt-4o" });
});
it("uses the execution lane before the project default override", () => {
expect(
resolveExecutionSettingsModel({
executionProvider: "google",
executionModelId: "gemini-2.5-pro",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
}),
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
});
it("falls back from planning global to the project default override", () => {
expect(
resolvePlanningSettingsModel({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
}),
).toEqual({ provider: "openai", modelId: "gpt-4o-mini" });
});
it("falls back from validator global to the project default override", () => {
expect(
resolveValidatorSettingsModel({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-opus-4",
}),
).toEqual({ provider: "anthropic", modelId: "claude-opus-4" });
});
it("uses title summarizer global, then project planning, then project default override", () => {
expect(
resolveTitleSummarizerSettingsModel({
titleSummarizerGlobalProvider: "openai",
titleSummarizerGlobalModelId: "gpt-4.1",
planningProvider: "google",
planningModelId: "gemini-2.5-pro",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
expect(
resolveTitleSummarizerSettingsModel({
planningProvider: "google",
planningModelId: "gemini-2.5-pro",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
expect(
resolveTitleSummarizerSettingsModel({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
});
it("uses task overrides before settings fallbacks", () => {
expect(
resolveTaskExecutionModel(
{
modelProvider: "openai",
modelId: "gpt-4o",
},
{
executionProvider: "anthropic",
executionModelId: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "openai", modelId: "gpt-4o" });
expect(
resolveTaskValidatorModel(
{},
{
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(
resolveTaskPlanningModel(
{},
{
planningGlobalProvider: "openai",
planningGlobalModelId: "gpt-4.1",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
});
});

View File

@@ -196,6 +196,17 @@ export {
AiServiceError,
__resetSummarizeState,
} from "./ai-summarize.js";
export {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";
// ── Memory Compaction ─────────────────────────────────────────────────

View File

@@ -0,0 +1,148 @@
import type { Settings } from "./types.js";
export interface ResolvedModelSelection {
provider?: string;
modelId?: string;
}
type ModelPair =
| ResolvedModelSelection
| {
provider?: string | null;
modelId?: string | null;
}
| undefined;
type TaskModelLike = {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
};
function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string } {
return Boolean(pair?.provider && pair?.modelId);
}
function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
for (const pair of pairs) {
if (hasCompleteModelPair(pair)) {
return { provider: pair.provider, modelId: pair.modelId };
}
}
return {};
}
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.defaultProviderOverride,
modelId: settings?.defaultModelIdOverride,
},
{
provider: settings?.defaultProvider,
modelId: settings?.defaultModelId,
},
);
}
export function resolveExecutionSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.executionProvider,
modelId: settings?.executionModelId,
},
{
provider: settings?.executionGlobalProvider,
modelId: settings?.executionGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolvePlanningSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
{
provider: settings?.planningGlobalProvider,
modelId: settings?.planningGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveValidatorSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.validatorProvider,
modelId: settings?.validatorModelId,
},
{
provider: settings?.validatorGlobalProvider,
modelId: settings?.validatorGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.titleSummarizerProvider,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
modelId: settings?.titleSummarizerGlobalModelId,
},
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.modelProvider,
modelId: task.modelId,
},
resolveExecutionSettingsModel(settings),
);
}
export function resolveTaskValidatorModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.validatorModelProvider,
modelId: task.validatorModelId,
},
resolveValidatorSettingsModel(settings),
);
}
export function resolveTaskPlanningModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.planningModelProvider,
modelId: task.planningModelId,
},
resolvePlanningSettingsModel(settings),
);
}

View File

@@ -3376,3 +3376,14 @@ export { PROMPT_KEY_CATALOG } from "./prompt-overrides.js";
// Re-exported here so the dashboard's `@fusion/core` → types.ts alias resolves
// client-side consumers (see packages/dashboard/vite.config.ts).
export { getErrorMessage } from "./error-message.js";
export {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";