diff --git a/apps/web/src/features/admin/components/admin-language-models-page.tsx b/apps/web/src/features/admin/components/admin-language-models-page.tsx
index 5461e6ce..96b8cd4b 100644
--- a/apps/web/src/features/admin/components/admin-language-models-page.tsx
+++ b/apps/web/src/features/admin/components/admin-language-models-page.tsx
@@ -768,8 +768,10 @@ function GatewaySettingsDialog({
const isAssistantGateway =
Boolean(gateway?.id) && assistantRoute?.gatewayProfileId === gateway?.id
+ const supportsCatalogReasoning =
+ !assistantRoute?.openAiReasoningEffort || assistantRoute.openAiReasoningEffort === "NONE"
const supportsAssistantChoices =
- isAssistantGateway && !assistantRoute?.openAiReasoningEffort
+ isAssistantGateway && supportsCatalogReasoning
function parsedAssistantModels() {
return assistantModels
@@ -892,9 +894,9 @@ function GatewaySettingsDialog({
Set this gateway as the Answer generation route before publishing choices to users.
- ) : assistantRoute?.openAiReasoningEffort ? (
+ ) : !supportsCatalogReasoning ? (
- Alternate choices are unavailable while the answer route pins a reasoning effort.
+ Alternate choices require provider-default or None reasoning on the answer route.
) : discoveredModels.length > 0 ? (
replaceAssistantModels(
"ai.assistant-model-gateway-inactive",
"Additional Assistant models must belong to the active Assistant gateway");
}
- if (route.openAiReasoningEffort() != null) {
+ if (route.openAiReasoningEffort() != null
+ && route.openAiReasoningEffort() != OpenAiReasoningEffort.NONE) {
throw new BusinessConflictException(
"ai.assistant-model-options-incompatible",
- "Additional Assistant models require provider-default reasoning options");
+ "Additional Assistant models require provider-default or none reasoning options");
}
List normalized = normalizeAssistantModels(
diff --git a/core/src/main/java/com/orgmemory/core/ai/AssistantModelAuthorityService.java b/core/src/main/java/com/orgmemory/core/ai/AssistantModelAuthorityService.java
index 0c19be44..3e4ff109 100644
--- a/core/src/main/java/com/orgmemory/core/ai/AssistantModelAuthorityService.java
+++ b/core/src/main/java/com/orgmemory/core/ai/AssistantModelAuthorityService.java
@@ -52,7 +52,7 @@ public List choices(UUID organizationId) {
route.modelId(),
route.modelId(),
true));
- if (override.isEmpty() || route.openAiReasoningEffort() != null) {
+ if (override.isEmpty() || !supportsCatalog(route.openAiReasoningEffort())) {
return List.copyOf(result);
}
administration.assistantModels(
@@ -88,7 +88,7 @@ public AssistantModelRouteAuthority authorize(
}
AiRouteOverrideView selectedRoute = override.orElseThrow(
AssistantModelAuthorityService::unavailable);
- if (selectedRoute.openAiReasoningEffort() != null) {
+ if (!supportsCatalog(selectedRoute.openAiReasoningEffort())) {
throw unavailable();
}
AiAssistantModelActivation activation = activations
@@ -182,7 +182,7 @@ private AiRoute revalidateCatalog(CatalogAssistantModelRouteAuthority selected)
if (!active.id().equals(selected.routeOverrideId())
|| active.version() != selected.routeOverrideVersion()
|| !active.gatewayProfileId().equals(selected.gatewayProfileId())
- || active.openAiReasoningEffort() != null) {
+ || !supportsCatalog(active.openAiReasoningEffort())) {
throw unavailable();
}
AiAssistantModelActivation activation = activations
@@ -196,7 +196,14 @@ private AiRoute revalidateCatalog(CatalogAssistantModelRouteAuthority selected)
AiGatewayProfileView profile = administration.require(
selected.organizationId(),
selected.gatewayProfileId());
- return new AiRoute(profile.gatewayKey(), activation.modelId());
+ return new AiRoute(
+ profile.gatewayKey(),
+ activation.modelId(),
+ active.openAiReasoningEffort());
+ }
+
+ private static boolean supportsCatalog(OpenAiReasoningEffort reasoning) {
+ return reasoning == null || reasoning == OpenAiReasoningEffort.NONE;
}
private static BusinessConflictException unavailable() {
diff --git a/core/src/test/java/com/orgmemory/core/ai/AiGatewayAdministrationServiceTests.java b/core/src/test/java/com/orgmemory/core/ai/AiGatewayAdministrationServiceTests.java
index 10517cfb..6bca0331 100644
--- a/core/src/test/java/com/orgmemory/core/ai/AiGatewayAdministrationServiceTests.java
+++ b/core/src/test/java/com/orgmemory/core/ai/AiGatewayAdministrationServiceTests.java
@@ -386,6 +386,45 @@ void reasoningCapabilityCannotBeDisabledWhileAnExplicitRouteUsesIt() {
assertTrue(storedProfile.get().supportsOpenAiReasoningEffort());
}
+ @Test
+ void assistantCatalogRejectsReasoningPoliciesOtherThanNone() {
+ UUID organizationId = UUID.randomUUID();
+ UUID adminUserId = UUID.randomUUID();
+ AiGatewayProfile profile = new AiGatewayProfile(
+ organizationId,
+ "openai-main",
+ "OpenAI",
+ AiGatewayPreset.OPENAI,
+ AiGatewayCategory.DIRECT_PROVIDER,
+ AiGatewayProtocol.OPENAI_COMPATIBLE,
+ "https://api.openai.com/v1",
+ 60,
+ true,
+ adminUserId);
+ AiRouteOverride route = new AiRouteOverride(
+ organizationId,
+ AiWorkload.ASSISTANT_CHAT,
+ profile.getId(),
+ "gpt-default",
+ OpenAiReasoningEffort.HIGH,
+ adminUserId,
+ java.time.Instant.now());
+ when(profiles.findByIdAndOrganizationIdAndEnabledTrue(
+ profile.getId(), organizationId))
+ .thenReturn(Optional.of(profile));
+ when(routes.findByOrganizationIdAndWorkload(
+ organizationId, AiWorkload.ASSISTANT_CHAT))
+ .thenReturn(Optional.of(route));
+
+ assertThrows(
+ BusinessConflictException.class,
+ () -> service.replaceAssistantModels(
+ organizationId,
+ profile.getId(),
+ List.of(new AiAssistantModelDefinition("gpt-fast", "Fast")),
+ adminUserId));
+ }
+
@Test
void assistantCatalogSoftDisablesAndReenableCreatesANewOpaqueActivation() {
UUID organizationId = UUID.randomUUID();
@@ -406,6 +445,7 @@ void assistantCatalogSoftDisablesAndReenableCreatesANewOpaqueActivation() {
AiWorkload.ASSISTANT_CHAT,
profile.getId(),
"gpt-default",
+ OpenAiReasoningEffort.NONE,
adminUserId,
java.time.Instant.now());
List stored = new ArrayList<>();
diff --git a/core/src/test/java/com/orgmemory/core/ai/AssistantModelAuthorityServiceTests.java b/core/src/test/java/com/orgmemory/core/ai/AssistantModelAuthorityServiceTests.java
index 29b60774..757a2fed 100644
--- a/core/src/test/java/com/orgmemory/core/ai/AssistantModelAuthorityServiceTests.java
+++ b/core/src/test/java/com/orgmemory/core/ai/AssistantModelAuthorityServiceTests.java
@@ -89,7 +89,49 @@ void catalogSelectionIsBoundToTheExactRouteIdentityAndVersion() {
}
@Test
- void explicitReasoningKeepsOnlyTheDefaultAndRejectsAlternateActivations() {
+ void explicitNoneOffersCatalogAndPropagatesTheGovernedReasoningPolicy() {
+ UUID organizationId = UUID.randomUUID();
+ UUID profileId = UUID.randomUUID();
+ UUID routeId = UUID.randomUUID();
+ UUID actorId = UUID.randomUUID();
+ AiRouteOverrideView current = routeOverride(
+ routeId,
+ profileId,
+ 3,
+ OpenAiReasoningEffort.NONE);
+ AiAssistantModelActivation activation = new AiAssistantModelActivation(
+ organizationId,
+ profileId,
+ "gpt-5.6-luna",
+ "GPT-5.6 Luna",
+ actorId);
+ when(routes.reference(organizationId, AiWorkload.ASSISTANT_CHAT))
+ .thenReturn(current.route());
+ when(administration.route(organizationId, AiWorkload.ASSISTANT_CHAT))
+ .thenReturn(Optional.of(current));
+ when(administration.require(organizationId, profileId))
+ .thenReturn(profile(profileId));
+ when(administration.assistantModels(organizationId, profileId))
+ .thenReturn(List.of(activation.view()));
+ when(activations.findByIdAndOrganizationIdAndEnabledTrue(
+ activation.getId(), organizationId))
+ .thenReturn(Optional.of(activation));
+
+ List choices = service.choices(organizationId);
+ AssistantModelRouteAuthority authority = service.authorize(
+ organizationId,
+ activation.getId());
+
+ assertEquals(List.of("gpt-default", "gpt-5.6-luna"), choices.stream()
+ .map(AssistantModelChoice::modelId)
+ .toList());
+ assertEquals(
+ new AiRoute("openai-main", "gpt-5.6-luna", OpenAiReasoningEffort.NONE),
+ service.revalidate(authority));
+ }
+
+ @Test
+ void unsupportedExplicitReasoningKeepsOnlyTheDefaultAndRejectsAlternateActivations() {
UUID organizationId = UUID.randomUUID();
UUID profileId = UUID.randomUUID();
AiRouteOverrideView current = routeOverride(
diff --git a/docs/specs/domains/ai-model-control-plane.md b/docs/specs/domains/ai-model-control-plane.md
index 763e2530..16115b04 100644
--- a/docs/specs/domains/ai-model-control-plane.md
+++ b/docs/specs/domains/ai-model-control-plane.md
@@ -5,7 +5,7 @@ Source: `core/src/main/java/com/orgmemory/core/ai`,
API/worker `application*.yml`, and
`apps/web/src/features/admin/components/admin-language-models-page.tsx`.
-Reconciled: `2026-08-06-assistant-chat-reasoning-effort (c3da6b25)`.
+Reconciled: `2026-08-06-assistant-catalog-none-reasoning`.
## Current Behavior
@@ -58,8 +58,13 @@ creates a new immutable activation UUID when a model is later re-enabled. Each
row repeats organization ownership in its profile and actor foreign keys; only
one active row may exist for an organization/profile/model tuple. Catalog
mutation is unavailable on deployment defaults, inactive gateways, or Answer
-routes with explicit reasoning effort. Catalog changes are audited without
-model prompts, output, endpoints, or credentials.
+routes with explicit reasoning effort other than `none`. Provider-default and
+explicit `none` may publish additional choices. Every selected activation
+inherits that server-owned route policy; the composer sends only the opaque
+activation UUID and cannot submit a raw model ID or reasoning value. Higher
+reasoning policies suppress alternate choices because catalog models have not
+been capability-validated for them. Catalog changes are audited without model
+prompts, output, endpoints, or credentials.
Deployment gateways use binder-safe nested objects. A production profile may
contribute only a managed credential while retaining the endpoint,
diff --git a/docs/tests/domains/ai-model-control-plane.md b/docs/tests/domains/ai-model-control-plane.md
index 43143c73..4dfe2509 100644
--- a/docs/tests/domains/ai-model-control-plane.md
+++ b/docs/tests/domains/ai-model-control-plane.md
@@ -8,7 +8,7 @@ Source: `core/src/test/java/com/orgmemory/core/ai`,
`apps/web/src/features/admin/components/provider-logo.test.tsx`,
`apps/web/test/e2e/admin-language-models.spec.ts`, and the admin web build.
-Reconciled: `2026-08-06-assistant-chat-reasoning-effort (c3da6b25)`.
+Reconciled: `2026-08-06-assistant-catalog-none-reasoning`.
| Behavior | Evidence | Status |
| --- | --- | --- |
@@ -19,6 +19,7 @@ Reconciled: `2026-08-06-assistant-chat-reasoning-effort (c3da6b25)`.
| Profile, credential, and route actor FKs cannot cross tenant boundaries | `PermissionsAdminIntegrationTests#aiControlPlaneActorReferencesCannotCrossTenantBoundaries` | covered |
| Credential rotation invalidates runtime model caches | `AiGatewayAdministrationServiceTests#credentialRotationAlwaysAdvancesTheRuntimeCacheRevision` | covered |
| Assistant catalog replacement soft-disables old authority and re-enable receives a new UUID | `AiGatewayAdministrationServiceTests#assistantCatalogSoftDisablesAndReenableCreatesANewOpaqueActivation`, `AssistantModelSelectionMigrationTests#activeModelIdentityIsUniqueButARevokedIdentityCanBeReplaced` | covered |
+| Assistant catalogs can be managed under explicit `none`, selected activations inherit `none`, and higher efforts remain fail-closed | `AiGatewayAdministrationServiceTests#assistantCatalogSoftDisablesAndReenableCreatesANewOpaqueActivation`, `#assistantCatalogRejectsReasoningPoliciesOtherThanNone`, `AssistantModelAuthorityServiceTests#explicitNoneOffersCatalogAndPropagatesTheGovernedReasoningPolicy`, `#unsupportedExplicitReasoningKeepsOnlyTheDefaultAndRejectsAlternateActivations`, `admin-language-models.spec.ts` | covered |
| Route identity can be authorized without requiring provider availability, while generation resolution still fails closed | `AiGatewayPropertiesTests#routeReferenceCanBeAuthorizedBeforeProviderAvailabilityIsNeeded`, `#anExplicitOrganizationRouteFailsClosedWhenItsGatewayIsUnavailable` | covered |
| Chat model dispatch selects the factory matching the route protocol and fails closed for missing or duplicate factories | `SpringAiChatModelFactoriesTests` | covered |
| Updating metadata and rotating a credential is one service transaction | `AiGatewayAdministrationServiceTests#metadataAndCredentialUpdateShareOneServiceTransaction` | covered |