fix(sdk): resolved missing role fallback chains
- Carried configured role identity through deferred CLI model resolution. - Consulted ordered authenticated role fallbacks after unavailable primaries. - Added startup regression coverage for missing primary and fallback entries. Fixes #6283
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `--model <role>` skipping the role's ordered `retry.fallbackChains` when its configured primary is unavailable, so startup now selects the first available authenticated fallback instead of leaving the role unresolved ([#6283](https://github.com/can1357/oh-my-pi/issues/6283)).
|
||||
|
||||
## [17.0.7] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1601,8 +1601,10 @@ export function filterAvailableModelsByEnabledPatterns(
|
||||
|
||||
export interface ResolveCliModelResult {
|
||||
model: Model<Api> | undefined;
|
||||
/** configuredPatterns is the full configured fallback chain when the selector resolves through a role. */
|
||||
/** configuredPatterns contains the role's ordered primary candidates. */
|
||||
configuredPatterns?: string[];
|
||||
/** configuredRole identifies the role expanded into configuredPatterns. */
|
||||
configuredRole?: string;
|
||||
/** configuredPatternIndex identifies the configured role pattern that matched an available model. */
|
||||
configuredPatternIndex?: number;
|
||||
selector?: string;
|
||||
@@ -1718,6 +1720,7 @@ export function resolveCliModel(options: {
|
||||
? `${formatModelRoleAlias(bareRoleName)}${bareRoleThinkingLevel ? `:${bareRoleThinkingLevel}` : ""}`
|
||||
: undefined;
|
||||
if (roleSelector) {
|
||||
const configuredRole = getModelRoleAlias(roleSelector, settings);
|
||||
configuredPatterns = resolveConfiguredModelPatterns([roleSelector], settings);
|
||||
const resolved = resolveModelRoleValue(roleSelector, availableModels, {
|
||||
settings,
|
||||
@@ -1727,6 +1730,7 @@ export function resolveCliModel(options: {
|
||||
return {
|
||||
model: resolved.model,
|
||||
selector: formatModelString(resolved.model),
|
||||
configuredRole,
|
||||
configuredPatterns,
|
||||
configuredPatternIndex: resolved.matchedPatternIndex,
|
||||
thinkingLevel: resolved.thinkingLevel,
|
||||
@@ -1738,6 +1742,7 @@ export function resolveCliModel(options: {
|
||||
return {
|
||||
model: undefined,
|
||||
configuredPatterns,
|
||||
configuredRole,
|
||||
selector: undefined,
|
||||
thinkingLevel: undefined,
|
||||
warning: resolved.warning,
|
||||
|
||||
@@ -2085,23 +2085,46 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
preferences: matchPreferences,
|
||||
});
|
||||
if (resolved.configuredPatterns && resolved.configuredPatterns.length > 0) {
|
||||
return resolved.configuredPatterns;
|
||||
const primaryPatterns = resolved.configuredPatterns.map(pattern => ({
|
||||
pattern,
|
||||
retryFallback: false,
|
||||
}));
|
||||
if (!resolved.configuredRole || !settings.get("retry.modelFallback")) {
|
||||
return primaryPatterns;
|
||||
}
|
||||
const fallbackChains = settings.get("retry.fallbackChains");
|
||||
const roleFallbacks =
|
||||
fallbackChains[resolved.configuredRole] ??
|
||||
(resolved.configuredRole === "default" ? undefined : fallbackChains.default);
|
||||
if (!Array.isArray(roleFallbacks)) return primaryPatterns;
|
||||
return [
|
||||
...primaryPatterns,
|
||||
...roleFallbacks
|
||||
.filter((pattern): pattern is string => typeof pattern === "string")
|
||||
.map(pattern => ({ pattern, retryFallback: true })),
|
||||
];
|
||||
}
|
||||
if (resolved.model) {
|
||||
return [
|
||||
formatModelSelectorValue(
|
||||
resolved.selector ?? formatModelStringWithRouting(resolved.model),
|
||||
resolved.thinkingLevel,
|
||||
),
|
||||
{
|
||||
pattern: formatModelSelectorValue(
|
||||
resolved.selector ?? formatModelStringWithRouting(resolved.model),
|
||||
resolved.thinkingLevel,
|
||||
),
|
||||
retryFallback: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return resolveConfiguredModelPatterns([trimmedSelector], settings);
|
||||
return resolveConfiguredModelPatterns([trimmedSelector], settings).map(pattern => ({
|
||||
pattern,
|
||||
retryFallback: false,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (let patternIndex = 0; patternIndex < expandedModelPatterns.length; patternIndex += 1) {
|
||||
const pattern = expandedModelPatterns[patternIndex];
|
||||
const { pattern, retryFallback } = expandedModelPatterns[patternIndex];
|
||||
const primary = parseModelPattern(pattern, availableModels, matchPreferences);
|
||||
if (!primary.model) continue;
|
||||
if (!primary.model || (retryFallback && !hasModelAuth(primary.model))) continue;
|
||||
let selectedModel = primary.model;
|
||||
let selectedThinkingLevel = primary.thinkingLevel;
|
||||
let selectedExplicitThinkingLevel = primary.explicitThinkingLevel;
|
||||
@@ -2132,8 +2155,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
);
|
||||
const seenSelectors = new Set<string>([primarySelector]);
|
||||
const fallbackSelectors: string[] = [];
|
||||
for (const fallbackPattern of expandedModelPatterns.slice(patternIndex + 1)) {
|
||||
const fallback = parseModelPattern(fallbackPattern, availableModels, matchPreferences);
|
||||
for (const fallbackEntry of expandedModelPatterns.slice(patternIndex + 1)) {
|
||||
const fallback = parseModelPattern(fallbackEntry.pattern, availableModels, matchPreferences);
|
||||
if (!fallback.model) continue;
|
||||
const fallbackSelector = formatModelSelectorValue(
|
||||
formatModelStringWithRouting(fallback.model),
|
||||
|
||||
@@ -352,6 +352,61 @@ describe("createAgentSession deferred model pattern resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a configured role fallback when its primary model is unavailable", async () => {
|
||||
const settings = Settings.isolated({
|
||||
"retry.fallbackChains": {
|
||||
slow: ["missing-provider/missing-fallback", "runtime-provider/runtime-model"],
|
||||
},
|
||||
});
|
||||
settings.setModelRole("slow", "missing-provider/missing-model");
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "missing-role-auth.db"));
|
||||
authStorage.setRuntimeApiKey("runtime-provider", "test-key");
|
||||
authStoragesToClose.push(authStorage);
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "missing-role-models.yml"));
|
||||
const parsed = parseArgs(["--model", "slow"]);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
|
||||
throw new Error(`buildSessionOptions unexpectedly exited with ${code}`);
|
||||
});
|
||||
try {
|
||||
const cliOptions = await buildCliSessionOptions(
|
||||
parsed,
|
||||
[],
|
||||
SessionManager.inMemory(),
|
||||
modelRegistry,
|
||||
settings,
|
||||
);
|
||||
expect(cliOptions.modelPattern).toBe("slow");
|
||||
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
...cliOptions,
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings,
|
||||
disableExtensionDiscovery: true,
|
||||
extensions: [providerExtension],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
skipPythonPreflight: true,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.model?.provider).toBe("runtime-provider");
|
||||
expect(session.model?.id).toBe("runtime-model");
|
||||
expect(modelFallbackMessage).toBeUndefined();
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves deferred bare role fallback chains", async () => {
|
||||
const settings = Settings.isolated();
|
||||
settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model");
|
||||
|
||||
Reference in New Issue
Block a user