6943b6d4d8
model-resolver.test.ts and prometheus-agent-config-builder.test.ts use
spyOn(shared, 'log') but do not own the logger module. When other test
files in the same bun test process call mock.module('../shared/logger'),
the import cache is poisoned and the spyOn targets a stale binding.
Add a lightweight mock.module call at the top of each file so the
auto-detection in run-ci-tests.ts picks them up as isolated targets.
This ensures each file gets its own module instance and the spy
captures all calls correctly.
Fixes the flaky CI failure pattern where resolveModelWithFallback and
buildPrometheusAgentConfig tests pass locally (separate bun process)
but fail in the shared CI batch.
953 lines
33 KiB
TypeScript
953 lines
33 KiB
TypeScript
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
|
|
|
|
// Isolate from other tests that mock.module the logger (CI cross-contamination fix)
|
|
mock.module("./logger", () => ({ log: (..._args: unknown[]) => {} }))
|
|
|
|
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
|
|
import * as logger from "./logger"
|
|
import * as connectedProvidersCache from "./connected-providers-cache"
|
|
|
|
describe("resolveModel", () => {
|
|
describe("priority chain", () => {
|
|
test("returns userModel when all three are set", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
inheritedModel: "openai/gpt-5.4",
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModel(input)
|
|
|
|
// then
|
|
expect(result).toBe("anthropic/claude-opus-4-6")
|
|
})
|
|
|
|
test("returns inheritedModel when userModel is undefined", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: undefined,
|
|
inheritedModel: "openai/gpt-5.4",
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModel(input)
|
|
|
|
// then
|
|
expect(result).toBe("openai/gpt-5.4")
|
|
})
|
|
|
|
test("returns systemDefault when both userModel and inheritedModel are undefined", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: undefined,
|
|
inheritedModel: undefined,
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModel(input)
|
|
|
|
// then
|
|
expect(result).toBe("google/gemini-3.1-pro")
|
|
})
|
|
})
|
|
|
|
describe("empty string handling", () => {
|
|
test("treats empty string as unset, uses fallback", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: "",
|
|
inheritedModel: "openai/gpt-5.4",
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModel(input)
|
|
|
|
// then
|
|
expect(result).toBe("openai/gpt-5.4")
|
|
})
|
|
|
|
test("treats whitespace-only string as unset, uses fallback", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: " ",
|
|
inheritedModel: "",
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModel(input)
|
|
|
|
// then
|
|
expect(result).toBe("google/gemini-3.1-pro")
|
|
})
|
|
})
|
|
|
|
describe("purity", () => {
|
|
test("same input returns same output (referential transparency)", () => {
|
|
// given
|
|
const input: ModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
inheritedModel: "openai/gpt-5.4",
|
|
systemDefault: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result1 = resolveModel(input)
|
|
const result2 = resolveModel(input)
|
|
|
|
// then
|
|
expect(result1).toBe(result2)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("resolveModelWithFallback", () => {
|
|
let logSpy: ReturnType<typeof spyOn>
|
|
|
|
beforeEach(() => {
|
|
logSpy = spyOn(logger, "log")
|
|
})
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
describe("Step 1: UI Selection (highest priority)", () => {
|
|
test("returns uiSelectedModel with override source when provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
uiSelectedModel: "opencode/big-pickle",
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("opencode/big-pickle")
|
|
expect(result!.source).toBe("override")
|
|
expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/big-pickle" })
|
|
})
|
|
|
|
test("UI selection takes priority over config override", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
uiSelectedModel: "opencode/big-pickle",
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("opencode/big-pickle")
|
|
expect(result!.source).toBe("override")
|
|
})
|
|
|
|
test("whitespace-only uiSelectedModel is treated as not provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
uiSelectedModel: " ",
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
|
})
|
|
|
|
test("empty string uiSelectedModel falls through to config override", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
uiSelectedModel: "",
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
})
|
|
})
|
|
|
|
describe("Step 2: Config Override", () => {
|
|
test("returns userModel with override source when userModel is provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("override")
|
|
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
|
})
|
|
|
|
test("override takes priority even if model not in availableModels", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "custom/my-model",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("custom/my-model")
|
|
expect(result!.source).toBe("override")
|
|
})
|
|
|
|
test("whitespace-only userModel is treated as not provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: " ",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.source).not.toBe("override")
|
|
})
|
|
|
|
test("empty string userModel is treated as not provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.source).not.toBe("override")
|
|
})
|
|
})
|
|
|
|
describe("Step 3: Provider fallback chain", () => {
|
|
test("tries providers in order within entry and returns first match", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("github-copilot/claude-opus-4-6-preview")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
|
|
provider: "github-copilot",
|
|
model: "claude-opus-4-6",
|
|
match: "github-copilot/claude-opus-4-6-preview",
|
|
variant: undefined,
|
|
})
|
|
})
|
|
|
|
test("respects provider priority order within entry", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.4" },
|
|
],
|
|
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-6", "google/gemini-3.1-pro"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("openai/gpt-5.4")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("tries next provider when first provider has no match", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "opencode"], model: "gpt-5-nano" },
|
|
],
|
|
availableModels: new Set(["opencode/gpt-5-nano"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("opencode/gpt-5-nano")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("uses fuzzy matching within provider", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("skips fallback chain when not provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.source).toBe("system-default")
|
|
})
|
|
|
|
test("skips fallback chain when empty", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.source).toBe("system-default")
|
|
})
|
|
|
|
test("case-insensitive fuzzy matching", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("cross-provider fuzzy match when preferred provider unavailable (librarian scenario)", () => {
|
|
// given - glm-5 is defined for zai-coding-plan, but only opencode has it
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
],
|
|
availableModels: new Set(["opencode/glm-5", "anthropic/claude-sonnet-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should find glm-5 from opencode via cross-provider fuzzy match
|
|
expect(result!.model).toBe("opencode/glm-5")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
|
model: "glm-5",
|
|
match: "opencode/glm-5",
|
|
variant: undefined,
|
|
})
|
|
})
|
|
|
|
test("prefers specified provider over cross-provider match", () => {
|
|
// given - both zai-coding-plan and opencode have glm-5
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["zai-coding-plan"], model: "glm-5" },
|
|
],
|
|
availableModels: new Set(["zai-coding-plan/glm-5", "opencode/glm-5"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should prefer zai-coding-plan (specified provider) over opencode
|
|
expect(result!.model).toBe("zai-coding-plan/glm-5")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("cross-provider match preserves variant from entry", () => {
|
|
// given - entry has variant, model found via cross-provider
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["zai-coding-plan"], model: "glm-5", variant: "high" },
|
|
],
|
|
availableModels: new Set(["opencode/glm-5"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - variant should be preserved
|
|
expect(result!.model).toBe("opencode/glm-5")
|
|
expect(result!.variant).toBe("high")
|
|
})
|
|
|
|
test("cross-provider match tries next entry if no match found anywhere", () => {
|
|
// given - first entry model not available anywhere, second entry available
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["zai-coding-plan"], model: "nonexistent-model" },
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should fall through to second entry
|
|
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
})
|
|
|
|
describe("Step 4: System default fallback (no availability match)", () => {
|
|
test("returns system default when no availability match found in fallback chain", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "nonexistent-model" },
|
|
],
|
|
availableModels: new Set(["openai/gpt-5.4", "anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.source).toBe("system-default")
|
|
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
|
})
|
|
|
|
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
|
|
// given - both model cache and connected-providers cache are missing (first run)
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: undefined, // no system default configured
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should return undefined to let OpenCode use Provider.defaultModel()
|
|
expect(result).toBeUndefined()
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("uses connected provider from fallback when availableModels empty but cache exists", () => {
|
|
// given - model cache missing but connected-providers cache exists
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should use connected provider (openai) from fallback chain
|
|
expect(result!.model).toBe("openai/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("uses github-copilot when google not connected (visual-engineering scenario)", () => {
|
|
// given - user has github-copilot but not google connected
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should use github-copilot (second provider) since google not connected
|
|
// model name is transformed to preview variant for github-copilot provider
|
|
expect(result!.model).toBe("github-copilot/gemini-3.1-pro-preview")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("falls through to system default when no provider in fallback is connected", () => {
|
|
// given - user only has anthropic connected, but fallback chain has openai/opencode
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["openai", "opencode"], model: "claude-haiku-4-5" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-opus-4-6-20251101",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - no provider in fallback is connected, fall through to system default
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6-20251101")
|
|
expect(result!.source).toBe("system-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
|
|
// given - no cache but system default is configured
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should fall through to system default
|
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.source).toBe("system-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("returns system default when fallbackChain is not provided", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
availableModels: new Set(["openai/gpt-5.4"]),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.source).toBe("system-default")
|
|
})
|
|
})
|
|
|
|
describe("Multi-entry fallbackChain", () => {
|
|
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
|
|
// given
|
|
const availableModels = new Set(["anthropic/claude-opus-4-6"])
|
|
|
|
// when
|
|
const result = resolveModelWithFallback({
|
|
fallbackChain: [
|
|
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "high" },
|
|
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
|
],
|
|
availableModels,
|
|
systemDefaultModel: "system/default",
|
|
})
|
|
|
|
// then
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("tries all providers in first entry before moving to second entry", () => {
|
|
// given
|
|
const availableModels = new Set(["google/gemini-3.1-pro"])
|
|
|
|
// when
|
|
const result = resolveModelWithFallback({
|
|
fallbackChain: [
|
|
{ providers: ["openai", "anthropic"], model: "gpt-5.4" },
|
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels,
|
|
systemDefaultModel: "system/default",
|
|
})
|
|
|
|
// then
|
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("returns first matching entry even if later entries have better matches", () => {
|
|
// given
|
|
const availableModels = new Set([
|
|
"openai/gpt-5.4",
|
|
"anthropic/claude-opus-4-6",
|
|
])
|
|
|
|
// when
|
|
const result = resolveModelWithFallback({
|
|
fallbackChain: [
|
|
{ providers: ["openai"], model: "gpt-5.4" },
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels,
|
|
systemDefaultModel: "system/default",
|
|
})
|
|
|
|
// then
|
|
expect(result!.model).toBe("openai/gpt-5.4")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("falls through to system default when none match availability", () => {
|
|
// given
|
|
const availableModels = new Set(["other/model"])
|
|
|
|
// when
|
|
const result = resolveModelWithFallback({
|
|
fallbackChain: [
|
|
{ providers: ["openai"], model: "gpt-5.4" },
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels,
|
|
systemDefaultModel: "system/default",
|
|
})
|
|
|
|
// then
|
|
expect(result!.model).toBe("system/default")
|
|
expect(result!.source).toBe("system-default")
|
|
})
|
|
})
|
|
|
|
describe("Type safety", () => {
|
|
test("result has correct ModelResolutionResult shape", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "google/gemini-3.1-pro",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result).toBeDefined()
|
|
expect(typeof result!.model).toBe("string")
|
|
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
|
|
})
|
|
})
|
|
|
|
describe("categoryDefaultModel (fuzzy matching for category defaults)", () => {
|
|
test("applies fuzzy matching to categoryDefaultModel when userModel not provided", () => {
|
|
// given - gemini-3.1-pro is the category default, but only gemini-3.1-pro-preview is available
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
|
fallbackChain: [
|
|
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should fuzzy match gemini-3.1-pro → gemini-3.1-pro-preview
|
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
|
expect(result!.source).toBe("category-default")
|
|
})
|
|
|
|
test("categoryDefaultModel uses exact match when available", () => {
|
|
// given - exact match exists
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
|
fallbackChain: [
|
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels: new Set(["google/gemini-3.1-pro", "google/gemini-3.1-pro-preview"]),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should use exact match
|
|
expect(result!.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.source).toBe("category-default")
|
|
})
|
|
|
|
test("categoryDefaultModel falls through to fallbackChain when no match in availableModels", () => {
|
|
// given - categoryDefaultModel has no match, but fallbackChain does
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "system/default",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should fall through to fallbackChain
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
|
|
test("userModel takes priority over categoryDefaultModel", () => {
|
|
// given - both userModel and categoryDefaultModel provided
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
|
fallbackChain: [
|
|
{ providers: ["google"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels: new Set(["google/gemini-3.1-pro-preview", "anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: "system/default",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - userModel wins
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("override")
|
|
})
|
|
|
|
test("categoryDefaultModel works when availableModels is empty but connected provider exists", () => {
|
|
// given - no availableModels but connected provider cache exists
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3.1-pro",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should use transformed categoryDefaultModel since google is connected
|
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
|
expect(result!.source).toBe("category-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("transforms gemini-3-flash in categoryDefaultModel for google connected provider", () => {
|
|
// given - google connected, category default uses gemini-3-flash
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3-flash",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - gemini-3-flash should be transformed to gemini-3-flash-preview
|
|
expect(result!.model).toBe("google/gemini-3-flash-preview")
|
|
expect(result!.source).toBe("category-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("does not double-transform categoryDefaultModel already containing -preview", () => {
|
|
// given - category default already has -preview suffix
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-3.1-pro-preview",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should NOT become gemini-3.1-pro-preview-preview
|
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
|
expect(result!.source).toBe("category-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("transforms gemini-3.1-pro in fallback chain for google connected provider", () => {
|
|
// given - google connected, fallback chain has gemini-3.1-pro
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["google", "github-copilot"], model: "gemini-3.1-pro" },
|
|
],
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should transform to preview variant for google provider
|
|
expect(result!.model).toBe("google/gemini-3.1-pro-preview")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
|
|
test("passes through non-gemini-3 models for google connected provider", () => {
|
|
// given - google connected, category default uses gemini-2.5-flash (no transform needed)
|
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["google"])
|
|
const input: ExtendedModelResolutionInput = {
|
|
categoryDefaultModel: "google/gemini-2.5-flash",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then - should pass through unchanged
|
|
expect(result!.model).toBe("google/gemini-2.5-flash")
|
|
expect(result!.source).toBe("category-default")
|
|
cacheSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe("Optional systemDefaultModel", () => {
|
|
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "nonexistent-model" },
|
|
],
|
|
availableModels: new Set(["openai/gpt-5.4"]),
|
|
systemDefaultModel: undefined,
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
availableModels: new Set(["openai/gpt-5.4"]),
|
|
systemDefaultModel: undefined,
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
userModel: "anthropic/claude-opus-4-6",
|
|
availableModels: new Set(),
|
|
systemDefaultModel: undefined,
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result).toBeDefined()
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("override")
|
|
})
|
|
|
|
test("still returns fallback match when systemDefaultModel undefined", () => {
|
|
// given
|
|
const input: ExtendedModelResolutionInput = {
|
|
fallbackChain: [
|
|
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
|
],
|
|
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
|
systemDefaultModel: undefined,
|
|
}
|
|
|
|
// when
|
|
const result = resolveModelWithFallback(input)
|
|
|
|
// then
|
|
expect(result).toBeDefined()
|
|
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(result!.source).toBe("provider-fallback")
|
|
})
|
|
})
|
|
})
|