fix(#2731): skip unauthenticated providers when resolving subagent model
Background subagents (explore/librarian) failed with auth errors because resolveModelForDelegateTask() always picked the first fallback entry when availableModels was empty — often an unauthenticated provider like xai or opencode-go. Fix: when connectedProvidersCache is populated, iterate fallback chain and pick the first entry whose provider is in the connected set. Legacy behavior preserved when cache is null (not yet populated). - model-selection.ts: use readConnectedProvidersCache to filter fallback chain - test: 4 new tests for connected-provider-aware resolution
This commit is contained in:
@@ -135,6 +135,89 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given provider cache exists and connected providers are known", () => {
|
||||
let readConnectedProvidersSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
|
||||
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
readConnectedProvidersSpy?.mockRestore()
|
||||
})
|
||||
|
||||
describe("#when availableModels is empty and fallback chain starts with unauthenticated provider", () => {
|
||||
test("#then skips unauthenticated providers and resolves to first connected one", () => {
|
||||
readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "anthropic"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
fallbackChain: [
|
||||
{ providers: ["xai"], model: "grok-code-fast-1" },
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
|
||||
{ providers: ["opencode"], model: "gpt-5-nano" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).not.toHaveProperty("skipped")
|
||||
const resolved = result as { model: string; variant?: string }
|
||||
expect(resolved.model).toBe("anthropic/claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("#then resolves first provider in entry that is connected", () => {
|
||||
readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "github-copilot"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
fallbackChain: [
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
{ providers: ["openai", "github-copilot"], model: "gpt-5.4", variant: "high" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
const resolved = result as { model: string; variant?: string }
|
||||
expect(resolved.model).toBe("openai/gpt-5.4")
|
||||
expect(resolved.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("#then falls through to system default when no provider in chain is connected", () => {
|
||||
readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
fallbackChain: [
|
||||
{ providers: ["xai"], model: "grok-code-fast-1" },
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when connected providers cache is null (not yet populated)", () => {
|
||||
test("#then falls back to first entry in chain (legacy behavior)", () => {
|
||||
readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
fallbackChain: [
|
||||
{ providers: ["xai"], model: "grok-code-fast-1" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
const resolved = result as { model: string }
|
||||
expect(resolved.model).toBe("xai/grok-code-fast-1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only connected providers cache exists (no provider-models cache)", () => {
|
||||
beforeEach(() => {
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { normalizeModel } from "../../shared/model-normalization"
|
||||
import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
import { hasConnectedProvidersCache, hasProviderModelsCache } from "../../shared/connected-providers-cache"
|
||||
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
||||
import { log } from "../../shared/logger"
|
||||
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
|
||||
|
||||
@@ -115,12 +115,30 @@ export function resolveModelForDelegateTask(input: {
|
||||
const fallbackChain = input.fallbackChain
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (input.availableModels.size === 0) {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const transformedModelId = transformModelForProvider(provider, entry.model)
|
||||
log("[resolveModelForDelegateTask] fallback chain resolved via connected provider", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
})
|
||||
return { model: `${provider}/${transformedModelId}`, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("[resolveModelForDelegateTask] no connected provider found in fallback chain")
|
||||
} else {
|
||||
const first = fallbackChain[0]
|
||||
const provider = first?.providers?.[0]
|
||||
if (provider) {
|
||||
const transformedModelId = transformModelForProvider(provider, first.model)
|
||||
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first, matchedFallback: true }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
|
||||
Reference in New Issue
Block a user