diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 816a0dcb981d2..aeb535774dfda 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2430,6 +2430,68 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("an unadvertised model refusal names advertised slugs to retry with", () => + Effect.gen(function* () { + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + getThreadRecords: () => Effect.succeed(parentProjection([])), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + { + ...codex, + models: [ + { slug: "claude-opus-4-1", isLegacy: true }, + ...Array.from({ length: 9 }, (_, index) => ({ slug: `claude-opus-5-${index + 1}` })), + ].map((model) => ({ ...model, name: model.slug, isCustom: false, capabilities: null })), + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const refuse = (model: string) => + service + .delegateTask(scope, { + task: "Summarize the diff.", + target: { providerInstanceId: codexInstanceId, model }, + mode: "async", + clientRequestId: `delegate-unadvertised-${model}`, + }) + .pipe(Effect.flip); + + const prefixed = yield* refuse("anthropic/claude-opus-5-3"); + assert.equal(prefixed.code, "model_unavailable"); + assert.equal( + prefixed.message, + "Model anthropic/claude-opus-5-3 is not advertised by provider codex. " + + "Did you mean claude-opus-5-3?", + ); + + const unmatched = yield* refuse("opus-5.5"); + assert.equal( + unmatched.message, + "Model opus-5.5 is not advertised by provider codex. Advertised models: " + + "claude-opus-5-1, claude-opus-5-2, claude-opus-5-3, claude-opus-5-4, claude-opus-5-5, " + + "claude-opus-5-6, claude-opus-5-7, claude-opus-5-8 and 2 more; " + + "orchestrator_capabilities with search finds them.", + ); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("driver-only delegation skips an instance the project turned off", () => Effect.gen(function* () { const altInstanceId = ProviderInstanceId.make("codex-alt"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index eaa505663c5bc..9d642c8455bc7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -354,6 +354,26 @@ function listCapabilityModels(input: { return { listed, omitted: visible.length - listed.length }; } +/** Names advertised slugs to retry with: same last `/` segment first, else a short list. */ +function advertisedModelsHint(models: ReadonlyArray, requested: string) { + const lastSegment = (slug: string) => slug.slice(slug.lastIndexOf("/") + 1).toLowerCase(); + const wanted = lastSegment(requested); + const close = models.filter((model) => lastSegment(model.slug) === wanted); + if (close.length > 0) { + return `Did you mean ${close + .slice(0, LISTED_MODEL_LIMIT) + .map((model) => model.slug) + .join(", ")}?`; + } + const listed = models + .toSorted((a, b) => Number(a.isLegacy ?? false) - Number(b.isLegacy ?? false)) + .slice(0, LISTED_MODEL_LIMIT); + const omitted = models.length - listed.length; + return `Advertised models: ${listed.map((model) => model.slug).join(", ")}${ + omitted > 0 ? ` and ${omitted} more; orchestrator_capabilities with search finds them` : "" + }.`; +} + /** Option ids and allowed values are all a delegating agent needs; labels are UI copy. */ function capabilityModelOption(descriptor: ProviderOptionDescriptor): OrchestratorMcpModelOption { const defaultValue = getProviderOptionCurrentValue(descriptor); @@ -377,7 +397,7 @@ export const requireAdvertisedModel = (provider: ServerProvider, model: string) ? Effect.fail( failure( "model_unavailable", - `Model ${model} is not advertised by provider ${provider.instanceId}.`, + `Model ${model} is not advertised by provider ${provider.instanceId}. ${advertisedModelsHint(provider.models, model)}`, ), ) : Effect.void; diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 938a2eac1b266..c667c9b00b9bf 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -399,7 +399,9 @@ it.effect("a launch with a model its provider does not advertise creates no thre ] as const) { expect(yield* launchWith(instanceId, model)).toMatchObject({ code: "model_unavailable", - message: `Model ${model} is not advertised by provider ${instanceId}.`, + message: expect.stringContaining( + `Model ${model} is not advertised by provider ${instanceId}. Advertised models: `, + ), }); } expect(launched).toHaveLength(0);