From 9375a5495464c16fd9c3d03176add0a1dfca5172 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sat, 22 Aug 2026 15:59:44 -0400 Subject: [PATCH] Fall back to another provider when the default's model catalog fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread creation picked the first available provider (Codex, by bundled install order) as the product default without checking whether it had a usable model catalog. On a host where Codex is registered but unconfigured, this hard-failed thread creation with a 503 even when Claude Code or another provider was fully working. Now the default-provider path tries each available provider in order until one resolves a model, and only when no provider was explicitly requested or remembered for the project — an explicit choice still hard-fails rather than silently substituting a different provider. Fixes #2306 --- .../threads/project-execution-defaults.ts | 5 +- .../src/services/threads/thread-create.ts | 73 +++++++++++++++---- .../services/threads/thread-default-policy.ts | 61 +++++++++++----- .../public/public-threads.defaults.test.ts | 66 ++++++++++++++++- .../threads/thread-default-policy.test.ts | 66 ++++++++++++++++- 5 files changed, 234 insertions(+), 37 deletions(-) diff --git a/apps/server/src/services/threads/project-execution-defaults.ts b/apps/server/src/services/threads/project-execution-defaults.ts index d2975e8690..ae096f2d55 100644 --- a/apps/server/src/services/threads/project-execution-defaults.ts +++ b/apps/server/src/services/threads/project-execution-defaults.ts @@ -28,6 +28,7 @@ interface ResolveProjectExecutionDefaultsForCreateArgs { interface ResolvedProjectExecutionDefaultsForCreate { executionDefaults: ProjectExecutionDefaults | null; providerId: string; + providerFallbackCandidates: readonly string[]; requestedModel: string | null; } @@ -95,11 +96,13 @@ export function resolveProjectExecutionDefaultsForCreate( storedDefaults, }, ); - const { executionDefaults, providerId } = resolution; + const { executionDefaults, providerId, providerFallbackCandidates } = + resolution; return { executionDefaults, providerId, + providerFallbackCandidates, requestedModel: requestedModel ?? null, }; } diff --git a/apps/server/src/services/threads/thread-create.ts b/apps/server/src/services/threads/thread-create.ts index c84ad54a5f..ba1c04edab 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -108,24 +108,27 @@ interface ResolveCatalogExecutionDefaultsArgs { executionDefaults: ProjectExecutionDefaults | null; hostId: string; providerId: string; + /** + * Other available providers to try, in order, if `providerId`'s catalog is + * unusable. Only non-empty when `providerId` itself came from the product + * default rather than an explicit request or a stored project default — an + * explicit choice never silently falls through to a different provider. + */ + providerFallbackCandidates?: readonly string[]; requestedModel: string | null; } -async function resolveCatalogExecutionDefaults( +async function loadCatalogDefaultForProvider( deps: ThreadCreateDeps, - args: ResolveCatalogExecutionDefaultsArgs, -): Promise { - if (args.executionDefaults !== null || args.requestedModel !== null) { - return args.executionDefaults; - } - + args: { cwd?: string; hostId: string; providerId: string }, +): Promise { const catalog = await resolveSystemProviderModels(deps, { ...(args.cwd !== undefined ? { cwd: args.cwd } : {}), hostId: args.hostId, providerId: args.providerId, }); if (catalog.modelLoadError !== null) { - throw new ApiError( + return new ApiError( 503, "model_catalog_unavailable", `Unable to load ${args.providerId} models to resolve the default. Try again once the host is connected and the provider is ready.`, @@ -138,7 +141,7 @@ async function resolveCatalogExecutionDefaults( const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0]; if (defaultModel === undefined) { - throw new ApiError( + return new ApiError( 503, "model_catalog_unavailable", `The ${args.providerId} model catalog is empty, so no default model can be resolved.`, @@ -151,6 +154,30 @@ async function resolveCatalogExecutionDefaults( }); } +async function resolveCatalogExecutionDefaults( + deps: ThreadCreateDeps, + args: ResolveCatalogExecutionDefaultsArgs, +): Promise { + if (args.executionDefaults !== null || args.requestedModel !== null) { + return args.executionDefaults; + } + + const candidates = [args.providerId, ...(args.providerFallbackCandidates ?? [])]; + let lastError: ApiError | null = null; + for (const providerId of candidates) { + const result = await loadCatalogDefaultForProvider(deps, { + ...(args.cwd !== undefined ? { cwd: args.cwd } : {}), + hostId: args.hostId, + providerId, + }); + if (!(result instanceof ApiError)) { + return result; + } + lastError = result; + } + throw lastError; +} + /** * Resolve the native-fork point for a source-derived thread, or null when it * cannot be provisioned as a fork. Both forks and side chats are native forks: @@ -705,13 +732,17 @@ export async function createThreadFromRequest( // for: without this, a thread created on boot sees an empty registry and // fails with "no provider available". await deps.providerRegistry.whenRegistrationsSettled(); - const { executionDefaults, providerId, requestedModel } = - resolveProjectExecutionDefaultsForCreate(deps, { - executionInputSources: requestInput.executionInputSources, - model: requestInput.model, - projectId: requestInput.projectId, - providerId: requestInput.providerId, - }); + const { + executionDefaults, + providerId, + providerFallbackCandidates, + requestedModel, + } = resolveProjectExecutionDefaultsForCreate(deps, { + executionInputSources: requestInput.executionInputSources, + model: requestInput.model, + projectId: requestInput.projectId, + providerId: requestInput.providerId, + }); const { originKind: _requestedOriginKind, parentThreadId: _requestedParentThreadId, @@ -761,9 +792,19 @@ export async function createThreadFromRequest( executionDefaults, hostId: childHostId, providerId, + providerFallbackCandidates, requestedModel, }, ); + // A fallback candidate's catalog can win over the product default's own + // (empty or failing) catalog; the thread must record whichever provider + // actually resolved a model rather than the one first guessed above. + if ( + resolvedExecutionDefaults !== null && + resolvedExecutionDefaults.providerId !== request.providerId + ) { + request.providerId = resolvedExecutionDefaults.providerId; + } let environmentId: string | null = null; let environmentIntent: ThreadProvisionEnvironmentIntent; diff --git a/apps/server/src/services/threads/thread-default-policy.ts b/apps/server/src/services/threads/thread-default-policy.ts index 9f18afcedc..0e57188617 100644 --- a/apps/server/src/services/threads/thread-default-policy.ts +++ b/apps/server/src/services/threads/thread-default-policy.ts @@ -24,25 +24,30 @@ export const DEFAULT_REASONING_LEVEL: ReasoningLevel = "medium"; const DEFAULT_PERMISSION_MODE: PermissionMode = "auto"; /** - * The default provider, used when neither the caller nor the project has - * chosen one: the user's `defaultProviderId` setting when it names an - * available provider, else the first available entry of the registry listing - * (the user's `providerOrder`, then plugin install order). Providers come only - * from plugin declarations, so an install with every provider plugin disabled - * has no default at all. + * Ordered candidates for the default provider, used when neither the caller + * nor the project has chosen one: the user's `defaultProviderId` setting + * leads when it names an available provider, then every other available + * entry of the registry listing (the user's `providerOrder`, then plugin + * install order). Exposed as a list (not just a single id) so a caller whose + * chosen candidate turns out to have no usable model catalog can retry the + * next one instead of failing outright. */ -function requireDefaultProviderId(registry: ProviderRegistryService): string { - const listed = registry.list(); +function listDefaultProviderIdCandidates( + registry: ProviderRegistryService, +): string[] { + const available = registry + .list() + .filter((registration) => registration.info.available) + .map((registration) => registration.info.id); const preferred = registry.getUserDefaultProviderId(); - const providerId = - (preferred !== null - ? listed.find( - (registration) => - registration.info.id === preferred && registration.info.available, - ) - : undefined - )?.info.id ?? - listed.find((registration) => registration.info.available)?.info.id; + if (preferred !== null && available.includes(preferred)) { + return [preferred, ...available.filter((id) => id !== preferred)]; + } + return available; +} + +function requireDefaultProviderId(registry: ProviderRegistryService): string { + const providerId = listDefaultProviderIdCandidates(registry)[0]; if (providerId === undefined) { // Reachable for real now that providers are plugin-only: disabling every // provider plugin leaves nothing to start a thread with. Say so, instead @@ -64,6 +69,14 @@ interface ResolveCreateThreadExecutionDefaultsArgs { interface CreateThreadExecutionDefaultsResolved { executionDefaults: ProjectExecutionDefaults | null; providerId: string; + /** + * Other available providers to try, in order, if `providerId`'s model + * catalog turns out to be unusable (empty or failing to load). Populated + * only when `providerId` itself came from the product default (no explicit + * request and no stored project default) — an explicit choice is never + * silently overridden. + */ + providerFallbackCandidates: readonly string[]; } interface IsManagedChildThreadArgs { @@ -172,6 +185,12 @@ export function resolveCreateThreadExecutionDefaults( registry: ProviderRegistryService, args: ResolveCreateThreadExecutionDefaultsArgs, ): CreateThreadExecutionDefaultsResolved { + const isProductDefault = + args.requestedProviderId === undefined && + args.storedDefaults?.providerId === undefined; + const defaultCandidates = isProductDefault + ? listDefaultProviderIdCandidates(registry) + : []; const providerId = args.requestedProviderId ?? args.storedDefaults?.providerId ?? @@ -187,7 +206,13 @@ export function resolveCreateThreadExecutionDefaults( const storedDefaults = args.storedDefaults?.providerId === providerId ? args.storedDefaults : null; - return { executionDefaults: storedDefaults, providerId }; + return { + executionDefaults: storedDefaults, + providerId, + providerFallbackCandidates: defaultCandidates.filter( + (id) => id !== providerId, + ), + }; } export function buildProviderThreadExecutionDefaults( diff --git a/apps/server/test/public/public-threads.defaults.test.ts b/apps/server/test/public/public-threads.defaults.test.ts index 4063a490eb..38b31524cd 100644 --- a/apps/server/test/public/public-threads.defaults.test.ts +++ b/apps/server/test/public/public-threads.defaults.test.ts @@ -413,7 +413,7 @@ describe("public thread default routes", () => { }); }); - it("returns an actionable error when the default model catalog cannot be loaded", async () => { + it("returns an actionable error when an explicitly requested provider's model catalog cannot be loaded", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps); registerProviderHostRpcResponder(harness, { @@ -442,6 +442,9 @@ describe("public thread default routes", () => { body: JSON.stringify({ origin: "cli", projectId: project.id, + // An explicit provider request must not silently fall through to + // another provider when its catalog fails. + providerId: "codex", input: [{ type: "text", text: "Create without defaults" }], environment: { type: "reuse", @@ -463,6 +466,67 @@ describe("public thread default routes", () => { }); }); + it("falls back to another available provider when the product default's model catalog cannot be loaded", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + registerProviderHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + // Only the first provider.list_models call (codex) needs a custom + // response; restoring the default capture afterward lets the + // fallback call (claude-code) succeed via the harness's generic + // model-list stub and lets thread.start get captured normally. + restoreCommandCaptureAfterResponse: true, + modelErrorsByProviderId: { + // Codex (the product default: first in install order) is + // registered but has no usable models on this host, exactly like + // an installed-but-unconfigured provider plugin. + codex: { + errorCode: "command_failed", + errorMessage: "Codex model discovery failed", + }, + }, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/thread-defaults-catalog-fallback", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/thread-defaults-catalog-fallback", + }); + + const response = await harness.app.request("/api/v1/threads", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + origin: "cli", + projectId: project.id, + input: [{ type: "text", text: "Create without defaults" }], + environment: { + type: "reuse", + environmentId: environment.id, + }, + }), + }); + + expect(response.status).toBe(201); + const createdThread = threadSchema.parse(await readJson(response)); + expect(createdThread.providerId).toBe("claude-code"); + const queuedStart = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "thread.start" && + command.threadId === createdThread.id, + ); + expect(queuedStart.command).toMatchObject({ + providerId: "claude-code", + options: { model: "test-provider-default" }, + }); + }); + }); + it("rejects thread creation without an origin at the public API boundary", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); diff --git a/apps/server/test/threads/thread-default-policy.test.ts b/apps/server/test/threads/thread-default-policy.test.ts index e9f1578252..7d7ae609a8 100644 --- a/apps/server/test/threads/thread-default-policy.test.ts +++ b/apps/server/test/threads/thread-default-policy.test.ts @@ -84,6 +84,15 @@ describe("resolveCreateThreadExecutionDefaults", () => { ).toEqual({ providerId: "codex", executionDefaults: null, + providerFallbackCandidates: [ + "claude-code", + "pi", + "acp-cursor", + "acp-opencode", + "acp-omp", + "acp-grok", + "acp-hermes-agent", + ], }); }); @@ -130,6 +139,48 @@ describe("resolveCreateThreadExecutionDefaults", () => { ).toBe("pi"); }); + it("orders fallback candidates behind the chosen default, preferred provider first", async () => { + const preferences = { + providerOrder: ["pi", "claude-code"], + defaultProviderId: "codex" as string | null, + }; + const userRegistry = createProviderRegistryService({ + readUserProviderPreferences: () => preferences, + }); + await registerFirstPartyProviders(userRegistry); + + expect( + resolveCreateThreadExecutionDefaults(userRegistry, { + storedDefaults: null, + }).providerFallbackCandidates, + ).toEqual([ + "pi", + "claude-code", + "acp-cursor", + "acp-opencode", + "acp-omp", + "acp-grok", + "acp-hermes-agent", + ]); + }); + + it("has no fallback candidates when a provider was explicitly requested", () => { + expect( + resolveCreateThreadExecutionDefaults(registry, { + requestedProviderId: "codex", + storedDefaults: null, + }).providerFallbackCandidates, + ).toEqual([]); + }); + + it("has no fallback candidates when a stored default provider is used", () => { + expect( + resolveCreateThreadExecutionDefaults(registry, { + storedDefaults: makeDefaults({ providerId: "codex" }), + }).providerFallbackCandidates, + ).toEqual([]); + }); + it("discards stored defaults when the resolved provider changes", () => { expect( resolveCreateThreadExecutionDefaults(registry, { @@ -142,6 +193,7 @@ describe("resolveCreateThreadExecutionDefaults", () => { ).toEqual({ providerId: "pi", executionDefaults: null, + providerFallbackCandidates: [], }); }); @@ -158,6 +210,7 @@ describe("resolveCreateThreadExecutionDefaults", () => { ).toEqual({ providerId: "codex", executionDefaults: storedDefaults, + providerFallbackCandidates: [], }); }); @@ -171,7 +224,18 @@ describe("resolveCreateThreadExecutionDefaults", () => { resolveCreateThreadExecutionDefaults(degradedRegistry, { storedDefaults: null, }), - ).toEqual({ providerId: "claude-code", executionDefaults: null }); + ).toEqual({ + providerId: "claude-code", + executionDefaults: null, + providerFallbackCandidates: [ + "pi", + "acp-cursor", + "acp-opencode", + "acp-omp", + "acp-grok", + "acp-hermes-agent", + ], + }); }); it("rejects an explicitly selected unavailable provider", async () => {