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:
roboomp
2026-07-22 10:52:57 +00:00
parent 7b141199d5
commit 093f7c2660
4 changed files with 98 additions and 11 deletions
+4
View File
@@ -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,
+33 -10
View File
@@ -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");