config.getApplications().containsKey(shortName)
+ || config.getToolsets().containsKey(shortName)
+ || config.getInterceptors().containsKey(shortName);
+ case APPLICATION -> config.getModels().containsKey(shortName)
+ || config.getToolsets().containsKey(shortName)
+ || config.getInterceptors().containsKey(shortName);
+ case TOOL_SET -> config.getModels().containsKey(shortName)
+ || config.getApplications().containsKey(shortName)
+ || config.getInterceptors().containsKey(shortName);
+ case INTERCEPTOR -> config.getModels().containsKey(shortName)
+ || config.getApplications().containsKey(shortName)
+ || config.getToolsets().containsKey(shortName);
+ default -> throw new IllegalArgumentException("Not a deployment type: " + type);
+ };
+ }
+
private static boolean isValidResourceKey(String resourceKey) {
return RESOURCE_KEY_PATTERN.matcher(resourceKey).matches();
}
@@ -425,14 +499,10 @@ public static String resourceKeyPattern() {
return RESOURCE_KEY_PATTERN.pattern();
}
- // Bare file-sourced toolset names have no '/'; API-managed toolsets are keyed by their canonical
- // id ("toolsets/platform/name") — validate only the trailing short-name segment in that case, same
- // as the plain-name case would validate the whole (slash-free) string. Only ToolSet map keys are
- // ever canonical-id-shaped this way — other RESOURCE_KEY_PATTERN callers (e.g. external-service
- // ids) must keep going through the strict isValidResourceKey above.
+ // ToolSet map keys are always short names now, file- and blob-sourced alike — same check as
+ // isValidResourceKey. Kept as its own named entry point since ToolSet call sites reason about
+ // it as "the toolset key check" rather than the generic one.
public static boolean isValidToolSetKey(String resourceKey) {
- int slash = resourceKey.lastIndexOf('/');
- String candidate = slash < 0 ? resourceKey : resourceKey.substring(slash + 1);
- return RESOURCE_KEY_PATTERN.matcher(candidate).matches();
+ return isValidResourceKey(resourceKey);
}
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/config/EntityChange.java b/server/src/main/java/com/epam/aidial/core/server/config/EntityChange.java
index 8e9bc30db..cadb2fbcb 100644
--- a/server/src/main/java/com/epam/aidial/core/server/config/EntityChange.java
+++ b/server/src/main/java/com/epam/aidial/core/server/config/EntityChange.java
@@ -9,6 +9,10 @@
* {@code decryptedEntity} signals delete. For non-null entities the caller is
* responsible for decrypting in-place before the call (same contract as the
* single-entity {@code applyEntityWrite} path).
+ *
+ * {@code mapKey} is the key used in {@code Config}'s in-memory type-map — the short name
+ * for models/interceptors/roles/applications/toolsets, the decoded {@code $id} for schema types,
+ * and the canonical id for all other types (keys, routes).
*/
-public record EntityChange(ResourceTypes type, String canonicalId, @Nullable Object decryptedEntity) {
+public record EntityChange(ResourceTypes type, String mapKey, @Nullable Object decryptedEntity) {
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/config/MergedConfigStore.java b/server/src/main/java/com/epam/aidial/core/server/config/MergedConfigStore.java
index 62b3e200d..47b9be2e2 100644
--- a/server/src/main/java/com/epam/aidial/core/server/config/MergedConfigStore.java
+++ b/server/src/main/java/com/epam/aidial/core/server/config/MergedConfigStore.java
@@ -23,6 +23,7 @@
import com.epam.aidial.core.storage.resource.ResourceTypes;
import com.epam.aidial.core.storage.service.LockService;
import com.epam.aidial.core.storage.service.ResourceService;
+import com.epam.aidial.core.storage.util.UrlUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import io.micrometer.core.instrument.Counter;
@@ -47,6 +48,9 @@
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Function;
+import javax.annotation.Nullable;
+
+import static com.epam.aidial.core.server.util.PlatformCanonicalIdUtil.lastSegment;
/**
* {@link ConfigStore} implementation that builds the runtime {@link Config} as the
@@ -334,14 +338,14 @@ static boolean isManagedEventUrl(String url) {
void applyReplicaEvent(ResourceDescriptor descriptor, ResourceEvent.Action action) {
try {
ResourceTypes type = (ResourceTypes) descriptor.getType();
- String canonicalId = canonicalId(descriptor);
+ String mapKey = mapKeyFor(descriptor);
if (action == ResourceEvent.Action.DELETE) {
- applyReplicaDelete(type, canonicalId);
+ applyReplicaDelete(type, mapKey);
return;
}
String body = resourceService.getResource(descriptor);
if (body == null) {
- applyReplicaDelete(type, canonicalId);
+ applyReplicaDelete(type, mapKey);
return;
}
JsonNode node = ProxyUtil.BLOB_MAPPER.readTree(body);
@@ -369,7 +373,7 @@ void applyReplicaEvent(ResourceDescriptor descriptor, ResourceEvent.Action actio
// applyReplicaDelete). On rotation (secret changed) the old auth bearer must be
// revoked so the previous secret no longer authenticates (FINDING #2).
Config snapshot = this.config;
- Key prior = snapshot == null ? null : snapshot.getKeys().get(canonicalId);
+ Key prior = snapshot == null ? null : snapshot.getKeys().get(mapKey);
String oldSecret = prior == null ? null : prior.getKey();
if (secret != null && !secret.isBlank()) {
ApiKeyData data = new ApiKeyData();
@@ -380,7 +384,7 @@ void applyReplicaEvent(ResourceDescriptor descriptor, ResourceEvent.Action actio
apiKeyStore.removeKey(oldSecret);
}
}
- applyEntityWrite(type, canonicalId, entity);
+ applyEntityWrite(type, mapKey, entity);
} finally {
rebuildLock.unlock();
}
@@ -391,7 +395,7 @@ void applyReplicaEvent(ResourceDescriptor descriptor, ResourceEvent.Action actio
}
}
- private void applyReplicaDelete(ResourceTypes type, String canonicalId) {
+ private void applyReplicaDelete(ResourceTypes type, String mapKey) {
if (type == ResourceTypes.GLOBAL_SETTINGS) {
applySettingsDelete();
return;
@@ -404,13 +408,13 @@ private void applyReplicaDelete(ResourceTypes type, String canonicalId) {
try {
if (type == ResourceTypes.PROJECT_KEY) {
Config snapshot = this.config;
- Key existing = snapshot == null ? null : snapshot.getKeys().get(canonicalId);
+ Key existing = snapshot == null ? null : snapshot.getKeys().get(mapKey);
String secret = existing == null ? null : existing.getKey();
if (secret != null && !secret.isBlank()) {
apiKeyStore.removeKey(secret);
}
}
- applyEntityDelete(type, canonicalId);
+ applyEntityDelete(type, mapKey);
} finally {
rebuildLock.unlock();
}
@@ -594,7 +598,7 @@ private void cancelPendingRebuild() {
/**
* Partial-update fast path for the API write controller (slice 4S.4 / OQ-32). Mutates the
- * single entity at {@code canonicalId} of the given {@code type} in the merged {@link Config}
+ * single entity at {@code mapKey} of the given {@code type} in the merged {@link Config}
* without re-scanning blob storage. {@code decryptedEntity} is the post-decryption Java entity
* (or the JSON-string body for {@code APP_TYPE_SCHEMA}) — the controller has already validated
* cross-references in strict mode and called {@code apiKeyStore.addOrUpdateKey} for keys.
@@ -603,10 +607,10 @@ private void cancelPendingRebuild() {
* resurrected when the new interceptor satisfies their references. For other types no transitive
* effect runs.
*/
- public Config applyEntityWrite(ResourceTypes type, String canonicalId, Object decryptedEntity) {
+ public Config applyEntityWrite(ResourceTypes type, String mapKey, Object decryptedEntity) {
rebuildLock.lock();
try {
- return applyEntityWriteLocked(type, canonicalId, decryptedEntity);
+ return applyEntityWriteLocked(type, mapKey, decryptedEntity);
} finally {
rebuildLock.unlock();
}
@@ -617,10 +621,10 @@ public Config applyEntityWrite(ResourceTypes type, String canonicalId, Object de
* cross-reference revalidation across the model map and routes newly-orphaned models through
* the {@code invalidEntities} sibling store. Other types have no transitive effect.
*/
- public Config applyEntityDelete(ResourceTypes type, String canonicalId) {
+ public Config applyEntityDelete(ResourceTypes type, String mapKey) {
rebuildLock.lock();
try {
- return applyEntityDeleteLocked(type, canonicalId);
+ return applyEntityDeleteLocked(type, mapKey);
} finally {
rebuildLock.unlock();
}
@@ -634,7 +638,7 @@ public Config applyEntityDelete(ResourceTypes type, String canonicalId) {
*
Each touched type-map is cloned once at the top of the batch and mutated
* in place across all entries — avoids the {@code O(batch × map)} clone cost that would arise
* from cloning the map per entry. Per-entry failures roll back the entity slot at
- * {@code (type, canonicalId)} so subsequent entries observe the pre-change state, matching
+ * {@code (type, mapKey)} so subsequent entries observe the pre-change state, matching
* single-entity atomicity.
*/
public Map applyBatch(List changes) {
@@ -663,7 +667,7 @@ public Map applyBatch(List changes) {
try {
applyChangeInPlace(next, nextInvalid, change, onSkip);
} catch (Exception error) {
- failures.put(change.canonicalId(), error.getMessage());
+ failures.put(change.mapKey(), error.getMessage());
}
}
@@ -677,7 +681,7 @@ public Map applyBatch(List changes) {
/**
* In-place per-entry apply for {@link #applyBatch}. Snapshots the entity slot at
- * {@code (type, canonicalId)} and the matching invalid-entity record so a validation/coercion
+ * {@code (type, mapKey)} and the matching invalid-entity record so a validation/coercion
* throw rolls back the per-entry mutation, leaving prior-entry effects intact in {@code next}.
* Cross-type mutations (resurrection / cascade) cannot partial-fail — the validate helper
* throws before the cross-type step runs.
@@ -687,42 +691,46 @@ private void applyChangeInPlace(Config next,
EntityChange change,
BiConsumer onSkip) {
ResourceTypes type = change.type();
- String canonicalId = change.canonicalId();
+ String mapKey = change.mapKey();
Object decryptedEntity = change.decryptedEntity();
- Object previousEntity = peekEntity(next, type, canonicalId);
+ Object previousEntity = peekEntity(next, type, mapKey);
Map perType = nextInvalid.get(type);
- InvalidEntityRecord previousInvalid = perType == null ? null : perType.get(canonicalId);
+ InvalidEntityRecord previousInvalid = perType == null ? null : perType.get(mapKey);
try {
- clearInvalid(nextInvalid, type, canonicalId);
+ clearInvalid(nextInvalid, type, mapKey);
if (decryptedEntity == null) {
- removeEntityInPlace(next, type, canonicalId);
+ removeEntityInPlace(next, type, mapKey);
if (type == ResourceTypes.INTERCEPTOR) {
ConfigPostProcessor.cascadeInterceptorDelete(next, onSkip);
}
return;
}
- putEntityInPlace(next, type, canonicalId, decryptedEntity);
+ putEntityInPlace(next, type, mapKey, decryptedEntity);
switch (type) {
- case MODEL -> ConfigPostProcessor.validateSingleModel(next, canonicalId, onSkip);
+ case MODEL -> ConfigPostProcessor.validateSingleModel(next, mapKey, onSkip);
case INTERCEPTOR -> {
- ConfigPostProcessor.validateSingleInterceptor(next, canonicalId);
+ ConfigPostProcessor.validateSingleInterceptor(next, mapKey);
resurrectInvalidModels(next, nextInvalid);
}
- case ROLE -> ConfigPostProcessor.validateSingleRole(next, canonicalId);
- case PROJECT_KEY, APP_TYPE_SCHEMA, CATALOG_SCHEMA, APPLICATION, TOOL_SET -> { /* no post-processing */ }
+ case ROLE -> ConfigPostProcessor.validateSingleRole(next, mapKey);
+ case APPLICATION -> ConfigPostProcessor.validateSingleApplication(next, mapKey);
+ case TOOL_SET -> ConfigPostProcessor.validateSingleToolSet(next, mapKey);
+ case PROJECT_KEY -> { /* no post-processing */ }
+ case APP_TYPE_SCHEMA -> ConfigPostProcessor.validateSingleSchema(next, ResourceTypes.APP_TYPE_SCHEMA, mapKey);
+ case CATALOG_SCHEMA -> ConfigPostProcessor.validateSingleSchema(next, ResourceTypes.CATALOG_SCHEMA, mapKey);
case ROUTE -> ConfigPostProcessor.sortRoutesInPlace(next);
default -> throw new IllegalArgumentException("Unsupported type for partial update: " + type);
}
} catch (RuntimeException error) {
if (previousEntity == null) {
- removeEntityInPlace(next, type, canonicalId);
+ removeEntityInPlace(next, type, mapKey);
} else {
- putEntityInPlace(next, type, canonicalId, previousEntity);
+ putEntityInPlace(next, type, mapKey, previousEntity);
}
if (previousInvalid != null) {
- nextInvalid.computeIfAbsent(type, k -> new HashMap<>()).put(canonicalId, previousInvalid);
+ nextInvalid.computeIfAbsent(type, k -> new HashMap<>()).put(mapKey, previousInvalid);
}
throw error;
}
@@ -769,7 +777,7 @@ public Config applySettingsDelete() {
}
}
- private Config applyEntityWriteLocked(ResourceTypes type, String canonicalId, Object decryptedEntity) {
+ private Config applyEntityWriteLocked(ResourceTypes type, String mapKey, Object decryptedEntity) {
Config next = shallowClone(this.config);
Map> nextInvalid = cloneInvalidDeep(this.invalidEntities);
@@ -782,7 +790,7 @@ private Config applyEntityWriteLocked(ResourceTypes type, String canonicalId, Ob
}
BiConsumer onSkip = skipRouter(nextInvalid);
- EntityChange change = new EntityChange(type, canonicalId, decryptedEntity);
+ EntityChange change = new EntityChange(type, mapKey, decryptedEntity);
applyChangeInPlace(next, nextInvalid, change, onSkip);
this.config = next;
@@ -790,7 +798,7 @@ private Config applyEntityWriteLocked(ResourceTypes type, String canonicalId, Ob
return next;
}
- private Config applyEntityDeleteLocked(ResourceTypes type, String canonicalId) {
+ private Config applyEntityDeleteLocked(ResourceTypes type, String mapKey) {
Config next = shallowClone(this.config);
Map> nextInvalid = cloneInvalidDeep(this.invalidEntities);
@@ -801,7 +809,7 @@ private Config applyEntityDeleteLocked(ResourceTypes type, String canonicalId) {
}
BiConsumer onSkip = skipRouter(nextInvalid);
- EntityChange change = new EntityChange(type, canonicalId, null);
+ EntityChange change = new EntityChange(type, mapKey, null);
applyChangeInPlace(next, nextInvalid, change, onSkip);
this.config = next;
@@ -814,24 +822,27 @@ private BiConsumer skipRouter(
if (!MODE_SKIP.equals(onInvalidEntity)) {
return null;
}
- return (skippedType, error) -> recordSkippedByMapKey(nextInvalid, skippedType, error, id -> null);
+ // Partial-update writes are always API-sourced — this path never touches file config.
+ return (skippedType, error) -> recordSkippedByMapKey(nextInvalid, skippedType, error, id -> null, true);
}
/**
- * Records a {@link InvalidEntityException} routed via {@code onSkip}, classifying the source the
- * same way the full {@code rebuild()} path does: a map key carrying a {@code '/'} is an API-sourced
- * canonical id, while a bare key is a file-defined simple name that we expand to its canonical id.
+ * Records a {@link InvalidEntityException} routed via {@code onSkip}. The map key's shape still
+ * tells us the canonical id and display name: a key carrying a {@code '/'} already is the
+ * canonical id, while a bare key is a short name we expand via {@code type/platform/name}. That
+ * derivation is independent of source, but the {@code source} label itself needs an explicit
+ * {@code fromApi} — for the five short-name-keyed types a file entry and a blob entry now share
+ * the identical (bare) key shape, so the caller must supply real provenance instead of guessing.
* {@code payloadByCanonicalId} supplies the parsed blob body (rebuild) or {@code null} (partial update).
*/
private void recordSkippedByMapKey(Map> nextInvalid,
ResourceTypes type, InvalidEntityException error,
- Function payloadByCanonicalId) {
+ Function payloadByCanonicalId, boolean fromApi) {
String mapKey = error.getMapKey();
- boolean fromApi = mapKey.contains("/");
- String canonicalId = fromApi
+ String canonicalId = mapKey.contains("/")
? mapKey
: canonicalId(type, locationStrategy.resolveBucket(type, EntityLocationStrategy.PLATFORM_SCOPE), mapKey);
- String simpleName = fromApi ? lastSegment(mapKey) : mapKey;
+ String simpleName = lastSegment(mapKey);
recordInvalid(nextInvalid, type, canonicalId, simpleName, error.getMessage(), error.getWarnings(),
payloadByCanonicalId.apply(canonicalId), REASON_VALIDATION, fromApi ? "api" : "file");
}
@@ -899,10 +910,10 @@ private static Map> cloneInvalid
}
private static void clearInvalid(Map> invalid,
- ResourceTypes type, String canonicalId) {
+ ResourceTypes type, String mapKey) {
Map perType = invalid.get(type);
if (perType != null) {
- perType.remove(canonicalId);
+ perType.remove(mapKey);
}
}
@@ -932,47 +943,47 @@ private static void cloneTypeMap(Config config, ResourceTypes type) {
}
}
- private static Object peekEntity(Config config, ResourceTypes type, String canonicalId) {
+ private static Object peekEntity(Config config, ResourceTypes type, String mapKey) {
return switch (type) {
- case MODEL -> config.getModels().get(canonicalId);
- case INTERCEPTOR -> config.getInterceptors().get(canonicalId);
- case ROLE -> config.getRoles().get(canonicalId);
- case PROJECT_KEY -> config.getKeys().get(canonicalId);
- case ROUTE -> config.getRoutes().get(canonicalId);
- case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().get(canonicalId);
- case CATALOG_SCHEMA -> config.getCatalogSchemas().get(canonicalId);
- case APPLICATION -> config.getApplications().get(canonicalId);
- case TOOL_SET -> config.getToolsets().get(canonicalId);
+ case MODEL -> config.getModels().get(mapKey);
+ case INTERCEPTOR -> config.getInterceptors().get(mapKey);
+ case ROLE -> config.getRoles().get(mapKey);
+ case PROJECT_KEY -> config.getKeys().get(mapKey);
+ case ROUTE -> config.getRoutes().get(mapKey);
+ case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().get(mapKey);
+ case CATALOG_SCHEMA -> config.getCatalogSchemas().get(mapKey);
+ case APPLICATION -> config.getApplications().get(mapKey);
+ case TOOL_SET -> config.getToolsets().get(mapKey);
default -> throw new IllegalArgumentException("Unsupported type for partial update: " + type);
};
}
- private static void putEntityInPlace(Config config, ResourceTypes type, String canonicalId, Object entity) {
+ private static void putEntityInPlace(Config config, ResourceTypes type, String mapKey, Object entity) {
switch (type) {
- case MODEL -> config.getModels().put(canonicalId, (Model) entity);
- case INTERCEPTOR -> config.getInterceptors().put(canonicalId, (Interceptor) entity);
- case ROLE -> config.getRoles().put(canonicalId, (Role) entity);
- case PROJECT_KEY -> config.getKeys().put(canonicalId, (Key) entity);
- case ROUTE -> config.getRoutes().put(canonicalId, (Route) entity);
- case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().put(canonicalId, schemaBody(entity));
- case CATALOG_SCHEMA -> config.getCatalogSchemas().put(canonicalId, schemaBody(entity));
- case APPLICATION -> config.getApplications().put(canonicalId, (Application) entity);
- case TOOL_SET -> config.getToolsets().put(canonicalId, (ToolSet) entity);
+ case MODEL -> config.getModels().put(mapKey, (Model) entity);
+ case INTERCEPTOR -> config.getInterceptors().put(mapKey, (Interceptor) entity);
+ case ROLE -> config.getRoles().put(mapKey, (Role) entity);
+ case PROJECT_KEY -> config.getKeys().put(mapKey, (Key) entity);
+ case ROUTE -> config.getRoutes().put(mapKey, (Route) entity);
+ case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().put(mapKey, schemaBody(entity));
+ case CATALOG_SCHEMA -> config.getCatalogSchemas().put(mapKey, schemaBody(entity));
+ case APPLICATION -> config.getApplications().put(mapKey, (Application) entity);
+ case TOOL_SET -> config.getToolsets().put(mapKey, (ToolSet) entity);
default -> throw new IllegalArgumentException("Unsupported type for partial update: " + type);
}
}
- private static void removeEntityInPlace(Config config, ResourceTypes type, String canonicalId) {
+ private static void removeEntityInPlace(Config config, ResourceTypes type, String mapKey) {
switch (type) {
- case MODEL -> config.getModels().remove(canonicalId);
- case INTERCEPTOR -> config.getInterceptors().remove(canonicalId);
- case ROLE -> config.getRoles().remove(canonicalId);
- case PROJECT_KEY -> config.getKeys().remove(canonicalId);
- case ROUTE -> config.getRoutes().remove(canonicalId);
- case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().remove(canonicalId);
- case CATALOG_SCHEMA -> config.getCatalogSchemas().remove(canonicalId);
- case APPLICATION -> config.getApplications().remove(canonicalId);
- case TOOL_SET -> config.getToolsets().remove(canonicalId);
+ case MODEL -> config.getModels().remove(mapKey);
+ case INTERCEPTOR -> config.getInterceptors().remove(mapKey);
+ case ROLE -> config.getRoles().remove(mapKey);
+ case PROJECT_KEY -> config.getKeys().remove(mapKey);
+ case ROUTE -> config.getRoutes().remove(mapKey);
+ case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().remove(mapKey);
+ case CATALOG_SCHEMA -> config.getCatalogSchemas().remove(mapKey);
+ case APPLICATION -> config.getApplications().remove(mapKey);
+ case TOOL_SET -> config.getToolsets().remove(mapKey);
default -> throw new IllegalArgumentException("Unsupported type for partial update: " + type);
}
}
@@ -1018,6 +1029,19 @@ private Config rebuild() {
Map toolsets = new LinkedHashMap<>(base.getToolsets());
merged.setRetriableErrorCodes(base.getRetriableErrorCodes());
merged.setGlobalInterceptors(base.getGlobalInterceptors());
+ // Wire the (still-being-populated) local maps onto merged now rather than after the blob
+ // scan below — same references either way, but it lets addBlobEntity/removeAddedEntity
+ // dispatch off a single Config parameter instead of a positional map per type (they used
+ // to take 9-11 map parameters each).
+ merged.setModels(models);
+ merged.setInterceptors(interceptors);
+ merged.setRoles(roles);
+ merged.setKeys(keys);
+ merged.setRoutes(routes);
+ merged.setApplicationTypeSchemas(schemas);
+ merged.setCatalogSchemas(catalogSchemas);
+ merged.setApplications(applications);
+ merged.setToolsets(toolsets);
Map blobBodies = new HashMap<>();
Map> pendingInvalid = new EnumMap<>(ResourceTypes.class);
@@ -1026,6 +1050,14 @@ private Config rebuild() {
// can classify the source without inferring from map-key shape — file secrets may be Base64
// and contain '/' (OQ-12). The merged keys map itself still folds both for config consumers.
Map apiKeysByCanonicalId = new HashMap<>();
+ // For models/interceptors/roles/applications/toolsets, a blob entry's map key (its short
+ // name) is indistinguishable in shape from a file entry's — both are bare, slash-free names.
+ // Schemas are locally-keyed too (by decoded $id); their $id always contains '/' so the
+ // shape-based check in the skip lambda already classifies them as "api", but they are
+ // tracked here for completeness. Track which (type, mapKey) pairs came from a blob this
+ // rebuild so the semantic pass can classify a skipped entity as "api" vs "file" correctly
+ // instead of guessing from key shape (which only works for the still-canonical-id-keyed types).
+ Map> apiSourcedKeys = new EnumMap<>(ResourceTypes.class);
for (ResourceTypes type : MANAGED_TYPES) {
for (String scope : locationStrategy.listScopes(type)) {
@@ -1045,6 +1077,7 @@ private Config rebuild() {
continue;
}
String canonicalId = canonicalId(type, bucket, name);
+ String mapKey = isLocallyKeyed(type) ? localKey(type, name) : canonicalId;
JsonNode node;
try {
// Parse once into JsonNode; reused below for typed deserialization and as the
@@ -1060,11 +1093,9 @@ private Config rebuild() {
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
type, bucket, bucketLocation, name);
- AddedEntity added;
+ Object added;
try {
- added = addBlobEntity(type, canonicalId, node,
- models, interceptors, roles, keys, routes, schemas, catalogSchemas,
- applications, toolsets);
+ added = addBlobEntity(merged, type, mapKey, node);
} catch (Exception parseError) {
recordInvalid(pendingInvalid, type, canonicalId, name,
"JSON parse failure: " + parseError.getMessage(),
@@ -1073,14 +1104,13 @@ private Config rebuild() {
continue;
}
- if (added != null && added.entity() != null) {
+ if (added != null) {
try {
- decryptManagedEntity(type, added.entity(), descriptor);
+ decryptManagedEntity(type, added, descriptor);
} catch (Exception decryptError) {
// Roll back the partial insertion so decryption-failure entities never
// reach addProjectKeys (locked 2S.9 invariant).
- removeAddedEntity(type, canonicalId, models, interceptors, roles, keys, routes, schemas, catalogSchemas,
- applications, toolsets);
+ removeAddedEntity(merged, type, mapKey);
recordInvalid(pendingInvalid, type, canonicalId, name,
"Decryption failure: " + decryptError.getMessage(),
List.of(new ValidationWarning("body", decryptError.getMessage())),
@@ -1088,7 +1118,10 @@ private Config rebuild() {
continue;
}
if (type == ResourceTypes.PROJECT_KEY) {
- apiKeysByCanonicalId.put(canonicalId, (Key) added.entity());
+ apiKeysByCanonicalId.put(canonicalId, (Key) added);
+ }
+ if (isLocallyKeyed(type)) {
+ apiSourcedKeys.computeIfAbsent(type, t -> new HashSet<>()).add(mapKey);
}
}
blobBodies.put(canonicalId, node);
@@ -1096,16 +1129,6 @@ private Config rebuild() {
}
}
- merged.setModels(models);
- merged.setInterceptors(interceptors);
- merged.setRoles(roles);
- merged.setKeys(keys);
- merged.setRoutes(routes);
- merged.setApplicationTypeSchemas(schemas);
- merged.setCatalogSchemas(catalogSchemas);
- merged.setApplications(applications);
- merged.setToolsets(toolsets);
-
// Semantic pass — under MODE_SKIP, route per-entity violations to invalidEntities and
// continue; under MODE_ABORT, the post-processor throws and the rebuild aborts (this.config
// stays at the previous value because we only swap below).
@@ -1122,7 +1145,11 @@ private Config rebuild() {
error.getMapKey(), error.getMessage());
return;
}
- recordSkippedByMapKey(pendingInvalid, type, error, blobBodies::get);
+ // For the short-name-keyed types, key shape no longer tells file from blob
+ // (see apiSourcedKeys above); everything else still resolves via shape alone.
+ boolean fromApi = error.getMapKey().contains("/")
+ || apiSourcedKeys.getOrDefault(type, Set.of()).contains(error.getMapKey());
+ recordSkippedByMapKey(pendingInvalid, type, error, blobBodies::get, fromApi);
}
: null;
// File partition = base file keys (keyed by secret); API partition = PROJECT_KEY blob entries
@@ -1130,11 +1157,10 @@ private Config rebuild() {
// consumers, but the ApiKeyStore feed stays explicitly source-classified (FINDING #7).
ConfigPostProcessor.processSemantic(merged, apiKeyStore, base.getKeys(), apiKeysByCanonicalId, onSkip);
- // ConfigPostProcessor sets entity.name = mapKey: canonical ID for API entries
- // ("models/platform/foo"), simple name for file entries ("gpt-4"). This is the OQ-23 contract:
- // canonical IDs surface on legacy /openai/models, /openai/deployments, and rate-limit
- // role-limit lookups for API-managed deployments. The new admin Configuration API listing
- // controller projects simpleName(mapKey) independently per design 03 §4.
+ // ConfigPostProcessor sets entity.name = mapKey. For models/interceptors/roles/
+ // applications/toolsets the map key is the short name ("gpt-4"); for schemas it is
+ // the decoded $id — in both cases file- and blob-sourced entries share the same map key.
+ // Keys and routes are unaffected — their map key stays the canonical id, as before.
boolean overlayFromApi = applySettingsOverlay(merged, pendingInvalid);
Map> finalInvalid =
@@ -1223,11 +1249,6 @@ private void recordInvalid(Map>
log.warn("Skipped {} '{}' from merged Config: {}", type.urlSegment(), canonicalId, reason);
}
- private static String lastSegment(String key) {
- int slash = key.lastIndexOf('/');
- return slash < 0 ? key : key.substring(slash + 1);
- }
-
public static String canonicalId(ResourceTypes type, String bucket, String name) {
return type.urlSegment() + ResourceDescriptor.PATH_SEPARATOR + bucket + ResourceDescriptor.PATH_SEPARATOR + name;
}
@@ -1237,56 +1258,99 @@ public static String canonicalId(ResourceDescriptor descriptor) {
descriptor.getBucketName(), descriptor.getName());
}
- private static AddedEntity addBlobEntity(ResourceTypes type, String canonicalId, JsonNode node,
- Map models, Map interceptors,
- Map roles, Map keys,
- LinkedHashMap routes, Map schemas,
- Map catalogSchemas,
- Map applications, Map toolsets)
+ /**
+ * The key {@code Config}'s in-memory maps use for {@code descriptor}'s entity.
+ *
+ * - Models/interceptors/roles/applications/toolsets → short name (last path segment), so
+ * blob-sourced and file-sourced entries of the same logical entity share one map key.
+ * - Schemas ({@code APP_TYPE_SCHEMA}, {@code CATALOG_SCHEMA}) → the decoded JSON-Schema
+ * {@code $id}, recovered from the blob name via {@link UrlUtil#unescapeSchemaId}.
+ * - Every other type → canonical id (unchanged).
+ *
+ * Callers that already have a {@link ResourceDescriptor} should use this instead of building
+ * the canonical id and then parsing the key back out of it.
+ */
+ public static String mapKeyFor(ResourceDescriptor descriptor) {
+ ResourceTypes type = (ResourceTypes) descriptor.getType();
+ return isLocallyKeyed(type) ? localKey(type, descriptor.getName()) : canonicalId(descriptor);
+ }
+
+ /**
+ * Returns {@code true} for types whose {@code Config} map key is derived from the blob file
+ * name rather than from the full canonical id. For schema types the blob name is the
+ * {@link UrlUtil#escapeSchemaId escaped} {@code $id}; for the remaining types it is the short
+ * entity name directly.
+ */
+ private static boolean isLocallyKeyed(ResourceTypes type) {
+ return switch (type) {
+ case MODEL, INTERCEPTOR, ROLE, APPLICATION, TOOL_SET,
+ APP_TYPE_SCHEMA, CATALOG_SCHEMA -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * Derives the {@code Config} map key from a blob file {@code name} for a locally-keyed type.
+ * Schema blob names are {@link UrlUtil#escapeSchemaId escaped} {@code $id} values and must be
+ * unescaped; all other locally-keyed types use the blob name verbatim.
+ */
+ private static String localKey(ResourceTypes type, String blobName) {
+ return switch (type) {
+ case APP_TYPE_SCHEMA, CATALOG_SCHEMA -> UrlUtil.unescapeSchemaId(blobName);
+ default -> blobName;
+ };
+ }
+
+ /**
+ * Reads the type-map to mutate off {@code config} rather than taking one map parameter per
+ * managed type — {@code config}'s maps are wired up by {@link #rebuild} before the blob scan
+ * that calls this runs, so they're already the right (still being populated) instances.
+ */
+ private static Object addBlobEntity(Config config, ResourceTypes type, String mapKey, JsonNode node)
throws JsonProcessingException {
switch (type) {
case MODEL -> {
Model entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Model.class);
- warnIfReplaced(type, canonicalId, models.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getModels().put(mapKey, entity));
+ return entity;
}
case INTERCEPTOR -> {
Interceptor entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Interceptor.class);
- warnIfReplaced(type, canonicalId, interceptors.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getInterceptors().put(mapKey, entity));
+ return entity;
}
case ROLE -> {
Role entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Role.class);
- warnIfReplaced(type, canonicalId, roles.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getRoles().put(mapKey, entity));
+ return entity;
}
case PROJECT_KEY -> {
Key entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Key.class);
- warnIfReplaced(type, canonicalId, keys.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getKeys().put(mapKey, entity));
+ return entity;
}
case ROUTE -> {
Route entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Route.class);
- warnIfReplaced(type, canonicalId, routes.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getRoutes().put(mapKey, entity));
+ return entity;
}
case APP_TYPE_SCHEMA -> {
- warnIfReplaced(type, canonicalId, schemas.put(canonicalId, node.toString()));
+ warnIfReplaced(type, mapKey, config.getApplicationTypeSchemas().put(mapKey, node.toString()));
return null;
}
case CATALOG_SCHEMA -> {
- warnIfReplaced(type, canonicalId, catalogSchemas.put(canonicalId, node.toString()));
+ warnIfReplaced(type, mapKey, config.getCatalogSchemas().put(mapKey, node.toString()));
return null;
}
case APPLICATION -> {
Application entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, Application.class);
- warnIfReplaced(type, canonicalId, applications.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getApplications().put(mapKey, entity));
+ return entity;
}
case TOOL_SET -> {
ToolSet entity = ProxyUtil.BLOB_MAPPER.treeToValue(node, ToolSet.class);
- warnIfReplaced(type, canonicalId, toolsets.put(canonicalId, entity));
- return new AddedEntity(entity);
+ warnIfReplaced(type, mapKey, config.getToolsets().put(mapKey, entity));
+ return entity;
}
default -> {
/* GLOBAL_SETTINGS is a singleton — design 02 §4 leaves union-by-key out of scope. */
@@ -1302,25 +1366,18 @@ private static void warnIfReplaced(ResourceTypes type, String canonicalId, Objec
}
}
- private static void removeAddedEntity(ResourceTypes type, String canonicalId,
- Map models, Map interceptors,
- Map roles, Map keys,
- LinkedHashMap routes, Map schemas,
- Map catalogSchemas,
- Map applications, Map toolsets) {
+ private static void removeAddedEntity(Config config, ResourceTypes type, String mapKey) {
switch (type) {
- case MODEL -> models.remove(canonicalId);
- case INTERCEPTOR -> interceptors.remove(canonicalId);
- case ROLE -> roles.remove(canonicalId);
- case PROJECT_KEY -> keys.remove(canonicalId);
- case ROUTE -> routes.remove(canonicalId);
- case APP_TYPE_SCHEMA -> schemas.remove(canonicalId);
- case CATALOG_SCHEMA -> catalogSchemas.remove(canonicalId);
- case APPLICATION -> applications.remove(canonicalId);
- case TOOL_SET -> toolsets.remove(canonicalId);
+ case MODEL -> config.getModels().remove(mapKey);
+ case INTERCEPTOR -> config.getInterceptors().remove(mapKey);
+ case ROLE -> config.getRoles().remove(mapKey);
+ case PROJECT_KEY -> config.getKeys().remove(mapKey);
+ case ROUTE -> config.getRoutes().remove(mapKey);
+ case APP_TYPE_SCHEMA -> config.getApplicationTypeSchemas().remove(mapKey);
+ case CATALOG_SCHEMA -> config.getCatalogSchemas().remove(mapKey);
+ case APPLICATION -> config.getApplications().remove(mapKey);
+ case TOOL_SET -> config.getToolsets().remove(mapKey);
default -> { /* no-op */ }
}
}
-
- private record AddedEntity(Object entity) { }
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/AdminApplyController.java b/server/src/main/java/com/epam/aidial/core/server/controller/AdminApplyController.java
index c6d710421..f36650464 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/AdminApplyController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/AdminApplyController.java
@@ -43,8 +43,11 @@
import com.epam.aidial.core.storage.service.LockService;
import com.epam.aidial.core.storage.service.ResourceService;
import com.epam.aidial.core.storage.util.EtagHeader;
+import com.epam.aidial.core.storage.util.UrlUtil;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import io.vertx.core.Future;
import io.vertx.core.buffer.Buffer;
import lombok.extern.slf4j.Slf4j;
@@ -53,6 +56,8 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
+
+import static com.epam.aidial.core.server.util.PlatformCanonicalIdUtil.lastSegment;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -308,8 +313,18 @@ static ValidationResult validateOnly(AdminManifest entry, Config scratch, boolea
if (!warnings.isEmpty() && !softValidation) {
return new ValidationResult(id, ValidationStatus.FAILED, joinWarnings(warnings));
}
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.MODEL, parsed);
+ if (dupError != null) {
+ return new ValidationResult(id, ValidationStatus.FAILED, dupError);
+ }
+ }
+ case "Interceptor" -> {
+ ConfigResourceController.treeToEntity(entry.spec(), Interceptor.class);
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.INTERCEPTOR, parsed);
+ if (dupError != null) {
+ return new ValidationResult(id, ValidationStatus.FAILED, dupError);
+ }
}
- case "Interceptor" -> ConfigResourceController.treeToEntity(entry.spec(), Interceptor.class);
case "Role" -> ConfigResourceController.treeToEntity(entry.spec(), Role.class);
case "Route" -> ConfigResourceController.treeToEntity(entry.spec(), Route.class);
case "Key" -> {
@@ -325,8 +340,24 @@ static ValidationResult validateOnly(AdminManifest entry, Config scratch, boolea
"Invalid key: at least one role must be assigned to the key " + key.getProject());
}
}
- case "Application" -> ConfigResourceController.treeToEntity(entry.spec(), Application.class);
- case "ToolSet" -> ConfigResourceController.treeToEntity(entry.spec(), ToolSet.class);
+ case "Application" -> {
+ ConfigResourceController.treeToEntity(entry.spec(), Application.class);
+ if (ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket())) {
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.APPLICATION, parsed);
+ if (dupError != null) {
+ return new ValidationResult(id, ValidationStatus.FAILED, dupError);
+ }
+ }
+ }
+ case "ToolSet" -> {
+ ConfigResourceController.treeToEntity(entry.spec(), ToolSet.class);
+ if (ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket())) {
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.TOOL_SET, parsed);
+ if (dupError != null) {
+ return new ValidationResult(id, ValidationStatus.FAILED, dupError);
+ }
+ }
+ }
case "Schema" -> {
if (!entry.spec().isObject()) {
return new ValidationResult(id, ValidationStatus.FAILED, "Schema spec must be a JSON object");
@@ -347,6 +378,19 @@ static ValidationResult validateOnly(AdminManifest entry, Config scratch, boolea
return new ValidationResult(id, ValidationStatus.VALID, null);
}
+ /**
+ * Shared by {@link #validateOnly} (precheck) and the real-apply {@code applyX} methods:
+ * non-null iff {@code parsed}'s short name is already claimed by a different
+ * model/application/interceptor/toolset in {@code scratch}. See
+ * {@link ConfigPostProcessor#isDeploymentIdTaken}.
+ */
+ private static String duplicateDeploymentIdMessage(Config scratch, ResourceTypes type, ParsedName parsed) {
+ if (ConfigPostProcessor.isDeploymentIdTaken(scratch, type, parsed.name())) {
+ return "Deployment ID '" + parsed.name() + "' is already used by a different entity";
+ }
+ return null;
+ }
+
private EntityResult applySingle(AdminManifest entry, Config scratch, List pending) {
String id = entry.name();
ParsedName parsed;
@@ -359,13 +403,13 @@ private EntityResult applySingle(AdminManifest entry, Config scratch, List applySettings(entry, id, parsed);
case "Schema" -> applySchema(entry, id, parsed, pending, ResourceTypes.APP_TYPE_SCHEMA);
case "CatalogSchema" -> applySchema(entry, id, parsed, pending, ResourceTypes.CATALOG_SCHEMA);
- case "Interceptor" -> applyManagedEntity(entry, id, parsed, ResourceTypes.INTERCEPTOR, Interceptor.class, pending);
- case "Role" -> applyManagedEntity(entry, id, parsed, ResourceTypes.ROLE, Role.class, pending);
- case "Route" -> applyManagedEntity(entry, id, parsed, ResourceTypes.ROUTE, Route.class, pending);
+ case "Interceptor" -> applyManagedEntity(entry, id, parsed, ResourceTypes.INTERCEPTOR, Interceptor.class, scratch, pending);
+ case "Role" -> applyManagedEntity(entry, id, parsed, ResourceTypes.ROLE, Role.class, scratch, pending);
+ case "Route" -> applyManagedEntity(entry, id, parsed, ResourceTypes.ROUTE, Route.class, scratch, pending);
case "Key" -> applyKey(entry, id, parsed, pending);
case "Model" -> applyModel(entry, id, parsed, scratch, pending);
- case "ToolSet" -> applyToolSet(entry, id, parsed, pending);
- case "Application" -> applyApplication(entry, id, parsed, pending);
+ case "ToolSet" -> applyToolSet(entry, id, parsed, scratch, pending);
+ case "Application" -> applyApplication(entry, id, parsed, scratch, pending);
default -> new EntityResult(id, AdminApplyStatus.FAILED, "Unknown kind: " + entry.kind());
};
}
@@ -382,34 +426,52 @@ private EntityResult applySettings(AdminManifest entry, String id, ParsedName pa
return new EntityResult(id, AdminApplyStatus.APPLIED, null);
}
- private EntityResult applySchema(AdminManifest entry, String id, ParsedName parsed, List pending,
- ResourceTypes type) {
+ private EntityResult applySchema(AdminManifest entry, String id, ParsedName parsed,
+ List pending, ResourceTypes type) {
if (!entry.spec().isObject()) {
return new EntityResult(id, AdminApplyStatus.FAILED, "Schema spec must be a JSON object");
}
- ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
- type, parsed.bucket(), parsed.location(), parsed.name());
+ // parsed.name() is the percent-encoded $id segment from the manifest name; decode once.
+ String decodedSchemaId = UrlUtil.decodePath(parsed.name());
+ ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecodedAtomicName(
+ type, parsed.bucket(), parsed.location(), decodedSchemaId);
+ // S3: ensure body.$id matches the derived $id from the canonical path.
+ JsonNode spec = entry.spec();
+ JsonNode idNode = spec.get("$id");
+ if (idNode == null || !decodedSchemaId.equals(idNode.asText())) {
+ spec = spec.deepCopy();
+ ((ObjectNode) spec).put("$id", decodedSchemaId);
+ }
String blobBody;
try {
- blobBody = ProxyUtil.BLOB_MAPPER.writeValueAsString(entry.spec());
+ blobBody = ProxyUtil.BLOB_MAPPER.writeValueAsString(spec);
} catch (JsonProcessingException e) {
// Drop e.getOriginalMessage() — it can echo verbatim schema content (potentially
// submitted secrets). Surface a generic failure tied to the entity id.
return new EntityResult(id, AdminApplyStatus.FAILED, "Failed to serialize schema for " + id);
}
resourceService.putResource(descriptor, blobBody, EtagHeader.ANY);
- pending.add(new EntityChange(type, MergedConfigStore.canonicalId(descriptor), entry.spec()));
+ pending.add(new EntityChange(type, MergedConfigStore.mapKeyFor(descriptor), spec));
return new EntityResult(id, AdminApplyStatus.APPLIED, null);
}
private EntityResult applyManagedEntity(AdminManifest entry, String id, ParsedName parsed,
- ResourceTypes type, Class entityClass, List pending) {
+ ResourceTypes type, Class entityClass, Config scratch,
+ List pending) {
T entity = ConfigResourceController.treeToEntity(entry.spec(), entityClass);
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
type, parsed.bucket(), parsed.location(), parsed.name());
+ /// Deployment-id uniqueness only applies to INTERCEPTOR here — ROLE/ROUTE aren't deployments
+ // resolved through Config.selectDeployment, so they don't share the short-name namespace.
+ if (type == ResourceTypes.INTERCEPTOR) {
+ String dupError = duplicateDeploymentIdMessage(scratch, type, parsed);
+ if (dupError != null) {
+ return new EntityResult(id, AdminApplyStatus.FAILED, dupError);
+ }
+ }
String blobBody = ConfigResourceController.serializeForBlob(entity);
resourceService.putResource(descriptor, blobBody, EtagHeader.ANY);
- pending.add(new EntityChange(type, MergedConfigStore.canonicalId(descriptor), entity));
+ pending.add(new EntityChange(type, MergedConfigStore.mapKeyFor(descriptor), entity));
return new EntityResult(id, AdminApplyStatus.APPLIED, null);
}
@@ -472,43 +534,62 @@ private EntityResult applyModel(AdminManifest entry, String id, ParsedName parse
}
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
ResourceTypes.MODEL, parsed.bucket(), parsed.location(), parsed.name());
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.MODEL, parsed);
+ if (dupError != null) {
+ return new EntityResult(id, AdminApplyStatus.FAILED, dupError);
+ }
secretFieldProcessor.encryptFields(model, descriptor);
String blobBody = ConfigResourceController.serializeForBlob(model);
resourceService.putResource(descriptor, blobBody, EtagHeader.ANY);
// Slice 4S.4: decrypt-in-place so partial-update receives plaintext upstream secrets.
secretFieldProcessor.decryptFields(model, descriptor);
- pending.add(new EntityChange(ResourceTypes.MODEL, MergedConfigStore.canonicalId(descriptor), model));
+ pending.add(new EntityChange(ResourceTypes.MODEL, MergedConfigStore.mapKeyFor(descriptor), model));
return new EntityResult(id, invalid ? AdminApplyStatus.APPLIED_INVALID : AdminApplyStatus.APPLIED, null);
}
- private EntityResult applyApplication(AdminManifest entry, String id, ParsedName parsed, List pending) {
+ private EntityResult applyApplication(AdminManifest entry, String id, ParsedName parsed, Config scratch, List pending) {
Application application = ConfigResourceController.treeToEntity(entry.spec(), Application.class);
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
ResourceTypes.APPLICATION, parsed.bucket(), parsed.location(), parsed.name());
+ // Only the platform bucket is materialized into MergedConfigStore (see EntityLocationStrategy) —
+ // public-bucket apps stay outside it and are served lazily by ApplicationService, so they're
+ // exempt from deployment-id uniqueness and pushing them into `pending` below would spuriously
+ // duplicate them in config.getApplications()-backed listings (e.g. ApplicationController/
+ // DeploymentController) until the next full rebuild.
+ boolean platform = ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket());
+ if (platform) {
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.APPLICATION, parsed);
+ if (dupError != null) {
+ return new EntityResult(id, AdminApplyStatus.FAILED, dupError);
+ }
+ }
// Bulk admin apply is always admin context — preserve forwardAuthToken if the manifest set it.
applicationService.putApplication(descriptor, EtagHeader.ANY, null, application, true,
AdminManagedFieldsWriteMode.AUTHORITATIVE);
- // Only the platform bucket is materialized into MergedConfigStore (see EntityLocationStrategy) —
- // public-bucket apps stay outside it and are served lazily by ApplicationService, so pushing
- // them into `pending` here would spuriously duplicate them in config.getApplications()-backed
- // listings (e.g. ApplicationController/DeploymentController) until the next full rebuild.
- if (ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket())) {
+ if (platform) {
Application decrypted = applicationService.getApplicationWithDecryptedSecrets(descriptor).getValue();
- pending.add(new EntityChange(ResourceTypes.APPLICATION, MergedConfigStore.canonicalId(descriptor), decrypted));
+ pending.add(new EntityChange(ResourceTypes.APPLICATION, MergedConfigStore.mapKeyFor(descriptor), decrypted));
}
return new EntityResult(id, AdminApplyStatus.APPLIED, null);
}
- private EntityResult applyToolSet(AdminManifest entry, String id, ParsedName parsed, List pending) {
+ private EntityResult applyToolSet(AdminManifest entry, String id, ParsedName parsed, Config scratch, List pending) {
ToolSet toolSet = ConfigResourceController.treeToEntity(entry.spec(), ToolSet.class);
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
ResourceTypes.TOOL_SET, parsed.bucket(), parsed.location(), parsed.name());
- toolSetService.putToolSet(descriptor, EtagHeader.ANY, null, toolSet, true);
// Same rationale as applyApplication above — only platform-bucket toolsets belong in
- // MergedConfigStore.
- if (ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket())) {
+ // MergedConfigStore / are subject to deployment-id uniqueness.
+ boolean platform = ResourceDescriptor.PLATFORM_BUCKET.equals(parsed.bucket());
+ if (platform) {
+ String dupError = duplicateDeploymentIdMessage(scratch, ResourceTypes.TOOL_SET, parsed);
+ if (dupError != null) {
+ return new EntityResult(id, AdminApplyStatus.FAILED, dupError);
+ }
+ }
+ toolSetService.putToolSet(descriptor, EtagHeader.ANY, null, toolSet, true);
+ if (platform) {
ToolSet decrypted = toolSetService.getToolSetWithDecryptedAuthSettings(descriptor).getValue();
- pending.add(new EntityChange(ResourceTypes.TOOL_SET, MergedConfigStore.canonicalId(descriptor), decrypted));
+ pending.add(new EntityChange(ResourceTypes.TOOL_SET, MergedConfigStore.mapKeyFor(descriptor), decrypted));
}
return new EntityResult(id, AdminApplyStatus.APPLIED, null);
}
@@ -550,22 +631,18 @@ static void mutateScratch(Config scratch, AdminManifest entry) {
scratch.getToolsets().put(entry.name(), toolSet);
}
case "Schema" -> {
- String json;
+ String schemaId = UrlUtil.decodePath(lastSegment(entry.name()));
try {
- json = ProxyUtil.BLOB_MAPPER.writeValueAsString(entry.spec());
- } catch (JsonProcessingException e) {
- return;
+ scratch.getApplicationTypeSchemas().put(schemaId, ProxyUtil.BLOB_MAPPER.writeValueAsString(entry.spec()));
+ } catch (JsonProcessingException ignored) {
}
- scratch.getApplicationTypeSchemas().put(entry.name(), json);
}
case "CatalogSchema" -> {
- String json;
+ String schemaId = UrlUtil.decodePath(lastSegment(entry.name()));
try {
- json = ProxyUtil.BLOB_MAPPER.writeValueAsString(entry.spec());
- } catch (JsonProcessingException e) {
- return;
+ scratch.getCatalogSchemas().put(schemaId, ProxyUtil.BLOB_MAPPER.writeValueAsString(entry.spec()));
+ } catch (JsonProcessingException ignored) {
}
- scratch.getCatalogSchemas().put(entry.name(), json);
}
default -> { /* unknown kinds never reach this code path */ }
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
index cd91b406f..ac05e50d5 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
@@ -46,6 +46,7 @@
import com.epam.aidial.core.storage.service.LockService;
import com.epam.aidial.core.storage.service.ResourceService;
import com.epam.aidial.core.storage.util.EtagHeader;
+import com.epam.aidial.core.storage.util.UrlUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -951,7 +952,11 @@ public Future> handle() throws Exception {
if (method == HttpMethod.GET || method == HttpMethod.HEAD) {
return handleGet();
}
+ ResourceTypes nameValidationType = resourceType();
+ boolean isSchemaType = nameValidationType == ResourceTypes.APP_TYPE_SCHEMA
+ || nameValidationType == ResourceTypes.CATALOG_SCHEMA;
if ((method == HttpMethod.PUT || method == HttpMethod.DELETE)
+ && !isSchemaType
&& !ENTITY_NAME_PATTERN.matcher(path == null ? "" : path).matches()) {
context.respond(HttpStatus.BAD_REQUEST,
"Invalid entity name segment: must match " + ENTITY_NAME_PATTERN.pattern());
@@ -1003,22 +1008,18 @@ public Future> handle() throws Exception {
return respondMethodNotAllowed();
}
- private Future> handleGet() throws JsonProcessingException {
+ private Future> handleGet() {
Config config = context.getConfig();
- boolean admin = authorizationService.isAdmin(context);
// Per-entity GET is blob-only (slice U.1): only canonical-ID lookups resolve here.
// File-sourced entries are inspected via /v1/admin/config/file/{type}[/{name}].
// Slice U.4: secret fields drop on response via @JsonProperty(WRITE_ONLY) — there is no
// ?reveal_secrets=true reveal flow and no security-admin tier.
return switch (resourceType()) {
- case MODEL -> handleSingleGet(
- config.getModels(), ResourceTypes.MODEL,
+ case MODEL -> handleSingleGetFromBlob(ResourceTypes.MODEL,
(key, model) -> projectItem(model, key));
- case INTERCEPTOR -> handleSingleGet(
- config.getInterceptors(), ResourceTypes.INTERCEPTOR,
+ case INTERCEPTOR -> handleSingleGetFromBlob(ResourceTypes.INTERCEPTOR,
(key, interceptor) -> projectItem(interceptor, key));
- case ROLE -> handleSingleGet(
- config.getRoles(), ResourceTypes.ROLE,
+ case ROLE -> handleSingleGetFromBlob(ResourceTypes.ROLE,
(key, role) -> projectItem(role, key));
case PROJECT_KEY -> handleSingleGet(
config.getKeys(), ResourceTypes.PROJECT_KEY,
@@ -1026,19 +1027,25 @@ private Future> handleGet() throws JsonProcessingException {
case ROUTE -> handleSingleGet(
config.getRoutes(), ResourceTypes.ROUTE,
(key, route) -> projectItem(route, key));
- case APP_TYPE_SCHEMA -> handleSchemaGet(config.getApplicationTypeSchemas(), ResourceTypes.APP_TYPE_SCHEMA, admin);
- case CATALOG_SCHEMA -> handleSchemaGet(config.getCatalogSchemas(), ResourceTypes.CATALOG_SCHEMA, admin);
- case APPLICATION -> handleSingleGet(
- config.getApplications(), ResourceTypes.APPLICATION,
+ case APP_TYPE_SCHEMA -> handleSingleGetFromBlob(ResourceTypes.APP_TYPE_SCHEMA,
+ (schemaId, node) -> projectSchemaItem(schemaId, (JsonNode) node));
+ case CATALOG_SCHEMA -> handleSingleGetFromBlob(ResourceTypes.CATALOG_SCHEMA,
+ (schemaId, node) -> projectSchemaItem(schemaId, (JsonNode) node));
+ case APPLICATION -> handleSingleGetFromBlob(ResourceTypes.APPLICATION,
(key, application) -> redactExternalServiceSecrets(projectItem(application, key)));
- case TOOL_SET -> handleSingleGet(
- config.getToolsets(), ResourceTypes.TOOL_SET,
+ case TOOL_SET -> handleSingleGetFromBlob(ResourceTypes.TOOL_SET,
(key, toolSet) -> redactAuthSettingsSecrets(projectItem(toolSet, key)));
case GLOBAL_SETTINGS -> handleSettingsGet(config);
default -> respondMethodNotAllowed();
};
}
+ /**
+ * Per-entity GET for {@code PROJECT_KEY}/{@code ROUTE} only — these two types still key
+ * {@code Config}'s in-memory map by canonical id (never migrated to short-name keying), so
+ * file-sourced entries never share a key with a blob-sourced one and this lookup can safely
+ * stay in-memory.
+ */
private Future> handleSingleGet(Map source,
ResourceTypes resourceType,
BiFunction projector) {
@@ -1078,6 +1085,83 @@ private Future> handleSingleGet(Map source,
return Future.succeededFuture();
}
+ /**
+ * Per-entity GET for locally-keyed types ({@code MODEL}, {@code INTERCEPTOR}, {@code ROLE},
+ * {@code APPLICATION}, {@code TOOL_SET}, {@code APP_TYPE_SCHEMA}, {@code CATALOG_SCHEMA}) —
+ * these types key {@code Config}'s in-memory map by a local identifier (short name or
+ * {@code $id}), the same key a file-sourced entry for the same logical entity already uses, so
+ * the map can no longer tell a genuinely blob-managed entity apart from a file-only one sharing
+ * that key. This reads blob storage directly by descriptor instead — the same pattern PUT/DELETE
+ * already use for these types — so this endpoint only ever serves entities that actually exist
+ * in the {@code platform} bucket. {@code Config}'s map stays purely a runtime-resolution
+ * structure.
+ */
+ private Future> handleSingleGetFromBlob(ResourceTypes resourceType, BiFunction projector) {
+ if (path == null || path.isEmpty()) {
+ context.respond(HttpStatus.NOT_FOUND);
+ return Future.succeededFuture();
+ }
+ ResourceDescriptor descriptor = descriptorFor(resourceType);
+ boolean admin = authorizationService.isAdmin(context);
+ EtagHeader etag = ProxyUtil.etag(context.getRequest());
+
+ taskExecutor.submit(() -> {
+ // getResourceWithMetadata validates the conditional header itself (throws the
+ // appropriate HttpException for If-None-Match/If-Match), so a 304/412 short-circuits
+ // here before any decrypt work runs.
+ Pair existing = resourceService.getResourceWithMetadata(descriptor, etag);
+ if (existing == null) {
+ return null;
+ }
+ Object entity = deserializeBlob(resourceType, existing.getValue());
+ return Pair.of(existing.getKey().getEtag(), projector.apply(path, entity));
+ }).onSuccess(result -> {
+ if (result == null) {
+ Map invalid = mergedConfigStore.getInvalidEntities()
+ .getOrDefault(resourceType, Map.of());
+ InvalidEntityRecord invalidRecord = invalid.get(canonicalId());
+ if (invalidRecord != null) {
+ context.respond(HttpStatus.OK, projectInvalidItem(invalidRecord, admin));
+ } else {
+ context.respond(HttpStatus.NOT_FOUND);
+ }
+ return;
+ }
+ context.putHeader(HttpHeaders.ETAG, result.getKey())
+ .respond(HttpStatus.OK, result.getValue());
+ }).onFailure(this::handleWriteError);
+
+ return Future.succeededFuture();
+ }
+
+ /**
+ * Deserializes a blob body for {@link #handleSingleGetFromBlob}. No decryption is performed:
+ * secret fields on entity types are {@code @JsonProperty(WRITE_ONLY)} and are therefore
+ * suppressed by Jackson on serialization regardless of their content; APPLICATION and TOOL_SET
+ * secrets are additionally redacted by the projector ({@link #redactExternalServiceSecrets}/
+ * {@link #redactAuthSettingsSecrets}).
+ */
+ private Object deserializeBlob(ResourceTypes type, String body) {
+ return switch (type) {
+ case APP_TYPE_SCHEMA, CATALOG_SCHEMA -> {
+ try {
+ yield ProxyUtil.BLOB_MAPPER.readTree(body);
+ } catch (JsonProcessingException e) {
+ throw new HttpException(HttpStatus.INTERNAL_SERVER_ERROR,
+ "Stored schema is malformed at " + locationOf(e));
+ }
+ }
+ default -> {
+ try {
+ yield treeToEntity(ProxyUtil.BLOB_MAPPER.readTree(body), entityClassFor(entityType));
+ } catch (JsonProcessingException e) {
+ throw new HttpException(HttpStatus.INTERNAL_SERVER_ERROR,
+ "Stored entity is malformed at " + locationOf(e));
+ }
+ }
+ };
+ }
+
/**
* Emits a {@code 304 Not Modified} if the matched entity's stored blob has an ETag
* that matches the client's {@code If-None-Match} header. The blob-metadata fetch
@@ -1143,9 +1227,9 @@ private ResourceDescriptor descriptorFor(ResourceTypes type) {
ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION, path);
case ROUTE -> ResourceDescriptorFactory.fromDecoded(ResourceTypes.ROUTE,
ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION, path);
- case APP_TYPE_SCHEMA -> ResourceDescriptorFactory.fromDecoded(ResourceTypes.APP_TYPE_SCHEMA,
+ case APP_TYPE_SCHEMA -> ResourceDescriptorFactory.fromDecodedAtomicName(ResourceTypes.APP_TYPE_SCHEMA,
ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION, path);
- case CATALOG_SCHEMA -> ResourceDescriptorFactory.fromDecoded(ResourceTypes.CATALOG_SCHEMA,
+ case CATALOG_SCHEMA -> ResourceDescriptorFactory.fromDecodedAtomicName(ResourceTypes.CATALOG_SCHEMA,
ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION, path);
case APPLICATION -> ResourceDescriptorFactory.fromDecoded(ResourceTypes.APPLICATION,
ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION, path);
@@ -1156,41 +1240,11 @@ private ResourceDescriptor descriptorFor(ResourceTypes type) {
}
private String canonicalId() {
- return entityType + "/" + bucket + "/" + path;
- }
-
- private Future> handleSchemaGet(Map schemas, ResourceTypes resourceType, boolean admin) throws JsonProcessingException {
- Map invalid = mergedConfigStore.getInvalidEntities()
- .getOrDefault(resourceType, Map.of());
- if (path == null || path.isEmpty()) {
- context.respond(HttpStatus.NOT_FOUND);
- return Future.succeededFuture();
- }
- // Per-entity GET is blob-only (U.1): canonical-ID lookup only; file-defined schemas
- // are inspected via /v1/admin/config/file/schemas/{name}.
- String schemaJson = schemas.get(canonicalId());
- if (schemaJson != null) {
- final String matched = canonicalId();
- final String json = schemaJson;
- return respondNotModifiedIfMatched(matched)
- .onSuccess(notModified -> {
- if (!notModified) {
- try {
- context.respond(HttpStatus.OK, projectSchemaItem(matched, json));
- } catch (JsonProcessingException e) {
- context.respond(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
- }
- }
- })
- .onFailure(this::handleWriteError);
- }
- InvalidEntityRecord invalidRecord = invalid.get(canonicalId());
- if (invalidRecord != null) {
- context.respond(HttpStatus.OK, projectInvalidItem(invalidRecord, admin));
- return Future.succeededFuture();
+ ResourceTypes type = resourceType();
+ if (type == ResourceTypes.APP_TYPE_SCHEMA || type == ResourceTypes.CATALOG_SCHEMA) {
+ return entityType + "/" + bucket + "/" + UrlUtil.encodePathSegment(path);
}
- context.respond(HttpStatus.NOT_FOUND);
- return Future.succeededFuture();
+ return entityType + "/" + bucket + "/" + path;
}
private Future> handleSettingsGet(Config config) {
@@ -1305,21 +1359,25 @@ private Future> handleAppOrToolSetPut() {
throw new HttpException(HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
return taskExecutor.submit(() -> lockService.underBucketLocks(MergedConfigStore.ADMIN_BUCKET_LOCATIONS, () -> {
- Object decrypted;
+ rejectDuplicateDeploymentId(type, path);
// The platform bucket requires explicit admin access for every operation (see
// AdminRoleAuthorizationService), not just an admin-AND-public-bucket combination like
// ResourceController's adminPublicWrite — so, same as AdminApplyController's bulk apply,
// this path is always admin context and may preserve forwardAuthToken.
- if (type == ResourceTypes.APPLICATION) {
- Application application = treeToEntity(requestNode, Application.class);
- applicationService.putApplication(descriptor, etag, author, application, true, AdminManagedFieldsWriteMode.AUTHORITATIVE);
- decrypted = applicationService.getApplicationWithDecryptedSecrets(descriptor).getValue();
- } else {
- ToolSet toolSet = treeToEntity(requestNode, ToolSet.class);
- toolSetService.putToolSet(descriptor, etag, author, toolSet, true);
- decrypted = toolSetService.getToolSetWithDecryptedAuthSettings(descriptor).getValue();
- }
- mergedConfigStore.applyEntityWrite(type, MergedConfigStore.canonicalId(descriptor), decrypted);
+ Object decrypted = switch (type) {
+ case APPLICATION -> {
+ Application application = treeToEntity(requestNode, Application.class);
+ applicationService.putApplication(descriptor, etag, author, application, true, AdminManagedFieldsWriteMode.AUTHORITATIVE);
+ yield applicationService.getApplicationWithDecryptedSecrets(descriptor).getValue();
+ }
+ case TOOL_SET -> {
+ ToolSet toolSet = treeToEntity(requestNode, ToolSet.class);
+ toolSetService.putToolSet(descriptor, etag, author, toolSet, true);
+ yield toolSetService.getToolSetWithDecryptedAuthSettings(descriptor).getValue();
+ }
+ default -> throw new IllegalArgumentException("Unexpected resource type: " + type);
+ };
+ mergedConfigStore.applyEntityWrite(type, MergedConfigStore.mapKeyFor(descriptor), decrypted);
return resourceService.getResourceMetadata(descriptor);
}));
}).onSuccess(meta -> context.putHeader(HttpHeaders.ETAG, meta.getEtag())
@@ -1341,15 +1399,17 @@ private Future> handleAppOrToolSetDelete() {
EtagHeader etag = ProxyUtil.etag(context.getRequest());
taskExecutor.submit(() -> lockService.underBucketLocks(MergedConfigStore.ADMIN_BUCKET_LOCATIONS, () -> {
- if (type == ResourceTypes.APPLICATION) {
- applicationService.deleteApplication(descriptor, etag);
- } else {
- boolean deleted = toolSetService.deleteToolset(context, descriptor, etag);
- if (!deleted) {
- throw new HttpException(HttpStatus.NOT_FOUND, "Resource not found: " + descriptor.getUrl());
+ switch (type) {
+ case APPLICATION -> applicationService.deleteApplication(descriptor, etag);
+ case TOOL_SET -> {
+ boolean deleted = toolSetService.deleteToolset(context, descriptor, etag);
+ if (!deleted) {
+ throw new HttpException(HttpStatus.NOT_FOUND, "Resource not found: " + descriptor.getUrl());
+ }
}
+ default -> throw new IllegalArgumentException("Unexpected resource type: " + type);
}
- mergedConfigStore.applyEntityDelete(type, MergedConfigStore.canonicalId(descriptor));
+ mergedConfigStore.applyEntityDelete(type, MergedConfigStore.mapKeyFor(descriptor));
return true;
})).onSuccess(v -> context.respond(HttpStatus.NO_CONTENT)).onFailure(this::handleWriteError);
@@ -1398,6 +1458,15 @@ private Future> handlePut() {
String oldSecret = null;
Object entity = null;
if (spec.entityClass() == null) {
+ ResourceTypes schemaType = resourceType();
+ if (schemaType == ResourceTypes.APP_TYPE_SCHEMA || schemaType == ResourceTypes.CATALOG_SCHEMA) {
+ // S3: override body.$id to match the decoded URL segment so the blob always
+ // round-trips cleanly — canonicalId = type/platform/encode($id), $id = path.
+ JsonNode idNode = requestNode.get("$id");
+ if (idNode == null || !path.equals(idNode.asText())) {
+ ((ObjectNode) requestNode).put("$id", path);
+ }
+ }
blobBody = requestNode.toString();
} else {
if (!requestNode.isObject()) {
@@ -1443,6 +1512,10 @@ private Future> handlePut() {
if (entity instanceof Model m) {
checkCrossReferences(m);
}
+ ResourceTypes writeType = resourceType();
+ if (writeType == ResourceTypes.MODEL || writeType == ResourceTypes.INTERCEPTOR) {
+ rejectDuplicateDeploymentId(writeType, path);
+ }
if (spec.isKey()) {
keyEntity = (Key) entity;
validateKeyForApiWrite(keyEntity, "PUT");
@@ -1474,7 +1547,7 @@ private Future> handlePut() {
secretFieldProcessor.decryptFields(entity, descriptor);
}
mergedConfigStore.applyEntityWrite(typeOf(descriptor),
- MergedConfigStore.canonicalId(descriptor),
+ MergedConfigStore.mapKeyFor(descriptor),
entity != null ? entity : requestNode);
return meta;
}));
@@ -1539,7 +1612,7 @@ private Future> handleDelete() {
if (deletedSecret != null) {
apiKeyStore.removeKey(deletedSecret);
}
- mergedConfigStore.applyEntityDelete(typeOf(descriptor), MergedConfigStore.canonicalId(descriptor));
+ mergedConfigStore.applyEntityDelete(typeOf(descriptor), MergedConfigStore.mapKeyFor(descriptor));
return true;
})).onSuccess(v -> context.respond(HttpStatus.NO_CONTENT)).onFailure(this::handleWriteError);
@@ -1552,6 +1625,23 @@ private static ResourceTypes typeOf(ResourceDescriptor descriptor) {
return (ResourceTypes) descriptor.getType();
}
+ /**
+ * Rejects a MODEL/INTERCEPTOR/APPLICATION/TOOL_SET write whose short name is already claimed
+ * by a different deployment (of any of those four types) in the live merged Config — the
+ * partial-update-path counterpart of {@code ConfigPostProcessor.skipOnDuplicate}, which only
+ * runs during a full rebuild. See {@link ConfigPostProcessor#isDeploymentIdTaken}.
+ */
+ private void rejectDuplicateDeploymentId(ResourceTypes type, String shortName) {
+ Config snapshot = mergedConfigStore.get();
+ if (snapshot == null) {
+ return;
+ }
+ if (ConfigPostProcessor.isDeploymentIdTaken(snapshot, type, shortName)) {
+ throw new HttpException(HttpStatus.CONFLICT,
+ "Deployment ID '" + shortName + "' is already used by a different entity");
+ }
+ }
+
private static ApiKeyData apiKeyData(Key key) {
ApiKeyData data = new ApiKeyData();
data.setOriginalKey(key);
@@ -1739,10 +1829,7 @@ private static ObjectNode redactExternalServiceSecrets(ObjectNode node) {
return node;
}
- private ObjectNode projectSchemaItem(String name, String json)
- throws JsonProcessingException {
- // applicationTypeSchemas stores raw JSON strings; parse for projection.
- JsonNode schema = ProxyUtil.MAPPER.readTree(json);
+ private ObjectNode projectSchemaItem(String name, JsonNode schema) {
ObjectNode node = ProxyUtil.MAPPER.createObjectNode();
if (schema.isObject()) {
node.setAll((ObjectNode) schema);
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java
index 0fa393f2a..5ec591e7f 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java
@@ -468,21 +468,10 @@ private ResolvedExternalService resolveExternalService(String scopeId, @Nullable
/** Resolves only the application for a scope's app part — never the owner's bucket — so the OBO gate can run first. */
private ResolvedApplication resolveApplication(String appPart) {
- // appPart carries no "applications/" type prefix (the URL is already under /v1/applications/),
- // whereas materialized Config keys do. Resolve verbatim first (config-file bare names, and —
- // once short-name addressing lands — short-name platform apps via derivation), then by
- // canonical id so a platform-bucket reference like "platform/my-app" hits its materialized
- // in-memory entry ("applications/platform/my-app"), which already carries decrypted secrets,
- // instead of being read back from blob. A config-managed (platform) app is access-controlled
- // by its userRoles, so it resolves as a static app and verifyAccess uses hasAccess(userRoles).
Deployment deployment = context.getConfig().selectDeployment(appPart);
- if (deployment == null) {
- deployment = context.getConfig().selectDeployment(CredentialsLocatorFactory.APPLICATIONS_PREFIX + appPart);
- }
if (deployment instanceof Application configApp) {
return new ResolvedApplication(configApp, null, true);
}
- // Dynamic (public / user-bucket) app — not materialized; read from blob, rule-based access.
ResourceDescriptor appDescriptor;
try {
appDescriptor = ResourceDescriptorFactory.fromAnyUrl(
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java
index de27a1e57..20021abe6 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java
@@ -274,15 +274,7 @@ private static String scopeId(String appId, String serviceId) {
}
private ResolvedApp resolveApp(String appId) {
- // See ExternalServiceCredentialsController.resolveApplication: appId omits the "applications/"
- // type prefix, so resolve verbatim first, then by canonical id so a platform-bucket app hits
- // its materialized in-memory entry ("applications/platform/my-app") — with decrypted secrets —
- // instead of being read from blob. A config-managed (platform) app resolves as a static app,
- // access-controlled by its userRoles like a config-file app rather than by folder rules.
Deployment deployment = context.getConfig().selectDeployment(appId);
- if (deployment == null) {
- deployment = context.getConfig().selectDeployment(CredentialsLocatorFactory.APPLICATIONS_PREFIX + appId);
- }
if (deployment instanceof Application configApp) {
return new ResolvedApp(configApp, null, null, true);
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java b/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java
index 0e388c36d..fc11e1a27 100644
--- a/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java
+++ b/server/src/main/java/com/epam/aidial/core/server/service/ConsentService.java
@@ -7,7 +7,6 @@
import com.epam.aidial.core.server.util.BucketBuilder;
import com.epam.aidial.core.server.util.ProxyUtil;
import com.epam.aidial.core.server.util.ResourceDescriptorFactory;
-import com.epam.aidial.core.storage.exception.ResourceNotFoundException;
import com.epam.aidial.core.storage.resource.ResourceDescriptor;
import com.epam.aidial.core.storage.resource.ResourceTypes;
import com.epam.aidial.core.storage.service.ResourceService;
diff --git a/server/src/main/java/com/epam/aidial/core/server/util/PlatformCanonicalIdUtil.java b/server/src/main/java/com/epam/aidial/core/server/util/PlatformCanonicalIdUtil.java
new file mode 100644
index 000000000..f0090cfdc
--- /dev/null
+++ b/server/src/main/java/com/epam/aidial/core/server/util/PlatformCanonicalIdUtil.java
@@ -0,0 +1,19 @@
+package com.epam.aidial.core.server.util;
+
+/**
+ * Shared helper for the canonical-id ↔ short-name relationship. Applies only to {@code platform}-bucket
+ * canonical ids, which are always exactly {@code type/platform/name} with no further nesting
+ * (enforced at the write boundary — see {@code ConfigResourceController.ENTITY_NAME_PATTERN}), so
+ * the last path segment is unambiguously the short name. A bare (slash-free) name is already its
+ * own short name.
+ */
+public final class PlatformCanonicalIdUtil {
+
+ private PlatformCanonicalIdUtil() {
+ }
+
+ public static String lastSegment(String key) {
+ int slash = key.lastIndexOf('/');
+ return slash < 0 ? key : key.substring(slash + 1);
+ }
+}
diff --git a/server/src/main/java/com/epam/aidial/core/server/util/ResourceDescriptorFactory.java b/server/src/main/java/com/epam/aidial/core/server/util/ResourceDescriptorFactory.java
index cb4222df2..5d95f4014 100644
--- a/server/src/main/java/com/epam/aidial/core/server/util/ResourceDescriptorFactory.java
+++ b/server/src/main/java/com/epam/aidial/core/server/util/ResourceDescriptorFactory.java
@@ -58,6 +58,23 @@ public static ResourceDescriptor fromDecoded(ResourceType type, String bucketNam
return from(type, bucketName, bucketLocation, elements, ResourceUtil.isFolder(path));
}
+ /**
+ * Like {@link #fromDecoded}, but treats {@code decodedName} as a single atomic resource name —
+ * never splits it on '/'. Use for identifiers (like a JSON-Schema {@code $id}) that are
+ * themselves URIs and therefore expected to contain '/', not folder-hierarchy separators.
+ * The name is escaped via {@link UrlUtil#escapeSchemaId} so the physical blob key is flat
+ * while keeping human-readable URI characters like ':' intact.
+ */
+ public static ResourceDescriptor fromDecodedAtomicName(ResourceType type, String bucketName,
+ String bucketLocation, String decodedName) {
+ verify(bucketLocation.endsWith(ResourceDescriptor.PATH_SEPARATOR), "Bucket location must end with /");
+ String physicalName = UrlUtil.escapeSchemaId(decodedName);
+ ResourceDescriptor resource = from(type, bucketName, bucketLocation, List.of(physicalName), false);
+ verify(resource.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8).length <= MAX_PATH_SIZE,
+ "Resource path exceeds max allowed size: " + MAX_PATH_SIZE);
+ return resource;
+ }
+
public static ResourceDescriptor fromPublicUrl(String url) {
return fromUrl(url, ResourceDescriptor.PUBLIC_BUCKET, ResourceDescriptor.PUBLIC_LOCATION, null);
}
diff --git a/server/src/test/java/com/epam/aidial/core/server/CanonicalIdListingTest.java b/server/src/test/java/com/epam/aidial/core/server/CanonicalIdListingTest.java
index 4a13e3c61..e748d1de7 100644
--- a/server/src/test/java/com/epam/aidial/core/server/CanonicalIdListingTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/CanonicalIdListingTest.java
@@ -3,15 +3,16 @@
import io.vertx.core.http.HttpMethod;
import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * HTTP integration tests for slice 2S.15 + Polish.1 (2026-05-08): canonical IDs surface as the
- * {@code id}/{@code model} fields on the legacy {@code /openai/models} and {@code /openai/deployments}
- * listings for API-managed entries, and on the admin Configuration API
- * ({@code /v1/{type}/{bucket}/...}) GET + listing projection. File-sourced entries continue to
- * surface their simple names. Locks the OQ-23 + Polish.1 contract that clients can copy a
- * listing's identifier verbatim into per-entity URLs.
+ * HTTP integration tests locking the short-name-addressing contract: the {@code id}/{@code model}
+ * fields on the legacy {@code /openai/models} and {@code /openai/deployments} listings surface
+ * the short name (last path segment) for API-managed entries, matching file-sourced entries —
+ * never the canonical id. The admin Configuration API ({@code /v1/{type}/{bucket}/...}) GET is
+ * blob-storage-backed directly (not the in-memory map), and also projects the short name — the
+ * URL's own {@code name} segment — for the same reason.
*/
public class CanonicalIdListingTest extends ResourceBaseTest {
@@ -23,27 +24,31 @@ public class CanonicalIdListingTest extends ResourceBaseTest {
""";
@Test
- void testApiManagedModelSurfacedAsCanonicalIdInOpenAiModels() {
+ void testApiManagedModelSurfacedAsShortNameInOpenAiModels() {
verify(send(HttpMethod.PUT, "/v1/models/platform/canonical-test", null, API_MODEL_BODY,
"authorization", "admin", "If-None-Match", "*"), 200);
Response list = send(HttpMethod.GET, "/openai/models", null, "");
verify(list, 200);
- assertTrue(list.body().contains("\"id\":\"models/platform/canonical-test\""),
- () -> "Expected canonical id for API-managed model: " + list.body());
- assertTrue(list.body().contains("\"model\":\"models/platform/canonical-test\""),
- () -> "Expected canonical model field for API-managed model: " + list.body());
+ assertTrue(list.body().contains("\"id\":\"canonical-test\""),
+ () -> "Expected short name for API-managed model: " + list.body());
+ assertTrue(list.body().contains("\"model\":\"canonical-test\""),
+ () -> "Expected short name model field for API-managed model: " + list.body());
+ assertFalse(list.body().contains("models/platform/canonical-test"),
+ () -> "Canonical id must not leak into the outbound listing: " + list.body());
}
@Test
- void testApiManagedModelSurfacedAsCanonicalIdInOpenAiDeployments() {
+ void testApiManagedModelSurfacedAsShortNameInOpenAiDeployments() {
verify(send(HttpMethod.PUT, "/v1/models/platform/canonical-deployments", null, API_MODEL_BODY,
"authorization", "admin", "If-None-Match", "*"), 200);
Response list = send(HttpMethod.GET, "/openai/deployments", null, "");
verify(list, 200);
- assertTrue(list.body().contains("\"id\":\"models/platform/canonical-deployments\""),
- () -> "Expected canonical id for API-managed deployment: " + list.body());
+ assertTrue(list.body().contains("\"id\":\"canonical-deployments\""),
+ () -> "Expected short name for API-managed deployment: " + list.body());
+ assertFalse(list.body().contains("models/platform/canonical-deployments"),
+ () -> "Canonical id must not leak into the outbound listing: " + list.body());
}
@Test
@@ -58,17 +63,17 @@ void testFileSourcedModelStillSurfacedAsSimpleName() {
}
@Test
- void testApiManagedModelAdminGetProjectsCanonicalId() {
- // Polish.1 (2026-05-08): admin GET projects the canonical ID for API-managed entries so
- // operators can copy-paste the identifier verbatim. File-sourced entries keep their simple
- // name. Under U.0 the per-entity GET still projects the canonical ID.
+ void testApiManagedModelAdminGetProjectsShortName() {
+ // Admin GET reads blob storage directly by descriptor (not the in-memory map), so it
+ // projects the URL's own short-name segment, matching how the entity is keyed in Config
+ // for runtime resolution.
verify(send(HttpMethod.PUT, "/v1/models/platform/admin-listing-projection", null, API_MODEL_BODY,
"authorization", "admin", "If-None-Match", "*"), 200);
Response single = send(HttpMethod.GET, "/v1/models/platform/admin-listing-projection", null, "",
"authorization", "admin");
verify(single, 200);
- assertTrue(single.body().contains("\"name\":\"models/platform/admin-listing-projection\""),
- () -> "Admin GET must project canonical ID for API entries: " + single.body());
+ assertTrue(single.body().contains("\"name\":\"admin-listing-projection\""),
+ () -> "Admin GET must project the short name for API entries: " + single.body());
}
}
diff --git a/server/src/test/java/com/epam/aidial/core/server/MergedConfigStoreApiTest.java b/server/src/test/java/com/epam/aidial/core/server/MergedConfigStoreApiTest.java
index b088e1891..0481ab940 100644
--- a/server/src/test/java/com/epam/aidial/core/server/MergedConfigStoreApiTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/MergedConfigStoreApiTest.java
@@ -11,6 +11,8 @@
import io.vertx.core.http.HttpMethod;
import org.junit.jupiter.api.Test;
+import java.net.URI;
+
import static com.epam.aidial.core.server.util.ResourceDescriptorFactory.fromDecoded;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -70,21 +72,20 @@ void testBlobModelSurfacesAfterReload() {
assertEquals(200, reload.status());
Config merged = dial.getProxy().getConfigStore().get();
- Model blobModel = merged.getModels().get("models/platform/" + blobName);
- assertNotNull(blobModel, () -> "Expected canonical-ID key in merged Config: " + merged.getModels().keySet());
- // Slice 2S.15 / OQ-23: Model.name carries the canonical ID for API-managed entries so
- // legacy /openai/models, /openai/deployments, and rate-limit role-limit lookups see the
- // canonical form. Polish.1 (2026-05-08) extends this to the admin Configuration API GET
- // / listing projection — canonical ID for API entries, simple name for file entries.
- assertEquals("models/platform/" + blobName, blobModel.getName(),
- "Entity.name carries the canonical ID for API-managed entries");
- assertNotNull(merged.getModels().get("test-model-v1"), "File model must still coexist by simple name");
+ Model blobModel = merged.getModels().get(blobName);
+ assertNotNull(blobModel, () -> "Expected short-name key in merged Config: " + merged.getModels().keySet());
+ // Model.name is the map key — the short name, the same key a file entry for this
+ // logical entity would use.
+ assertEquals(blobName, blobModel.getName(), "Entity.name is the short name for API-managed entries too");
+ assertNotNull(merged.getModels().get("test-model-v1"), "File model must still coexist by its own short name");
+ // Admin GET reads blob storage directly (not the in-memory map), so the projected name is
+ // whatever the URL's short-name segment was.
Response get = send(HttpMethod.GET, "/v1/models/platform/" + blobName, null, "",
"authorization", "admin");
verify(get, 200);
- assertTrue(get.body().contains("\"name\":\"models/platform/" + blobName + "\""),
- () -> "Expected canonical name in projection: " + get.body());
+ assertTrue(get.body().contains("\"name\":\"" + blobName + "\""),
+ () -> "Expected short name in projection: " + get.body());
assertTrue(get.body().contains("\"endpoint\""),
() -> "Expected endpoint field in projection: " + get.body());
}
@@ -104,13 +105,158 @@ void testBlobInterceptorSurfacesAfterReload() {
assertEquals(200, reload.status());
Config merged = dial.getProxy().getConfigStore().get();
- Interceptor blob = merged.getInterceptors().get("interceptors/platform/" + blobName);
- assertNotNull(blob, () -> "Expected canonical-ID key in merged Config: " + merged.getInterceptors().keySet());
- // Slice 2S.15: API-managed entries carry the canonical ID as their name (per OQ-23).
- assertEquals("interceptors/platform/" + blobName, blob.getName());
+ Interceptor blob = merged.getInterceptors().get(blobName);
+ assertNotNull(blob, () -> "Expected short-name key in merged Config: " + merged.getInterceptors().keySet());
+ assertEquals(blobName, blob.getName());
assertNotNull(merged.getInterceptors().get("interceptor1"), "File interceptor must still coexist");
}
+ @Test
+ void testBlobModelOverwritesFileEntryAtSameShortNameAfterReload() {
+ // A blob model written under the SAME short name as an existing file-sourced model shares
+ // that single map key with it — both sources key by short name uniformly, so the blob
+ // write simply overwrites the slot in place; there's no separate canonical-id slot left
+ // over to shadow.
+ String shortName = "test-model-v1";
+ String body = """
+ {
+ "type": "chat",
+ "displayName": "Migrated Test Model",
+ "endpoint": "http://localhost:7001/openai/deployments/migrated/chat/completions"
+ }
+ """;
+ putBlob(ResourceTypes.MODEL, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ shortName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ Model model = merged.getModels().get(shortName);
+ assertNotNull(model);
+ assertEquals("http://localhost:7001/openai/deployments/migrated/chat/completions", model.getEndpoint(),
+ "Blob entity must win over the file entry sharing its short name");
+ assertEquals(model, merged.selectDeployment(shortName),
+ "selectDeployment must resolve the short name directly to the blob entity");
+ }
+
+ @Test
+ void testBlobInterceptorOverwritesFileEntryAtSameShortNameAfterReload() {
+ String shortName = "interceptor1";
+ String body = """
+ {
+ "endpoint": "http://localhost:9000/migrated-intercept"
+ }
+ """;
+ putBlob(ResourceTypes.INTERCEPTOR, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ shortName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ Interceptor interceptor = merged.getInterceptors().get(shortName);
+ assertNotNull(interceptor);
+ assertEquals("http://localhost:9000/migrated-intercept", interceptor.getEndpoint(),
+ "Blob entity must win over the file entry sharing its short name");
+ }
+
+ @Test
+ void testBlobRoleOverwritesFileEntryAtSameShortNameAfterReload() {
+ String shortName = "default";
+ String body = """
+ {
+ "limits": {}
+ }
+ """;
+ putBlob(ResourceTypes.ROLE, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ shortName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ assertNotNull(merged.getRoles().get(shortName));
+ assertEquals(shortName, merged.getRoles().get(shortName).getName());
+ }
+
+ @Test
+ void testBlobApplicationOverwritesFileEntryAtSameShortNameAfterReload() {
+ String shortName = "app";
+ String body = """
+ {
+ "endpoint": "http://application1/v1/completions",
+ "display_name": "Migrated Platform App"
+ }
+ """;
+ putBlob(ResourceTypes.APPLICATION, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ shortName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ assertNotNull(merged.getApplications().get(shortName));
+ assertEquals(merged.getApplications().get(shortName), merged.selectDeployment(shortName),
+ "selectDeployment must resolve the short name directly to the blob entity");
+ }
+
+ @Test
+ void testBlobToolSetOverwritesFileEntryAtSameShortNameAfterReload() {
+ String shortName = "git";
+ String body = """
+ {
+ "endpoint": "http://localhost:9876",
+ "transport": "HTTP",
+ "display_name": "Migrated Git Toolset"
+ }
+ """;
+ putBlob(ResourceTypes.TOOL_SET, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ shortName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ assertNotNull(merged.getToolsets().get(shortName));
+ assertEquals(merged.getToolsets().get(shortName), merged.selectDeployment(shortName),
+ "selectDeployment must resolve the short name directly to the blob entity");
+ }
+
+ @Test
+ void testBlobAppTypeSchemaShadowsFileEntryByIdAfterReload() {
+ // App-type/catalog schemas are keyed by $id (file) vs. canonical id (blob), so the
+ // blob-shadows-file removal used for name-addressed types (by short name) doesn't apply
+ // directly — instead, the migrated blob entity's own $id is used to remove the file entry
+ // sharing it, so $id-keyed listings (ApplicationTypeSchemaController) don't surface the
+ // schema twice.
+ String fileSchemaId = "https://mydial.somewhere.com/custom_application_schemas/specific_application_type";
+ String blobName = "blob-schema-1";
+ String body = """
+ {
+ "$schema": "https://dial.epam.com/application_type_schemas/schema#",
+ "$id": "%s",
+ "display_name": "Blob-migrated schema"
+ }
+ """.formatted(fileSchemaId);
+ putBlob(ResourceTypes.APP_TYPE_SCHEMA, ResourceDescriptor.PLATFORM_BUCKET, ResourceDescriptor.PLATFORM_LOCATION,
+ blobName, body);
+
+ Response reload = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
+ assertEquals(200, reload.status());
+
+ Config merged = dial.getProxy().getConfigStore().get();
+ String canonicalId = "schemas/platform/" + blobName;
+ assertTrue(merged.getApplicationTypeSchemas().containsKey(canonicalId),
+ () -> "Expected canonical-ID key in merged Config: " + merged.getApplicationTypeSchemas().keySet());
+ assertFalse(merged.getApplicationTypeSchemas().containsKey(fileSchemaId),
+ () -> "File entry keyed by $id must be shadowed by the migrated blob entity: "
+ + merged.getApplicationTypeSchemas().keySet());
+ assertEquals(merged.getApplicationTypeSchemas().get(canonicalId),
+ merged.getCustomApplicationSchema(URI.create(fileSchemaId)),
+ "getCustomApplicationSchema must still resolve the $id via the alias index");
+ }
+
@Test
void testReloadConfigSucceedsUnderMergedStore() {
Response resp = operationRequest("/v1/ops/config/reload", null, "Authorization", "admin");
diff --git a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
index 5744de508..0b17b8588 100644
--- a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
@@ -42,8 +42,8 @@ void testApplicationPutGetDeleteRoundTrip() {
Response get = send(HttpMethod.GET, "/v1/applications/platform/my-platform-app", null, "",
"authorization", "admin");
verify(get, 200);
- assertTrue(get.body().contains("\"name\":\"applications/platform/my-platform-app\""),
- () -> "Expected canonical name in body: " + get.body());
+ assertTrue(get.body().contains("\"name\":\"my-platform-app\""),
+ () -> "Expected short name in body: " + get.body());
assertTrue(get.body().contains("\"endpoint\":\"http://application1/v1/completions\""),
() -> "Expected endpoint in body: " + get.body());
@@ -66,8 +66,8 @@ void testToolSetPutGetDeleteRoundTrip() {
Response get = send(HttpMethod.GET, "/v1/toolsets/platform/my-platform-toolset", null, "",
"authorization", "admin");
verify(get, 200);
- assertTrue(get.body().contains("\"name\":\"toolsets/platform/my-platform-toolset\""),
- () -> "Expected canonical name in body: " + get.body());
+ assertTrue(get.body().contains("\"name\":\"my-platform-toolset\""),
+ () -> "Expected short name in body: " + get.body());
assertTrue(get.body().contains("\"endpoint\":\"http://localhost:9876\""),
() -> "Expected endpoint in body: " + get.body());
@@ -193,7 +193,7 @@ void testApplicationExternalServiceSecretNeverLeaksOnGet() {
}
@Test
- void testPlatformAppExternalServiceAccessAllowedByUserRoles() throws Exception {
+ void testPlatformAppExternalServiceAccessAllowedByUserRoles() {
// Regression (#1773 review): external-service access on a platform app must go through the
// app's userRoles like a config app, not folder rules. This app is open (no user_roles), so a
// non-admin user can sign in; before the fix the folder-rules branch returned 403.
@@ -225,7 +225,7 @@ void testPlatformAppExternalServiceAccessAllowedByUserRoles() throws Exception {
try (TestWebServer ignore = new TestWebServer(9876, handler)) {
Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """
{
- "url": "applications/platform/extsvc-open-app/external_services/svc1",
+ "url": "applications/extsvc-open-app/external_services/svc1",
"credentials_level": "USER",
"authentication_type": "OAUTH",
"code": "auth-code"
@@ -264,7 +264,7 @@ void testPlatformAppExternalServiceDeniedWithoutUserRole() {
Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """
{
- "url": "applications/platform/extsvc-restricted-app/external_services/svc1",
+ "url": "applications/extsvc-restricted-app/external_services/svc1",
"credentials_level": "USER",
"authentication_type": "OAUTH",
"code": "auth-code"
diff --git a/server/src/test/java/com/epam/aidial/core/server/config/ConfigPostProcessorTest.java b/server/src/test/java/com/epam/aidial/core/server/config/ConfigPostProcessorTest.java
index 255b42403..8d0ea89f4 100644
--- a/server/src/test/java/com/epam/aidial/core/server/config/ConfigPostProcessorTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/config/ConfigPostProcessorTest.java
@@ -94,23 +94,21 @@ void testSemanticSkipRoutesDuplicateToCallback() {
}
@Test
- void testSemanticKeepsCanonicalIdKeyedToolSet() {
- // Materialized platform toolsets are keyed by canonical id ("toolsets/platform/name"), unlike
- // file-sourced ones (bare "name") — processToolSets must validate only the trailing short-name
- // segment, not reject the whole key for containing '/'.
+ void testSemanticDropsCanonicalIdKeyedToolSet() {
+ // Toolsets in Config are now keyed by short name only; a canonical-id-shaped key
+ // ("toolsets/platform/name") fails isValidToolSetKey and is dropped.
Config config = newMutableConfig();
config.getToolsets().put("toolsets/platform/my-toolset", new ToolSet());
ConfigPostProcessor.processSemantic(config, null, Map.of(), Map.of(), null);
- assertTrue(config.getToolsets().containsKey("toolsets/platform/my-toolset"));
- assertEquals("toolsets/platform/my-toolset", config.getToolsets().get("toolsets/platform/my-toolset").getName());
+ assertTrue(config.getToolsets().isEmpty());
}
@Test
- void testSemanticDropsCanonicalIdToolSetWithInvalidShortName() {
+ void testSemanticDropsToolSetWithInvalidName() {
Config config = newMutableConfig();
- config.getToolsets().put("toolsets/platform/bad name", new ToolSet());
+ config.getToolsets().put("bad name", new ToolSet());
ConfigPostProcessor.processSemantic(config, null, Map.of(), Map.of(), null);
diff --git a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStorePartialUpdateTest.java b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStorePartialUpdateTest.java
index 0ccb3c871..64ab9cfce 100644
--- a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStorePartialUpdateTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStorePartialUpdateTest.java
@@ -98,18 +98,19 @@ public void interceptorDeleteCascadesToModelInvalidEntities() {
}
@Test
- public void cascadeClassifiesSourceByMapKeyShape() {
- // Two models reference the same interceptor: one file-defined (bare simple-name key) and one
- // API-defined (canonical-id key). Deleting the interceptor cross-ref-invalidates both; the
- // recorded source must follow the key shape, mirroring the full rebuild() onSkip classifier.
- Model fileModel = new Model();
- fileModel.setInterceptors(List.of(INTERCEPTOR_ID));
- Model apiModel = new Model();
- apiModel.setInterceptors(List.of(INTERCEPTOR_ID));
+ public void cascadeClassifiesInvalidatedModelsAsApiSourced() {
+ // Models are keyed by short name uniformly now (file- and blob-sourced alike), so key
+ // shape can no longer tell them apart. The partial-update path never touches file config
+ // either way, so every survivor this cascade walks is classified "api" — regardless of
+ // which source the model itself originally came from.
+ Model firstModel = new Model();
+ firstModel.setInterceptors(List.of(INTERCEPTOR_ID));
+ Model secondModel = new Model();
+ secondModel.setInterceptors(List.of(INTERCEPTOR_ID));
Config seeded = newConfig();
Map models = new LinkedHashMap<>();
- models.put("gpt-4-file", fileModel); // bare simple name → file-sourced
- models.put(MODEL_ID, apiModel); // contains '/' → api-sourced
+ models.put("gpt-4-first", firstModel);
+ models.put("gpt-4-second", secondModel);
seeded.setModels(models);
seeded.setInterceptors(mutable(INTERCEPTOR_ID, new Interceptor()));
MergedConfigStore store = initStore(seeded, MergedConfigStore.MODE_SKIP);
@@ -119,13 +120,15 @@ public void cascadeClassifiesSourceByMapKeyShape() {
Map invalidModels = store.getInvalidEntities().get(ResourceTypes.MODEL);
assertEquals(2, invalidModels.size(), "both cross-ref-invalidated models recorded");
- InvalidEntityRecord fileRecord = invalidModels.get("models/platform/gpt-4-file");
- assertEquals("file", fileRecord.getSource(), "bare-key model attributed to file source");
- assertEquals("gpt-4-file", fileRecord.getSimpleName());
+ // invalidEntities is always keyed by (derived) canonical id, regardless of source —
+ // unaffected by short-name keying, which only concerns Config's live entity maps.
+ InvalidEntityRecord firstRecord = invalidModels.get("models/platform/gpt-4-first");
+ assertEquals("api", firstRecord.getSource());
+ assertEquals("gpt-4-first", firstRecord.getSimpleName());
- InvalidEntityRecord apiRecord = invalidModels.get(MODEL_ID);
- assertEquals("api", apiRecord.getSource(), "canonical-id-key model attributed to api source");
- assertEquals("gpt-4", apiRecord.getSimpleName());
+ InvalidEntityRecord secondRecord = invalidModels.get("models/platform/gpt-4-second");
+ assertEquals("api", secondRecord.getSource());
+ assertEquals("gpt-4-second", secondRecord.getSimpleName());
}
@Test
diff --git a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreReplicaUpdateTest.java b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreReplicaUpdateTest.java
index 85fe46866..a176756f0 100644
--- a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreReplicaUpdateTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreReplicaUpdateTest.java
@@ -370,7 +370,7 @@ public void modelCreateFetchesDecryptsAndAppliesEntity() {
store.applyReplicaEvent(descriptor, ResourceEvent.Action.CREATE);
- Model applied = store.get().getModels().get(MODEL_ID);
+ Model applied = store.get().getModels().get(descriptor.getName());
assertEquals("GPT-4", applied.getDisplayName().getPlainValue());
verify(secretFieldProcessor).decryptFields(any(Model.class), eq(descriptor));
verifyNoInteractions(apiKeyStore);
diff --git a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreTest.java b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreTest.java
index dc85db3b4..260922f11 100644
--- a/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/config/MergedConfigStoreTest.java
@@ -124,7 +124,7 @@ vertx, taskExecutor, resourceService, apiKeyStore, new PlatformEntityLocationStr
store.init(fileConfigStore);
Config config = store.get();
- Application materialized = config.getApplications().get("applications/platform/my-app");
+ Application materialized = config.getApplications().get("my-app");
assertEquals("http://localhost/completions", materialized.getEndpoint());
verify(externalServiceService).decryptSecrets(
argThat(d -> "platform".equals(d.getBucketName())), eq(materialized));
@@ -147,7 +147,7 @@ vertx, taskExecutor, resourceService, apiKeyStore, new PlatformEntityLocationStr
store.init(fileConfigStore);
Config config = store.get();
- ToolSet materialized = config.getToolsets().get("toolsets/platform/my-toolset");
+ ToolSet materialized = config.getToolsets().get("my-toolset");
assertEquals("http://localhost:9876", materialized.getEndpoint());
verify(resourceAuthSettingsEncryptionService).decrypt(
eq(toolSetDescriptor.getUrl()), any(BucketInfo.class), eq(materialized.getAuthSettings()));
diff --git a/storage/src/main/java/com/epam/aidial/core/storage/util/UrlUtil.java b/storage/src/main/java/com/epam/aidial/core/storage/util/UrlUtil.java
index b5a87f965..ca1d6a4b6 100644
--- a/storage/src/main/java/com/epam/aidial/core/storage/util/UrlUtil.java
+++ b/storage/src/main/java/com/epam/aidial/core/storage/util/UrlUtil.java
@@ -32,6 +32,25 @@ public String encodePathSegment(@Nullable String segment) {
return ENCODER.escape(segment);
}
+ /**
+ * Escapes a JSON Schema {@code $id} so it can be stored as a flat blob-storage key: replaces
+ * {@code %} with {@code %25} first, then {@code /} with {@code %2F}. The result contains no
+ * literal {@code /} characters while preserving all other URI characters (e.g. {@code :}).
+ * Inverse: {@link #unescapeSchemaId}.
+ */
+ public static String escapeSchemaId(String schemaId) {
+ return schemaId.replace("%", "%25").replace("/", "%2F");
+ }
+
+ /**
+ * Reverses {@link #escapeSchemaId}: replaces {@code %2F} with {@code /} first, then
+ * {@code %25} with {@code %}. The reversed order ensures a literal {@code %2F} in the
+ * original value (stored as {@code %252F}) is restored correctly.
+ */
+ public static String unescapeSchemaId(String escaped) {
+ return escaped.replace("%2F", "/").replace("%25", "%");
+ }
+
public String encodePath(@Nullable String path) {
if (path == null) {
return null;