Skip to content
Merged
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 @@ -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
Expand Down Expand Up @@ -892,9 +894,9 @@ function GatewaySettingsDialog({
<p className="text-xs text-muted-foreground">
Set this gateway as the Answer generation route before publishing choices to users.
</p>
) : assistantRoute?.openAiReasoningEffort ? (
) : !supportsCatalogReasoning ? (
<p className="text-xs text-muted-foreground">
Alternate choices are unavailable while the answer route pins a reasoning effort.
Alternate choices require provider-default or None reasoning on the answer route.
</p>
) : discoveredModels.length > 0 ? (
<Button
Expand Down
5 changes: 5 additions & 0 deletions apps/web/test/e2e/admin-language-models.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ test("admin connects providers and can restore the deployment model route", asyn
})
await page.getByRole("button", { name: "Close" }).click()

await page.getByRole("button", { name: /9Router primary/ }).first().click()
await expect(page.getByLabel("Assistant model choices")).toBeEnabled()
await page.getByRole("button", { name: "Close" }).click()

await page.getByRole("button", { name: "Use deployment default" }).first().click()
await expect(page.getByText("Deployment default restored.")).toBeVisible()
await expect(page.getByText("Deployment default", { exact: true })).toHaveCount(3)
Expand Down Expand Up @@ -239,6 +243,7 @@ async function modelSettingsHarness(
gatewayKey: "nine-router-primary",
gatewayProfileId: GATEWAY_ID,
modelId: "openai/gpt-5",
openAiReasoningEffort: "NONE",
source: "ORGANIZATION_OVERRIDE",
editable: true,
version: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,10 +460,11 @@ public List<AiAssistantModelActivationView> 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<AiAssistantModelDefinition> normalized = normalizeAssistantModels(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public List<AssistantModelChoice> 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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -406,6 +445,7 @@ void assistantCatalogSoftDisablesAndReenableCreatesANewOpaqueActivation() {
AiWorkload.ASSISTANT_CHAT,
profile.getId(),
"gpt-default",
OpenAiReasoningEffort.NONE,
adminUserId,
java.time.Instant.now());
List<AiAssistantModelActivation> stored = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssistantModelChoice> 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(
Expand Down
11 changes: 8 additions & 3 deletions docs/specs/domains/ai-model-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion docs/tests/domains/ai-model-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand All @@ -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 |
Expand Down
Loading