Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ResolveProjectExecutionDefaultsForCreateArgs {
interface ResolvedProjectExecutionDefaultsForCreate {
executionDefaults: ProjectExecutionDefaults | null;
providerId: string;
providerFallbackCandidates: readonly string[];
requestedModel: string | null;
}

Expand Down Expand Up @@ -95,11 +96,13 @@ export function resolveProjectExecutionDefaultsForCreate(
storedDefaults,
},
);
const { executionDefaults, providerId } = resolution;
const { executionDefaults, providerId, providerFallbackCandidates } =
resolution;

return {
executionDefaults,
providerId,
providerFallbackCandidates,
requestedModel: requestedModel ?? null,
};
}
Expand Down
73 changes: 57 additions & 16 deletions apps/server/src/services/threads/thread-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectExecutionDefaults | null> {
if (args.executionDefaults !== null || args.requestedModel !== null) {
return args.executionDefaults;
}

args: { cwd?: string; hostId: string; providerId: string },
): Promise<ProjectExecutionDefaults | ApiError> {
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.`,
Expand All @@ -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.`,
Expand All @@ -151,6 +154,30 @@ async function resolveCatalogExecutionDefaults(
});
}

async function resolveCatalogExecutionDefaults(
deps: ThreadCreateDeps,
args: ResolveCatalogExecutionDefaultsArgs,
): Promise<ProjectExecutionDefaults | null> {
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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 43 additions & 18 deletions apps/server/src/services/threads/thread-default-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 ??
Expand All @@ -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(
Expand Down
66 changes: 65 additions & 1 deletion apps/server/test/public/public-threads.defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
Loading