diff --git a/CHANGELOG.md b/CHANGELOG.md index 2291405d..f912a0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Authoritative provider routing plan (PR #229).** Introduced `ProviderRoutingPlan` in `tramai-core` — the single immutable snapshot of configured provider routing (`providers`, `routes`, `defaultProvider`) with typed `ProviderId`/`ModelId` value classes and fail-fast build-time validation. Duplicate provider registration now fails with `ConfigurationException` instead of silently replacing the earlier registration; blank IDs, unknown primary/fallback providers, duplicate fallback routes, and unknown defaults are rejected at construction. `ProviderRegistry` remains a public compatibility façade over the plan with unchanged API and resolution order (`ProviderRoute`/`ResolvedProviderRoute` JVM shapes unchanged). The engine freezes the plan into `EngineComponents.ProviderComponents`; standalone composes through the plan builder; `SovereignTramai.Builder` deleted its shadow routing state (`registeredProviders`, `primaryModelRoutes`, `fallbackRoutes`, `defaultProviderName`, `FallbackRoute`) and now validates the shared plan via `SovereignRoutingValidationPolicy`; Spring resolves property-provider vs bean precedence into one unique provider set before the plan builder (explicit beans still override property-backed providers; genuine duplicate user beans fail deterministically). Routing-related sovereign evidence and artifact-verification targets derive from the same frozen plan. Epic 2.2 is complete. + - Refactored the engine's internal runtime composition into an immutable component snapshot; zero public API change and no execution-semantic changes, except the intentional fail-fast rejection of invalid partial approval composition at the engine component boundary. - **Explicit runtime lifecycle ownership (PR #226).** `Tramai` and `SovereignTramai` are now `AutoCloseable` and own exactly one lazily-created runtime (one engine) shared by every `create()`/`runtime()` call — previously every `create()` leaked an unreachable engine. Closing is idempotent and concurrency-safe; after close, `create()`/`runtime()` and old proxies fail fast with a fixed `IllegalStateException` before any provider work. `TramaiEngine.close()` cancels once and awaits engine-hierarchy termination (self-close safe), and terminates in-flight suspend invocations; the caller continuation is always resumed exactly once. Spring closes the shared runtime via `destroyMethod = "close"`, so multiple `@AiService` beans share one owned engine. TramAI closes only resources it creates; externally supplied providers/stores/clients/observers remain caller-owned. API surface addition is additive: `Tramai`/`SovereignTramai` gain `close()`; all constructor descriptors remain byte-identical to 0.5.0 (note: adding the `AutoCloseable` supertype is source-compatible but affects compiled negative-`instanceof` checks). Epic 1.3 Runtime Lifecycle Ownership is complete. diff --git a/config/quality/maintainability-deviations.yml b/config/quality/maintainability-deviations.yml index 52f1b56e..c1b8b230 100644 --- a/config/quality/maintainability-deviations.yml +++ b/config/quality/maintainability-deviations.yml @@ -50,8 +50,8 @@ deviations: metric: globalMutableState scope: ":tramai-core" baseline: 3 - allowed: 3 - reason: "Provider registration is inherently global. Migration to injectable registry planned for 0.7.0." + allowed: 4 + reason: "Provider registration is inherently global. Migration to injectable registry planned for 0.7.0. PR #229's new ProviderRoutingPlan adds two mutable-collection findings in its builder/validation path (the per-model fallback staging list and the duplicate-detection set), matching the pre-existing mutable-collection pattern in the same module; resolved in the 0.7.0 registry migration." acceptedAt: "2026-07-18" targetPhase: "0.7.0" owner: "GionaGranchelli" diff --git a/docs/ROADMAP-0.6.0.md b/docs/ROADMAP-0.6.0.md index 2355c914..c908e690 100644 --- a/docs/ROADMAP-0.6.0.md +++ b/docs/ROADMAP-0.6.0.md @@ -460,34 +460,44 @@ The exact API may differ, but each group must have one responsibility and explic ## Epic 2.2: Create one provider-routing plan +> **Status:** ✅ Complete — PR #229 (refactor(routing): introduce authoritative provider routing plan). + **Goal:** Eliminate shadow configuration across standalone, sovereign, Spring, and provider-registry builders. -### Proposed model +### Implemented model + +`ProviderRoutingPlan` is the single frozen source of configured provider routing: ```kotlin -data class ProviderRoutingPlan( +@JvmInline value class ProviderId(val value: String) +@JvmInline value class ModelId(val value: String) +data class PlannedProviderRoute(val providerId: ProviderId, val effectiveModelId: ModelId) + +class ProviderRoutingPlan private constructor( val providers: Map, - val routes: Map>, + val routes: Map>, val defaultProvider: ProviderId?, ) ``` +`ProviderRegistry` is a compatibility façade over the plan (existing public API preserved; additive routing-plan APIs introduced — `ProviderRoutingPlan`, `ProviderId`/`ModelId`, `PlannedProviderRoute`, `ProviderRegistry.from(...)`, `ProviderRegistry.routingPlan`, `Tramai.Builder.buildRoutingPlan()`). The engine freezes the plan into `ProviderComponents`; standalone composes through the plan builder; sovereign validates the same plan via `SovereignRoutingValidationPolicy` (no shadow maps); Spring resolves bean-over-property precedence before the plan builder so the canonical model never observes a duplicate. + ### Tasks -1. Add typed provider and model identifiers or validated value classes. -2. Make duplicate provider registration fail rather than silently replace. -3. Validate blank names, unknown providers, duplicate routes, invalid defaults, and fallback loops during construction. -4. Expose an immutable routing-plan snapshot for validation and evidence generation. -5. Apply additional sovereign constraints as validation policies over the same plan. -6. Make Spring construct the same routing plan rather than reimplementing route logic. -7. Remove sovereign builder shadow maps after migration. +1. ✅ Add typed provider and model identifiers or validated value classes (`ProviderId`, `ModelId`). +2. ✅ Make duplicate provider registration fail rather than silently replace (fail-fast at plan build). +3. ✅ Validate blank names, unknown providers, duplicate routes, invalid defaults, and degenerate route structures during construction. (Deliberately NOT recursive fallback routing/graph-cycle detection: `fallbackProvider()` keeps the same effective model on another provider and is not a self-loop.) +4. ✅ Expose an immutable routing-plan snapshot for validation and evidence generation. +5. ✅ Apply additional sovereign constraints as validation policies over the same plan (`SovereignRoutingValidationPolicy`). +6. ✅ Make Spring construct the same routing plan rather than reimplementing route logic (property providers + beans merged into one unique set; no Spring-side route validator). +7. ✅ Remove sovereign builder shadow maps after migration (`registeredProviders`, `primaryModelRoutes`, `fallbackRoutes`, `defaultProviderName`, `FallbackRoute` deleted). ### Acceptance criteria -- One authoritative object represents configured provider routing. -- Standalone and sovereign modes differ through validation policy, not duplicated state. -- Evidence generation and runtime execution consume the same immutable plan. -- Invalid routes fail before service creation. +- ✅ One authoritative object represents configured provider routing. +- ✅ Standalone and sovereign modes differ through validation policy, not duplicated state. +- ✅ Evidence generation and runtime execution consume the same immutable plan. +- ✅ Invalid routes fail before service creation. --- diff --git a/docs/journal/2026-08-14-provider-routing-plan.md b/docs/journal/2026-08-14-provider-routing-plan.md new file mode 100644 index 00000000..e3d5d3bc --- /dev/null +++ b/docs/journal/2026-08-14-provider-routing-plan.md @@ -0,0 +1,25 @@ +# Implementation Status — 2026-08-14 + +## What's Been Implemented + +PR #229: **refactor(routing): introduce authoritative provider routing plan** (Epic 2.2 complete). + +- `ProviderRoutingPlan` in tramai-core — single immutable snapshot of providers/routes/defaultProvider, typed `ProviderId`/`ModelId` value classes, fail-fast build-time validation (duplicates, blank IDs, unknown providers, duplicate fallbacks, unknown defaults). +- `ProviderRegistry` reduced to a compatibility façade over the plan (public API + `ProviderRoute`/`ResolvedProviderRoute` JVM shapes unchanged, additive API dump only). +- Engine freezes the plan into `EngineComponents.ProviderComponents`; `TramaiInvocationHandler` resolves candidates from the plan. +- Standalone `Tramai.Builder` mutates the canonical plan builder; validates+freezes once at build. +- Sovereign shadow state deleted (`registeredProviders`, `primaryModelRoutes`, `fallbackRoutes`, `defaultProviderName`, `FallbackRoute`); `SovereignRoutingValidationPolicy` validates the shared plan (incl. offline LOCAL constraints); artifact-verification targets derive from the plan. +- Spring merges property providers + `ModelProvider` beans into one unique set (bean overrides same-id property provider) before the plan builder; genuine duplicate user beans fail deterministically. No Spring-side route validator. +- Docs: ROADMAP-0.6.0.md (Epic 2.2 ✅ Complete), CHANGELOG.md, tramai-spring.md, tramai-sovereign.md. + +## What's Missing / Blocked + +- Nothing for Epic 2.2. P3 note from agy review (close abandoned `Tramai` if sovereign validation throws) deferred — engine is lazily created, no active leak. +- Pre-existing `examples:governed-workflow` apiCheck drift on master (unrelated to this PR; `buildGovernedNetworkPolicyWorkflow` never dumped). + +## Current State + +- Branch `refactor/0.6.0-provider-routing-plan`, pushed (fix-round commits applied after initial review). +- Local gates: 757 module tests / 0 failures, apiCheck (tramai modules) PASS, verifyPr PASS (268 tasks), verifyCancellationSafety PASS (292=292). +- agy review: merge-ready, 0 P1/P2. +- PR #229 open: https://github.com/GionaGranchelli/tramAI/pull/229 — CI running, awaiting review. diff --git a/docs/modules/tramai-sovereign.md b/docs/modules/tramai-sovereign.md index 7617dac6..160267e7 100644 --- a/docs/modules/tramai-sovereign.md +++ b/docs/modules/tramai-sovereign.md @@ -130,7 +130,7 @@ val receipts = tramai.verificationReceipts() ## Build-Time Validation -`SovereignTramai.Builder.build()` validates at construction time: +`SovereignTramai.Builder.build()` validates the immutable provider routing plan at construction time: - Profile configuration is present - Model registry is present diff --git a/docs/modules/tramai-spring.md b/docs/modules/tramai-spring.md index da105164..af0df4ad 100644 --- a/docs/modules/tramai-spring.md +++ b/docs/modules/tramai-spring.md @@ -283,10 +283,11 @@ The module is activated by either: │ 1. Binds TramaiProperties from application.yml │ 2. Resolves secret values (env, file, Vault, AWS) │ 3. Calls AiToolScanner.fromApplicationContext() → discovers @AiTool beans - │ 4. Registers property-defined providers (Anthropic, OpenAI, Ollama, OpenAI-compatible) + │ 4. Resolves property-defined providers (Anthropic, OpenAI, Ollama, OpenAI-compatible) │ 5. Collects user-defined ModelProvider beans via ObjectProvider - │ 6. Configures model routing, fallbacks, cache, interceptors - │ 7. Builds and returns Tramai instance + │ 6. Merges providers by id; an explicit bean replaces a same-id property provider + │ 7. Registers the unique provider set, then configures model routing, fallbacks, cache, interceptors + │ 8. Builds and returns Tramai instance │ └─ @Bean "aiServiceBeanDefinitionRegistrar" (AiServiceBeanDefinitionRegistrar) implements BeanDefinitionRegistryPostProcessor diff --git a/tramai-core/api/tramai-core.api b/tramai-core/api/tramai-core.api index e60a0b4f..4c5c5a3c 100644 --- a/tramai-core/api/tramai-core.api +++ b/tramai-core/api/tramai-core.api @@ -1995,6 +1995,20 @@ public final class dev/tramai/core/provider/BoundedProviderErrorBody { public fun toString ()Ljava/lang/String; } +public final class dev/tramai/core/provider/ModelId { + public static final synthetic fun box-impl (Ljava/lang/String;)Ldev/tramai/core/provider/ModelId; + public static fun constructor-impl (Ljava/lang/String;)Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public static fun hashCode-impl (Ljava/lang/String;)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Ljava/lang/String; +} + public abstract interface class dev/tramai/core/provider/ModelProvider { public abstract fun complete (Ldev/tramai/core/model/ModelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun providerId ()Ljava/lang/String; @@ -2006,6 +2020,19 @@ public final class dev/tramai/core/provider/ModelProvider$DefaultImpls { public static fun supportsCapability (Ldev/tramai/core/provider/ModelProvider;Ldev/tramai/core/provider/ProviderCapability;)Z } +public final class dev/tramai/core/provider/PlannedProviderRoute { + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-yuptCEU ()Ljava/lang/String; + public final fun component2-HAG__Ns ()Ljava/lang/String; + public final fun copy-l0_Qrn8 (Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/core/provider/PlannedProviderRoute; + public static synthetic fun copy-l0_Qrn8$default (Ldev/tramai/core/provider/PlannedProviderRoute;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/core/provider/PlannedProviderRoute; + public fun equals (Ljava/lang/Object;)Z + public final fun getEffectiveModelId-HAG__Ns ()Ljava/lang/String; + public final fun getProviderId-yuptCEU ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/tramai/core/provider/ProviderCapability : java/lang/Enum { public static final field STREAMING Ldev/tramai/core/provider/ProviderCapability; public static final field STRUCTURED_OUTPUT Ldev/tramai/core/provider/ProviderCapability; @@ -2035,9 +2062,24 @@ public final class dev/tramai/core/provider/ProviderFailuresKt { public static synthetic fun safeProviderFailure$default (Ljava/lang/String;Ldev/tramai/core/exception/ProviderFailureCode;Ljava/lang/Integer;ZLjava/lang/Long;ILjava/lang/Object;)Ldev/tramai/core/exception/ProviderException; } +public final class dev/tramai/core/provider/ProviderId { + public static final synthetic fun box-impl (Ljava/lang/String;)Ldev/tramai/core/provider/ProviderId; + public static fun constructor-impl (Ljava/lang/String;)Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public static fun hashCode-impl (Ljava/lang/String;)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Ljava/lang/String; +} + public final class dev/tramai/core/provider/ProviderRegistry { public static final field Companion Ldev/tramai/core/provider/ProviderRegistry$Companion; - public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/tramai/core/provider/ProviderRoutingPlan;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getRoutingPlan ()Ldev/tramai/core/provider/ProviderRoutingPlan; public final fun resolve (Ldev/tramai/core/annotations/Operation;)Ldev/tramai/core/provider/ModelProvider; public final fun resolveCandidates (Ldev/tramai/core/annotations/Operation;)Ljava/util/List; } @@ -2055,6 +2097,7 @@ public final class dev/tramai/core/provider/ProviderRegistry$Builder { public final class dev/tramai/core/provider/ProviderRegistry$Companion { public final fun builder ()Ldev/tramai/core/provider/ProviderRegistry$Builder; + public final fun from (Ldev/tramai/core/provider/ProviderRoutingPlan;)Ldev/tramai/core/provider/ProviderRegistry; public final fun singleProvider (Ldev/tramai/core/provider/ModelProvider;)Ldev/tramai/core/provider/ProviderRegistry; } @@ -2071,6 +2114,34 @@ public final class dev/tramai/core/provider/ProviderRoute { public fun toString ()Ljava/lang/String; } +public final class dev/tramai/core/provider/ProviderRoutingPlan { + public static final field Companion Ldev/tramai/core/provider/ProviderRoutingPlan$Companion; + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDefaultProvider-TfRv3Xo ()Ljava/lang/String; + public final fun getProviders ()Ljava/util/Map; + public final fun getRoutes ()Ljava/util/Map; +} + +public final class dev/tramai/core/provider/ProviderRoutingPlan$Builder { + public fun ()V + public final fun build ()Ldev/tramai/core/provider/ProviderRoutingPlan; + public final fun defaultProvider (Ljava/lang/String;)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; + public final fun fallbackModel (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; + public final fun fallbackProvider (Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; + public final fun model (Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; + public final fun provider (Ljava/lang/String;Ldev/tramai/core/provider/ModelProvider;Z)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; + public static synthetic fun provider$default (Ldev/tramai/core/provider/ProviderRoutingPlan$Builder;Ljava/lang/String;Ldev/tramai/core/provider/ModelProvider;ZILjava/lang/Object;)Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; +} + +public final class dev/tramai/core/provider/ProviderRoutingPlan$Companion { + public final fun builder ()Ldev/tramai/core/provider/ProviderRoutingPlan$Builder; +} + +public final class dev/tramai/core/provider/ProviderRoutingPlanKt { + public static final fun resolve (Ldev/tramai/core/provider/ProviderRoutingPlan;Ldev/tramai/core/annotations/Operation;)Ldev/tramai/core/provider/ModelProvider; + public static final fun resolveCandidates (Ldev/tramai/core/provider/ProviderRoutingPlan;Ldev/tramai/core/annotations/Operation;)Ljava/util/List; +} + public final class dev/tramai/core/provider/ResolvedProviderRoute { public fun (Ljava/lang/String;Ldev/tramai/core/provider/ModelProvider;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRegistry.kt b/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRegistry.kt index efe0df04..c9b5eb34 100644 --- a/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRegistry.kt +++ b/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRegistry.kt @@ -1,19 +1,11 @@ package dev.tramai.core.provider import dev.tramai.core.annotations.Operation -import dev.tramai.core.exception.ConfigurationException -/** - * One explicit execution route for a requested model. - */ -data class ProviderRoute( - val providerName: String, - val effectiveModelName: String, -) +/** One explicit execution route for a requested model. */ +data class ProviderRoute(val providerName: String, val effectiveModelName: String) -/** - * A provider route resolved to a concrete provider instance. - */ +/** A provider route resolved to a concrete provider instance. */ data class ResolvedProviderRoute( val providerName: String, val provider: ModelProvider, @@ -22,168 +14,61 @@ data class ResolvedProviderRoute( ) /** - * Explicit provider registry used to resolve operations to concrete providers. + * Compatibility facade for the authoritative [ProviderRoutingPlan]. + * + * This class holds no routing state of its own: every operation delegates to the + * frozen [routingPlan]. The plan instance passed to [from] (or produced by + * [Builder.build]) is the exact object consulted during execution — never a + * reconstructed copy. + * + * ABI note: the pre-0.6.0 private constructor descriptor + * `(Map, Map, String, DefaultConstructorMarker)` is deliberately not preserved. + * That synthetic marker belongs to a private constructor; `DefaultConstructorMarker` + * itself cannot be instantiated by consumers, and the committed binary-compatibility + * fixture does not exercise this class, so the descriptor is non-contractual. The + * public surface (companion factories, builder, resolve/resolveCandidates, and both + * DTO shapes) is byte-compatible with 0.5.0. */ -class ProviderRegistry private constructor( - private val providersByName: Map, - private val routesByRequestedModel: Map>, - private val defaultProviderName: String?, -) { - /** - * Resolves the provider for an [operation]. - * - * Resolution order is: explicit operation provider, explicit model mapping, default provider. - */ - fun resolve(operation: Operation): ModelProvider { - return resolveCandidates(operation).first().provider - } - - /** - * Resolves the ordered provider routes for an [operation], including any configured fallbacks. - */ - fun resolveCandidates(operation: Operation): List { - val explicitProvider = operation.provider.takeIf { it.isNotBlank() } - if (explicitProvider != null) { - val provider = providersByName[explicitProvider] - ?: throw ConfigurationException("Unknown provider '$explicitProvider' requested by operation model '${operation.model}'") - return listOf( - ResolvedProviderRoute( - providerName = explicitProvider, - provider = provider, - requestedModelName = operation.model, - effectiveModelName = operation.model, - ), - ) - } - - val registeredRoutes = routesByRequestedModel[operation.model] - if (registeredRoutes != null) { - return registeredRoutes.map { route -> - ResolvedProviderRoute( - providerName = route.providerName, - provider = providersByName[route.providerName] - ?: throw ConfigurationException("Model '${operation.model}' is mapped to unknown provider '${route.providerName}'"), - requestedModelName = operation.model, - effectiveModelName = route.effectiveModelName, - ) - } - } - - val defaultProviderName = defaultProviderName - if (defaultProviderName != null) { - val defaultProvider = providersByName[defaultProviderName] - ?: throw ConfigurationException("Default provider '$defaultProviderName' is not registered") - return listOf( - ResolvedProviderRoute( - providerName = defaultProviderName, - provider = defaultProvider, - requestedModelName = operation.model, - effectiveModelName = operation.model, - ), - ) - } +class ProviderRegistry private constructor(private val plan: ProviderRoutingPlan) { + fun resolve(operation: Operation): ModelProvider = plan.resolve(operation) - throw ConfigurationException("No provider is registered for model '${operation.model}'. Register the model explicitly or configure a default provider.") - } + fun resolveCandidates(operation: Operation): List = plan.resolveCandidates(operation) companion object { - /** - * Creates a mutable registry builder. - */ fun builder(): Builder = Builder() - /** - * Creates a registry backed by a single provider and uses it as the default. - */ - fun singleProvider(provider: ModelProvider): ProviderRegistry = Builder() - .provider(provider.providerId(), provider, default = true) - .build() + fun singleProvider(provider: ModelProvider): ProviderRegistry { + return builder() + .provider(provider.providerId(), provider, default = true) + .build() + } + + fun from(plan: ProviderRoutingPlan): ProviderRegistry = ProviderRegistry(plan) } - /** - * Builder for an explicit provider registry. - */ class Builder { - private val providersByName = linkedMapOf() - private val routesByRequestedModel = linkedMapOf>() - private var defaultProviderName: String? = null - - /** - * Registers a provider under [name]. - */ - fun provider( - name: String, - provider: ModelProvider, - default: Boolean = false, - ): Builder { - providersByName[name] = provider - if (default) { - defaultProviderName = name - } - return this - } + private val planBuilder = ProviderRoutingPlan.builder() - /** - * Maps a logical [modelName] to a registered provider name. - */ - fun model( - modelName: String, - providerName: String, - ): Builder { - val existingFallbacks = routesByRequestedModel[modelName] - ?.drop(1) - .orEmpty() - routesByRequestedModel[modelName] = listOf( - ProviderRoute( - providerName = providerName, - effectiveModelName = modelName, - ), - ) + existingFallbacks - return this + fun provider(name: String, provider: ModelProvider, default: Boolean = false): Builder = apply { + planBuilder.provider(name, provider, default) } - /** - * Adds an explicit fallback route for [requestedModelName]. - */ - fun fallbackModel( - requestedModelName: String, - fallbackModelName: String, - providerName: String, - ): Builder = apply { - routesByRequestedModel[requestedModelName] = - routesByRequestedModel.getOrPut(requestedModelName) { emptyList() } + ProviderRoute( - providerName = providerName, - effectiveModelName = fallbackModelName, - ) + fun model(modelName: String, providerName: String): Builder = apply { planBuilder.model(modelName, providerName) } + + fun fallbackModel(requestedModelName: String, fallbackModelName: String, providerName: String): Builder = apply { + planBuilder.fallbackModel(requestedModelName, fallbackModelName, providerName) } - /** - * Adds a fallback route that keeps the same model name but uses another provider. - */ - fun fallbackProvider( - modelName: String, - providerName: String, - ): Builder = fallbackModel( - requestedModelName = modelName, - fallbackModelName = modelName, - providerName = providerName, - ) - - /** - * Selects the provider used when an operation does not specify an explicit provider or model mapping. - */ - fun defaultProvider(providerName: String): Builder { - defaultProviderName = providerName - return this + fun fallbackProvider(modelName: String, providerName: String): Builder = apply { + planBuilder.fallbackProvider(modelName, providerName) } - /** - * Produces an immutable registry snapshot. - */ - fun build(): ProviderRegistry = ProviderRegistry( - providersByName = providersByName.toMap(), - routesByRequestedModel = routesByRequestedModel.mapValues { (_, routes) -> routes.toList() }, - defaultProviderName = defaultProviderName, - ) + fun defaultProvider(providerName: String): Builder = apply { planBuilder.defaultProvider(providerName) } + + fun build(): ProviderRegistry { + return ProviderRegistry(planBuilder.build()) + } } + + val routingPlan: ProviderRoutingPlan get() = plan } diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRoutingPlan.kt b/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRoutingPlan.kt new file mode 100644 index 00000000..f4468f1c --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRoutingPlan.kt @@ -0,0 +1,183 @@ +package dev.tramai.core.provider + +import dev.tramai.core.annotations.Operation +import dev.tramai.core.exception.ConfigurationException +import java.util.Collections + +@JvmInline +value class ProviderId(val value: String) + +@JvmInline +value class ModelId(val value: String) + +data class PlannedProviderRoute( + val providerId: ProviderId, + val effectiveModelId: ModelId, +) + +/** Immutable, authoritative snapshot of configured provider routing. */ +class ProviderRoutingPlan private constructor( + providers: Map, + routes: Map>, + val defaultProvider: ProviderId?, +) { + val providers: Map = Collections.unmodifiableMap(providers.toMap()) + val routes: Map> = Collections.unmodifiableMap( + routes.mapValues { (_, configuredRoutes) -> + Collections.unmodifiableList(configuredRoutes.toList()) + }, + ) + + companion object { + fun builder(): Builder = Builder() + } + + class Builder { + private val providers = linkedMapOf() + private val primaryRoutes = linkedMapOf() + private val fallbackRoutes = linkedMapOf>() + private val duplicateProviderIds = linkedSetOf() + private val duplicatePrimaryModels = linkedSetOf() + private var defaultProvider: ProviderId? = null + + fun provider(name: String, provider: ModelProvider, default: Boolean = false): Builder = apply { + val providerId = ProviderId(name) + if (providerId in providers) duplicateProviderIds += providerId + providers[providerId] = provider + if (default) defaultProvider = providerId + } + + // Registers the explicit primary route for a model. A second primary for the + // same model is a duplicate configuration error, not a silent replacement. + fun model(modelName: String, providerName: String): Builder = apply { + val modelId = ModelId(modelName) + if (modelId in primaryRoutes) duplicatePrimaryModels += modelId + primaryRoutes[modelId] = PlannedProviderRoute(ProviderId(providerName), modelId) + } + + fun fallbackModel( + requestedModelName: String, + fallbackModelName: String, + providerName: String, + ): Builder = apply { + val requestedModelId = ModelId(requestedModelName) + fallbackRoutes.getOrPut(requestedModelId) { mutableListOf() } += + PlannedProviderRoute(ProviderId(providerName), ModelId(fallbackModelName)) + } + + fun fallbackProvider(modelName: String, providerName: String): Builder = + fallbackModel(modelName, modelName, providerName) + + fun defaultProvider(providerName: String): Builder = apply { + defaultProvider = ProviderId(providerName) + } + + fun build(): ProviderRoutingPlan { + validate() + val composedRoutes = linkedMapOf>() + primaryRoutes.forEach { (modelId, primary) -> + composedRoutes[modelId] = listOf(primary) + fallbackRoutes[modelId].orEmpty() + } + return ProviderRoutingPlan(providers, composedRoutes, defaultProvider) + } + + private fun validate() { + providers.keys.forEach { validateProviderId(it) } + if (duplicateProviderIds.isNotEmpty()) { + throw ConfigurationException("Duplicate provider '${duplicateProviderIds.first().value}'") + } + if (duplicatePrimaryModels.isNotEmpty()) { + throw ConfigurationException("Duplicate primary route for model '${duplicatePrimaryModels.first().value}'") + } + // Every model with fallback routes must have an explicit primary. A fallback-only + // route list would otherwise let a fallback masquerade as the primary at index 0. + fallbackRoutes.keys.forEach { modelId -> + if (modelId !in primaryRoutes) { + throw ConfigurationException("Model '${modelId.value}' has fallback routes but no primary route") + } + } + primaryRoutes.forEach { (modelId, primary) -> + validateModelId(modelId) + validateProviderId(primary.providerId) + validateModelId(primary.effectiveModelId) + if (primary.providerId !in providers) { + throw ConfigurationException("Primary route for model '${modelId.value}' targets unknown provider '${primary.providerId.value}'") + } + fallbackRoutes[modelId].orEmpty().forEach { fallback -> + if (fallback == primary) { + throw ConfigurationException("Fallback route for model '${modelId.value}' duplicates its primary route") + } + } + } + fallbackRoutes.forEach { (modelId, configuredRoutes) -> + validateModelId(modelId) + val seenFallbacks = mutableSetOf() + configuredRoutes.forEach { route -> + validateProviderId(route.providerId) + validateModelId(route.effectiveModelId) + if (route.providerId !in providers) { + throw ConfigurationException("Fallback route for model '${modelId.value}' targets unknown provider '${route.providerId.value}'") + } + if (!seenFallbacks.add(route)) { + throw ConfigurationException("Duplicate fallback route for model '${modelId.value}'") + } + } + } + defaultProvider?.let { providerId -> + validateProviderId(providerId) + if (providerId !in providers) { + throw ConfigurationException("Default provider '${providerId.value}' is not registered") + } + } + } + + private fun validateProviderId(providerId: ProviderId) { + if (providerId.value.isBlank()) throw ConfigurationException("Provider name must not be blank") + if (providerId.value != providerId.value.trim()) { + throw ConfigurationException("Provider name '${providerId.value}' must not have surrounding whitespace") + } + } + + private fun validateModelId(modelId: ModelId) { + if (modelId.value.isBlank()) throw ConfigurationException("Model name must not be blank") + if (modelId.value != modelId.value.trim()) { + throw ConfigurationException("Model name '${modelId.value}' must not have surrounding whitespace") + } + } + } +} + +/** Resolves execution candidates from this immutable routing snapshot. */ +fun ProviderRoutingPlan.resolveCandidates(operation: Operation): List { + val explicitProvider = operation.provider.takeIf { it.isNotBlank() } + if (explicitProvider != null) { + val provider = providers[ProviderId(explicitProvider)] + ?: throw ConfigurationException("Unknown provider '$explicitProvider' requested by operation model '${operation.model}'") + return listOf(ResolvedProviderRoute(explicitProvider, provider, operation.model, operation.model)) + } + + val requestedModelId = ModelId(operation.model) + val registeredRoutes = routes[requestedModelId] + if (registeredRoutes != null) { + return registeredRoutes.map { route -> + ResolvedProviderRoute( + providerName = route.providerId.value, + provider = providers[route.providerId] + ?: throw ConfigurationException("Model '${operation.model}' is mapped to unknown provider '${route.providerId.value}'"), + requestedModelName = operation.model, + effectiveModelName = route.effectiveModelId.value, + ) + } + } + + val defaultProviderId = defaultProvider + if (defaultProviderId != null) { + val defaultProvider = providers[defaultProviderId] + ?: throw ConfigurationException("Default provider '${defaultProviderId.value}' is not registered") + return listOf(ResolvedProviderRoute(defaultProviderId.value, defaultProvider, operation.model, operation.model)) + } + + throw ConfigurationException("No provider is registered for model '${operation.model}'. Register the model explicitly or configure a default provider.") +} + +fun ProviderRoutingPlan.resolve(operation: Operation): ModelProvider = resolveCandidates(operation).first().provider diff --git a/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRegistryCompatibilityTest.kt b/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRegistryCompatibilityTest.kt new file mode 100644 index 00000000..8d6dd5aa --- /dev/null +++ b/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRegistryCompatibilityTest.kt @@ -0,0 +1,63 @@ +package dev.tramai.core.provider + +import dev.tramai.core.annotations.Operation +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import kotlin.test.Test + +class ProviderRegistryCompatibilityTest { + @Test + fun `old builder API produces a working registry`() { + val primary = NamedProvider("primary") + val fallback = NamedProvider("fallback") + val default = NamedProvider("default") + val registry = ProviderRegistry.builder().provider("primary", primary).provider("fallback", fallback) + .provider("default", default).model("model", "primary").fallbackModel("model", "fallback-model", "fallback") + .defaultProvider("default").build() + + assertThat(registry.resolve(Operation(prompt = "unused", model = "model"))).isSameAs(primary) + assertThat(registry.resolve(Operation(prompt = "unused", model = "other"))).isSameAs(default) + } + + @Test + fun `resolution keeps explicit provider model route and default precedence`() { + val explicit = NamedProvider("explicit") + val routed = NamedProvider("routed") + val default = NamedProvider("default") + val registry = ProviderRegistry.builder().provider("explicit", explicit).provider("routed", routed) + .provider("default", default).model("model", "routed").defaultProvider("default").build() + + assertThat(registry.resolve(Operation(prompt = "unused", model = "model", provider = "explicit"))).isSameAs(explicit) + assertThat(registry.resolve(Operation(prompt = "unused", model = "model"))).isSameAs(routed) + assertThat(registry.resolve(Operation(prompt = "unused", model = "other"))).isSameAs(default) + } + + @Test + fun `fallback ordering remains primary then configured fallbacks`() { + val registry = ProviderRegistry.builder().provider("one", NamedProvider("one")).provider("two", NamedProvider("two")) + .provider("three", NamedProvider("three")).model("model", "one").fallbackProvider("model", "two") + .fallbackModel("model", "alternate", "three").build() + + assertThat(registry.resolveCandidates(Operation(prompt = "unused", model = "model"))).extracting { it.providerName } + .containsExactly("one", "two", "three") + } + + @Test + fun `single provider remains the default provider`() { + val provider = NamedProvider("single") + assertThat(ProviderRegistry.singleProvider(provider).resolve(Operation(prompt = "unused", model = "anything"))).isSameAs(provider) + } + + @Test + fun `duplicate provider now fails at build`() = assertThatThrownBy { + ProviderRegistry.builder().provider("one", NamedProvider("one")).provider("one", NamedProvider("replacement")).build() + }.isInstanceOf(ConfigurationException::class.java) + + private class NamedProvider(private val name: String) : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = error("unused") + override fun providerId(): String = name + } +} diff --git a/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRoutingPlanTest.kt b/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRoutingPlanTest.kt new file mode 100644 index 00000000..32997a95 --- /dev/null +++ b/tramai-core/src/test/kotlin/dev/tramai/core/provider/ProviderRoutingPlanTest.kt @@ -0,0 +1,154 @@ +package dev.tramai.core.provider + +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import kotlin.test.Test + +class ProviderRoutingPlanTest { + @Test + fun `primary and fallbacks preserve registration order`() { + val plan = ProviderRoutingPlan.builder() + .provider("primary", NamedProvider("primary")) + .provider("first", NamedProvider("first")) + .provider("second", NamedProvider("second")) + .model("requested", "primary") + .fallbackModel("requested", "first-model", "first") + .fallbackModel("requested", "second-model", "second") + .build() + + assertThat(plan.providers.keys.map { it.value }).containsExactly("primary", "first", "second") + assertThat(plan.routes.getValue(ModelId("requested"))).containsExactly( + PlannedProviderRoute(ProviderId("primary"), ModelId("requested")), + PlannedProviderRoute(ProviderId("first"), ModelId("first-model")), + PlannedProviderRoute(ProviderId("second"), ModelId("second-model")), + ) + } + + @Test fun `duplicate provider is rejected at build`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).provider("one", NamedProvider("two")).build() + }.isInstanceOf(ConfigurationException::class.java) + + @Test fun `blank provider and model ids are rejected`() { + assertThatThrownBy { ProviderRoutingPlan.builder().provider(" ", NamedProvider("one")).build() } + .isInstanceOf(ConfigurationException::class.java) + assertThatThrownBy { ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model(" ", "one").build() } + .isInstanceOf(ConfigurationException::class.java) + } + + @Test fun `unknown primary provider is rejected at build`() = assertThatThrownBy { + ProviderRoutingPlan.builder().model("model", "missing").build() + }.isInstanceOf(ConfigurationException::class.java) + + @Test fun `unknown fallback provider is rejected at build`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model("model", "one").fallbackProvider("model", "missing").build() + }.isInstanceOf(ConfigurationException::class.java) + + @Test fun `unknown default provider is rejected at build`() = assertThatThrownBy { + ProviderRoutingPlan.builder().defaultProvider("missing").build() + }.isInstanceOf(ConfigurationException::class.java) + + @Test fun `duplicate identical fallback route is rejected`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model("model", "one") + .fallbackModel("model", "fallback", "one").fallbackModel("model", "fallback", "one").build() + }.isInstanceOf(ConfigurationException::class.java) + + @Test + fun `plan snapshot is unaffected by later builder mutation`() { + val builder = ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model("model", "one") + val plan = builder.build() + builder.provider("two", NamedProvider("two")).fallbackProvider("model", "two") + + assertThat(plan.providers.keys).containsExactly(ProviderId("one")) + assertThat(plan.routes.getValue(ModelId("model"))).containsExactly( + PlannedProviderRoute(ProviderId("one"), ModelId("model")), + ) + } + + @Test + fun `provider collaborator identity is preserved`() { + val provider = NamedProvider("one") + val plan = ProviderRoutingPlan.builder().provider("one", provider).build() + assertThat(plan.providers.getValue(ProviderId("one"))).isSameAs(provider) + } + + @Test + fun `fallback provider for same model is accepted`() { + val plan = ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).provider("two", NamedProvider("two")) + .model("model", "one").fallbackProvider("model", "two").build() + assertThat(plan.routes.getValue(ModelId("model"))).hasSize(2) + } + + @Test + fun `plan collections reject mutation after build`() { + val plan = ProviderRoutingPlan.builder() + .provider("one", NamedProvider("one")) + .model("model", "one") + .build() + + assertThatThrownBy { + (plan.providers as MutableMap)[ProviderId("x")] = NamedProvider("x") + }.isInstanceOf(UnsupportedOperationException::class.java) + + assertThatThrownBy { + (plan.routes[ModelId("model")] as MutableList).add( + PlannedProviderRoute(ProviderId("x"), ModelId("x")), + ) + }.isInstanceOf(UnsupportedOperationException::class.java) + } + + @Test + fun `fallback routes without a primary are rejected at build`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")) + .fallbackModel("model", "alternate", "one").build() + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("no primary route") + + @Test + fun `duplicate primary route is rejected`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).provider("two", NamedProvider("two")) + .model("model", "one").model("model", "two").build() + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("Duplicate primary route") + + @Test + fun `fallback identical to primary is rejected`() = assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")) + .model("model", "one").fallbackProvider("model", "one").build() + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("duplicates its primary route") + + @Test + fun `model ids with surrounding whitespace are rejected`() { + assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model(" model ", "one").build() + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("whitespace") + assertThatThrownBy { + ProviderRoutingPlan.builder().provider("one", NamedProvider("one")).model("model", "one") + .fallbackModel("model", " fallback ", "one").build() + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("whitespace") + } + + @Test + fun `fallback registered before primary is preserved`() { + val plan = ProviderRoutingPlan.builder() + .provider("one", NamedProvider("one")) + .provider("two", NamedProvider("two")) + .fallbackProvider("model", "two") + .model("model", "one") + .build() + assertThat(plan.routes.getValue(ModelId("model"))).containsExactly( + PlannedProviderRoute(ProviderId("one"), ModelId("model")), + PlannedProviderRoute(ProviderId("two"), ModelId("model")), + ) + } + + private class NamedProvider(private val name: String) : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = error("unused") + override fun providerId(): String = name + } +} diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt index 4b637cd7..4532e95d 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt @@ -123,6 +123,7 @@ import kotlin.reflect.jvm.kotlinFunction import dev.tramai.engine.components.ApprovalCapability import dev.tramai.engine.components.EngineComponentFactory import dev.tramai.engine.components.EngineComponents +import dev.tramai.core.provider.resolveCandidates private const val MAX_SAFE_TOOL_NAME_LENGTH = 128 private const val UNREGISTERED_TOOL_NAME = "unregistered_tool" @@ -133,7 +134,7 @@ private const val UNREGISTERED_TOOL_NAME = "unregistered_tool" class TramaiEngine private constructor( private val components: EngineComponents, ) : AutoCloseable { - private val providerRegistry = components.providers.providerRegistry + private val routingPlan = components.providers.routingPlan private val structuredOutputHandler = components.execution.structuredOutputHandler private val toolRegistry = components.tools.toolRegistry private val operationObserver = components.observation.operationObserver @@ -631,7 +632,7 @@ internal class TramaiInvocationHandler( private val resumeOperationRegistry: ResumeOperationRegistry, ) : InvocationHandler { - private val providerRegistry = components.providers.providerRegistry + private val routingPlan = components.providers.routingPlan private val structuredOutputHandler = components.execution.structuredOutputHandler private val toolRegistry = components.tools.toolRegistry private val operationObserver = components.observation.operationObserver @@ -843,7 +844,7 @@ internal class TramaiInvocationHandler( try { val correlationId = java.util.UUID.randomUUID().toString() enforceBeforeProviderResolution(operation, correlationId, securityContext) - val candidates = providerRegistry.resolveCandidates(operation.operation) + val candidates = routingPlan.resolveCandidates(operation.operation) var lastFailure: Throwable? = null var lastCircuitOpen: CircuitBreakerOpenException? = null val attemptCounter = AttemptCounter() @@ -2499,7 +2500,7 @@ internal class TramaiInvocationHandler( var lastCircuitOpen: CircuitBreakerOpenException? = null enforceBeforeProviderResolution(operation, correlationId, securityContext) - val candidates = providerRegistry.resolveCandidates(operation.operation) + val candidates = routingPlan.resolveCandidates(operation.operation) for ((routeIndex, route) in candidates.withIndex()) { val circuitOpen = handleCircuitBreakerOpenRoute( diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt index 4ed297b6..69f3fdc5 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt @@ -32,7 +32,7 @@ internal object EngineComponentFactory { val capability = approvalCapability(approvalContinuationStore, toolArgumentsDigester, approvalGateCoordinator) val resolvedPolicy = policyEngine ?: LegacyPermissivePolicyEngine return EngineComponents( - ProviderComponents(providerRegistry), ToolComponents(toolRegistry, toolResultFilteringSettings), + ProviderComponents(providerRegistry.routingPlan), ToolComponents(toolRegistry, toolResultFilteringSettings), SecurityComponents(resolvedPolicy, policyEngine == null, promptSanitizer, modelRegistry, modelRegistrySettings, dlpInterceptor, dlpRedactionAuditEmitter, policyDecisionAuditEmitter), ApprovalComponents(suspendedInvocationStore, approvalLifecycleAuditEmitter, capability), PersistenceComponents(responseCache, chatMemory, conversationIdProvider), diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt index 3811b342..d80c443f 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt @@ -13,7 +13,7 @@ import dev.tramai.core.observation.OperationObserver import dev.tramai.core.observation.ToolFailureDiagnosticObserver import dev.tramai.core.policy.PolicyDecisionAuditEmitter import dev.tramai.core.policy.PolicyEngine -import dev.tramai.core.provider.ProviderRegistry +import dev.tramai.core.provider.ProviderRoutingPlan import dev.tramai.core.security.DlpInterceptor import dev.tramai.core.security.DlpRedactionAuditEmitter import dev.tramai.core.security.PromptSanitizer @@ -45,7 +45,7 @@ internal data class EngineComponents( ) /** Runtime snapshot of provider routing. The snapshot reference is immutable; supplied providers retain their existing ownership and thread-safety contracts. */ -internal data class ProviderComponents(val providerRegistry: ProviderRegistry) +internal data class ProviderComponents(val routingPlan: ProviderRoutingPlan) /** Runtime snapshot of tool resolution and filtering settings. Caller-supplied registries remain caller-owned. */ internal data class ToolComponents(val toolRegistry: ToolRegistry, val toolResultFilteringSettings: ToolResultFilteringSettings) diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt index a3eae6ff..31587eaa 100644 --- a/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt @@ -5,6 +5,7 @@ import dev.tramai.core.approval.ApprovalGateCoordinator import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.annotations.Operation import dev.tramai.core.memory.ChatMemory import dev.tramai.core.memory.ConversationIdProvider import dev.tramai.core.memory.UuidConversationIdProvider @@ -24,6 +25,7 @@ import dev.tramai.core.policy.PolicyDecisionAuditEmitter import dev.tramai.core.policy.PolicyEngine import dev.tramai.core.provider.ModelProvider import dev.tramai.core.provider.ProviderRegistry +import dev.tramai.core.provider.resolveCandidates import dev.tramai.core.security.DlpInterceptor import dev.tramai.core.security.DlpRedactionAuditEmitter import dev.tramai.core.security.NoOpDlpInterceptor @@ -118,7 +120,7 @@ class EngineComponentsTest { clock = Clock.systemUTC(), ) - assertSame(registry, components.providers.providerRegistry) + assertSame(registry.routingPlan, components.providers.routingPlan) assertIs(components.approvals.capability) assertTrue(components.security.isLegacyFallback) assertSame(LegacyPermissivePolicyEngine, components.security.resolvedPolicyEngine) @@ -126,6 +128,31 @@ class EngineComponentsTest { assertSame(NoOpOperationResponseCache, components.persistence.responseCache) } + @Test + fun `frozen routing plan preserves primary fallback order and explicit provider resolution`() { + val registry = ProviderRegistry.builder() + .provider("primary", registryTestProvider()) + .provider("fallback", registryTestProvider()) + .provider("explicit", registryTestProvider()) + .model("requested", "primary") + .fallbackModel("requested", "fallback-model", "fallback") + .build() + + val components = createComponents(providerRegistry = registry) + + assertSame(registry.routingPlan, components.providers.routingPlan) + assertEquals( + listOf("primary" to "requested", "fallback" to "fallback-model"), + components.providers.routingPlan.resolveCandidates(routingOperation("withFallbacks")) + .map { it.providerName to it.effectiveModelName }, + ) + assertEquals( + listOf("explicit" to "requested"), + components.providers.routingPlan.resolveCandidates(routingOperation("withExplicitProvider")) + .map { it.providerName to it.effectiveModelName }, + ) + } + @Test fun `policy resolution happens once at construction`() { val customPolicy = collaborator() @@ -219,6 +246,19 @@ class EngineComponentsTest { override fun providerId(): String = "test-provider" } + private fun routingOperation(methodName: String): Operation = RoutingService::class.java + .methods + .single { it.name == methodName } + .getAnnotation(Operation::class.java) + + private interface RoutingService { + @Operation(model = "requested") + fun withFallbacks(): String + + @Operation(model = "requested", provider = "explicit") + fun withExplicitProvider(): String + } + private inline fun collaborator(): T { assertTrue(T::class.java.isInterface) @Suppress("UNCHECKED_CAST") diff --git a/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicy.kt b/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicy.kt new file mode 100644 index 00000000..d4c56493 --- /dev/null +++ b/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicy.kt @@ -0,0 +1,109 @@ +package dev.tramai.sovereign + +import dev.tramai.core.provider.ProviderRoutingPlan +import dev.tramai.security.ProviderTrustZone + +/** Internal policy validation for the authoritative provider routing plan. */ +internal object SovereignRoutingValidationPolicy { + fun validate(plan: ProviderRoutingPlan, profile: SovereignProfileConfiguration) { + val registeredProviders = plan.providers.keys.map { it.value }.toSet() + + require(registeredProviders.isNotEmpty()) { "At least one provider must be registered" } + registeredProviders.forEach { providerName -> + require(providerName in profile.allowedProviders) { + "Registered provider '$providerName' is not in allowedProviders" + } + } + profile.allowedProviders.forEach { providerName -> + require(providerName in registeredProviders) { + "Allowed provider '$providerName' has not been registered" + } + } + registeredProviders.forEach { providerName -> + require(providerName in profile.providerZones) { + "Registered provider '$providerName' has no trust zone configured" + } + } + + profile.allowedModels.forEach { modelName -> + require(plan.routes[dev.tramai.core.provider.ModelId(modelName)]?.isNotEmpty() == true) { + "Allowed model '$modelName' has no primary route" + } + } + plan.routes.forEach { (modelId, routes) -> + val modelName = modelId.value + val primary = routes.firstOrNull() + if (primary != null) { + val providerName = primary.providerId.value + require(modelName in profile.allowedModels) { + "Primary route for '$modelName' routes a model not in allowedModels" + } + require(primary.effectiveModelId.value in profile.allowedModels) { + "Primary route for '$modelName' targets unapproved effective model '${primary.effectiveModelId.value}'" + } + require(providerName in registeredProviders) { + "Model '$modelName' routes to unknown provider '$providerName'" + } + require(providerName in profile.allowedProviders) { + "Model '$modelName' routes to non-allowed provider '$providerName'" + } + } + routes.drop(1).forEach { fallback -> + val providerName = fallback.providerId.value + require(modelName in profile.allowedModels) { + "Fallback source model '$modelName' is not in allowedModels" + } + require(providerName in registeredProviders) { + "Fallback route for '$modelName' targets unknown provider '$providerName'" + } + require(providerName in profile.allowedFallbackProviders) { + "Fallback provider '$providerName' is not in allowedFallbackProviders" + } + require(fallback.effectiveModelId.value in profile.allowedModels) { + "Fallback model '${fallback.effectiveModelId.value}' is not in allowedModels" + } + } + } + + plan.defaultProvider?.value?.let { providerName -> + require(providerName in registeredProviders) { "Default provider '$providerName' is not registered" } + require(providerName in profile.allowedProviders) { + "Default provider '$providerName' is not in allowedProviders" + } + } + + if (profile.deploymentMode == SovereignDeploymentMode.OFFLINE) { + registeredProviders.forEach { providerName -> + require(profile.providerZones.getValue(providerName) == ProviderTrustZone.LOCAL) { + "offline-profile-non-local-provider-rejected" + } + } + plan.routes.values.forEach { routes -> + routes.firstOrNull()?.let { primary -> + require(profile.providerZones.getValue(primary.providerId.value) == ProviderTrustZone.LOCAL) { + "offline-profile-non-local-primary-route-rejected" + } + } + routes.drop(1).forEach { fallback -> + require(profile.providerZones.getValue(fallback.providerId.value) == ProviderTrustZone.LOCAL) { + "offline-profile-non-local-fallback-rejected" + } + } + } + plan.defaultProvider?.value?.let { providerName -> + require(profile.providerZones.getValue(providerName) == ProviderTrustZone.LOCAL) { + "offline-profile-non-local-default-provider-rejected" + } + } + } + } +} + +internal fun ProviderRoutingPlan.verificationTargets(): Set> = + buildSet { + routes.values.forEach { configuredRoutes -> + configuredRoutes.forEach { route -> + add(route.providerId.value to route.effectiveModelId.value) + } + } + } diff --git a/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt b/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt index 8c2d72f9..bafae33a 100644 --- a/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt +++ b/tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt @@ -147,26 +147,12 @@ class SovereignTramai private constructor( fun builder(): Builder = Builder() } - /** - * Describes a fallback route configured during builder assembly. - */ - private data class FallbackRoute( - val requestedModelName: String, - val fallbackModelName: String, - val providerName: String, - ) - class Builder { private var profileConfiguration: SovereignProfileConfiguration? = null private var modelRegistry: ModelRegistry? = null private var auditStore: AuditStore? = null private val standaloneBuilder = Tramai.builder() - // Tracking state for build-time validation - private val registeredProviders = linkedSetOf() - private val primaryModelRoutes = linkedMapOf() - private val fallbackRoutes = mutableListOf() - private var defaultProviderName: String? = null private var clock: Clock = Clock.systemUTC() private var modelArtifactVerifier: ModelArtifactVerifier? = null private var verificationSettings: ModelArtifactVerificationSettings = @@ -195,24 +181,20 @@ class SovereignTramai private constructor( this.auditStore = store } - // --- Delegated standalone builder methods with tracking --- + // --- Delegated standalone builder methods --- /** * Registers a provider with an optional explicit [name]. * - * @throws IllegalArgumentException if the provider name is blank, - * has surrounding whitespace, or is a duplicate. + * Routing validation (blank/whitespace names, duplicates, unknown providers, + * invalid defaults) is deferred to [build], which throws + * [dev.tramai.core.exception.ConfigurationException] for invalid routing. */ fun provider( provider: ModelProvider, name: String = provider.providerId(), default: Boolean = false, ): Builder = apply { - require(name.isNotBlank()) { "Provider name must not be blank" } - require(name == name.trim()) { "Provider name must not have surrounding whitespace" } - require(name !in registeredProviders) { "Duplicate provider registration: $name" } - registeredProviders.add(name) - if (default) defaultProviderName = name standaloneBuilder.provider(provider, name, default) } @@ -223,7 +205,6 @@ class SovereignTramai private constructor( modelName: String, providerName: String, ): Builder = apply { - primaryModelRoutes[modelName] = providerName standaloneBuilder.model(modelName, providerName) } @@ -248,7 +229,6 @@ class SovereignTramai private constructor( fallbackModelName: String, providerName: String, ): Builder = apply { - fallbackRoutes.add(FallbackRoute(requestedModelName, fallbackModelName, providerName)) standaloneBuilder.fallbackModel(requestedModelName, fallbackModelName, providerName) } @@ -262,7 +242,6 @@ class SovereignTramai private constructor( ) fun defaultProvider(providerName: String): Builder = apply { - this.defaultProviderName = providerName standaloneBuilder.defaultProvider(providerName) } @@ -384,87 +363,6 @@ class SovereignTramai private constructor( "ModelRegistry is required for sovereign profile" } - // Build-time provider and route validation - require(registeredProviders.isNotEmpty()) { - "At least one provider must be registered" - } - - // Every registered provider must be explicitly allowed - for (p in registeredProviders) { - require(p in profile.allowedProviders) { - "Registered provider '$p' is not in allowedProviders" - } - } - - // Every allowed provider must be registered - for (p in profile.allowedProviders) { - require(p in registeredProviders) { - "Allowed provider '$p' has not been registered" - } - } - - // Every registered provider must have an explicit trust zone - for (p in registeredProviders) { - require(p in profile.providerZones) { - "Registered provider '$p' has no trust zone configured" - } - } - - // Every allowed model must have an explicit primary route - for (m in profile.allowedModels) { - require(m in primaryModelRoutes) { - "Allowed model '$m' has no primary route" - } - } - - // Every primary route must target a registered allowed provider - for ((modelName, providerName) in primaryModelRoutes) { - require(modelName in profile.allowedModels) { - "Primary route for '$modelName' routes a model not in allowedModels" - } - require(providerName in registeredProviders) { - "Model '$modelName' routes to unknown provider '$providerName'" - } - require(providerName in profile.allowedProviders) { - "Model '$modelName' routes to non-allowed provider '$providerName'" - } - } - - // Fallback routes must target registered providers - for (fb in fallbackRoutes) { - require(fb.requestedModelName in profile.allowedModels) { - "Fallback source model '${fb.requestedModelName}' is not in allowedModels" - } - require(fb.providerName in registeredProviders) { - "Fallback route for '${fb.requestedModelName}' targets unknown provider '${fb.providerName}'" - } - require(fb.providerName in profile.allowedFallbackProviders) { - "Fallback provider '${fb.providerName}' is not in allowedFallbackProviders" - } - require(fb.fallbackModelName in profile.allowedModels) { - "Fallback model '${fb.fallbackModelName}' is not in allowedModels" - } - } - - // Default provider must be registered and allowed - val defaultName = defaultProviderName - if (defaultName != null) { - require(defaultName in registeredProviders) { - "Default provider '$defaultName' is not registered" - } - require(defaultName in profile.allowedProviders) { - "Default provider '$defaultName' is not in allowedProviders" - } - } - - // Offline deployment validation — before registry lookup - validateOfflineDeployment(profile) - - val verificationReceipts = verifyLocalModelArtifacts( - profile = profile, - modelRegistry = modelRegistry!!, - ) - val policyConfig: PolicyConfiguration = profile.toPolicyConfiguration() val policyEngine = DefaultPolicyEngine(policyConfig) val auditEng = AuditEngine(store = auditStore!!, clock = clock) @@ -477,10 +375,21 @@ class SovereignTramai private constructor( .modelRegistry(modelRegistry!!) .modelRegistrySettings(ModelRegistrySettings(enabled = true)) .approvalLifecycleAudit(approvalLifecycleEmitter) - .build() + + // Freeze the authoritative routing plan and validate it against the sovereign + // profile BEFORE constructing the tramai instance, so an invalid routing + // configuration fails at build without leaving a partially-built runtime. + val plan = tramai.buildRoutingPlan() + SovereignRoutingValidationPolicy.validate(plan, profile) + + val verificationReceipts = verifyLocalModelArtifacts( + profile = profile, + modelRegistry = modelRegistry!!, + plan = plan, + ) return SovereignTramai( - delegate = tramai, + delegate = tramai.build(), verificationReceipts = verificationReceipts, profile = profile, verificationSettings = verificationSettings, @@ -490,6 +399,7 @@ class SovereignTramai private constructor( private fun verifyLocalModelArtifacts( profile: SovereignProfileConfiguration, modelRegistry: ModelRegistry, + plan: dev.tramai.core.provider.ProviderRoutingPlan, ): List { if (!verificationSettings.enabled) { return emptyList() @@ -504,21 +414,11 @@ class SovereignTramai private constructor( profile = profile, modelRegistry = modelRegistry, verifier = verifier, - verificationTargets = collectVerificationTargets(), + verificationTargets = plan.verificationTargets(), ) } } - private fun collectVerificationTargets(): Set> = - buildSet { - primaryModelRoutes.forEach { (modelName, providerName) -> - add(providerName to modelName) - } - fallbackRoutes.forEach { route -> - add(route.providerName to route.fallbackModelName) - } - } - private suspend fun verifyByProviderZone( profile: SovereignProfileConfiguration, modelRegistry: ModelRegistry, @@ -602,37 +502,6 @@ class SovereignTramai private constructor( } ?: throw IllegalStateException("artifact-manifest-not-found") } - private fun validateOfflineDeployment( - profile: SovereignProfileConfiguration, - ) { - if (profile.deploymentMode != SovereignDeploymentMode.OFFLINE) { - return - } - - for (providerName in registeredProviders) { - require(profile.providerZones.getValue(providerName) == ProviderTrustZone.LOCAL) { - "offline-profile-non-local-provider-rejected" - } - } - - for ((_, providerName) in primaryModelRoutes) { - require(profile.providerZones.getValue(providerName) == ProviderTrustZone.LOCAL) { - "offline-profile-non-local-primary-route-rejected" - } - } - - for (fallback in fallbackRoutes) { - require(profile.providerZones.getValue(fallback.providerName) == ProviderTrustZone.LOCAL) { - "offline-profile-non-local-fallback-rejected" - } - } - - defaultProviderName?.let { providerName -> - require(profile.providerZones.getValue(providerName) == ProviderTrustZone.LOCAL) { - "offline-profile-non-local-default-provider-rejected" - } - } - } } } diff --git a/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicyTest.kt b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicyTest.kt new file mode 100644 index 00000000..d962c756 --- /dev/null +++ b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignRoutingValidationPolicyTest.kt @@ -0,0 +1,85 @@ +package dev.tramai.sovereign + +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import dev.tramai.core.provider.ModelProvider +import dev.tramai.core.provider.ProviderRoutingPlan +import dev.tramai.security.ProviderTrustZone +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class SovereignRoutingValidationPolicyTest { + + @Test + fun `policy reads registered providers from the authoritative plan`() { + val plan = ProviderRoutingPlan.builder() + .provider("unlisted-provider", FakeProvider("unlisted-provider"), default = true) + .model("test-model", "unlisted-provider") + .build() + val profile = profile(allowedProviders = setOf("local-provider")) + + assertThatThrownBy { SovereignRoutingValidationPolicy.validate(plan, profile) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Registered provider 'unlisted-provider' is not in allowedProviders") + } + + @Test + fun `policy reads fallback routes from the authoritative plan`() { + val plan = ProviderRoutingPlan.builder() + .provider("local-provider", FakeProvider("local-provider"), default = true) + .provider("fallback-provider", FakeProvider("fallback-provider")) + .model("test-model", "local-provider") + .model("fallback-model", "fallback-provider") + .fallbackProvider("test-model", "fallback-provider") + .build() + val profile = profile(allowedProviders = setOf("local-provider", "fallback-provider")) + + assertThatThrownBy { SovereignRoutingValidationPolicy.validate(plan, profile) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Fallback provider 'fallback-provider' is not in allowedFallbackProviders") + } + + @Test + fun `verification targets include primary and fallback plan routes`() { + val plan = ProviderRoutingPlan.builder() + .provider("local-provider", FakeProvider("local-provider"), default = true) + .provider("fallback-provider", FakeProvider("fallback-provider")) + .model("test-model", "local-provider") + .fallbackModel("test-model", "fallback-model", "fallback-provider") + .build() + + assertThat(plan.verificationTargets()).containsExactlyInAnyOrder( + "local-provider" to "test-model", + "fallback-provider" to "fallback-model", + ) + } + + @Test + fun `policy rejects fallback-only routing configuration`() { + // A fallback registered without an explicit primary is a sovereign-regression + // vector (the fallback would masquerade as a primary and execute an unapproved + // effective model). The canonical plan must reject it at build. + assertThatThrownBy { + ProviderRoutingPlan.builder() + .provider("local-provider", FakeProvider("local-provider"), default = true) + .fallbackModel("approved-model", "NOT-APPROVED", "local-provider") + .build() + } + .isInstanceOf(dev.tramai.core.exception.ConfigurationException::class.java) + .hasMessageContaining("no primary route") + } + + private fun profile(allowedProviders: Set) = SovereignProfileConfiguration( + allowedModels = setOf("test-model", "fallback-model"), + allowedProviders = allowedProviders, + providerZones = allowedProviders.associateWith { ProviderTrustZone.LOCAL }, + ) + + private class FakeProvider(private val name: String) : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "unused") + + override fun providerId(): String = name + } +} diff --git a/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiArtifactVerificationTest.kt b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiArtifactVerificationTest.kt index e8948dc5..c32d8ef7 100644 --- a/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiArtifactVerificationTest.kt +++ b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiArtifactVerificationTest.kt @@ -419,13 +419,12 @@ class SovereignTramaiArtifactVerificationTest { } @Test - fun `LOCAL primary and LOCAL fallback referencing same pair verifies once`() { - val registeredModel = localRegisteredModel( - artifactDigest = ModelArtifactDigest.of("sha256:${"a".repeat(64)}"), - ) - + fun `fallback identical to primary is rejected before artifact verification`() { + // A fallback identical to the primary (same provider, same effective model) is a + // degenerate route — under the canonical plan it is rejected at build, so artifact + // verification never sees it. This is the fail-fast contract of Epic 2.2. val registry = InMemoryModelRegistry.builder() - .register(registeredModel) + .register(localRegisteredModel(artifactDigest = ModelArtifactDigest.of("sha256:${"a".repeat(64)}"))) .build() val profile = SovereignProfileConfiguration( @@ -435,35 +434,23 @@ class SovereignTramaiArtifactVerificationTest { providerZones = mapOf("local-provider" to ProviderTrustZone.LOCAL), ) - val seenModels = mutableListOf() - val verifier = RecordingVerifier { model -> - seenModels += model - VerifiedLocalModelArtifact( - registryEntryId = model.registryEntryId, - manifestDigest = model.artifactDigest!!, - modelName = model.modelName, - verifiedAt = fixedClock.instant(), - artifactCount = 1, - totalSizeBytes = 512, - ) + assertThatThrownBy { + SovereignTramai.builder() + .profile(profile) + .modelRegistry(registry) + .auditStore(InMemoryAuditStore()) + .provider(FakeProvider("local-provider"), name = "local-provider", default = true) + .model("test-model", "local-provider") + .fallbackProvider("test-model", "local-provider") + .clock(fixedClock) + .modelArtifactVerifier(RecordingVerifier { error("verifier should not be invoked") }) + .modelArtifactVerificationSettings( + ModelArtifactVerificationSettings(enabled = true), + ) + .build() } - - val tramai = SovereignTramai.builder() - .profile(profile) - .modelRegistry(registry) - .auditStore(InMemoryAuditStore()) - .provider(FakeProvider("local-provider"), name = "local-provider", default = true) - .model("test-model", "local-provider") - .fallbackProvider("test-model", "local-provider") - .clock(fixedClock) - .modelArtifactVerifier(verifier) - .modelArtifactVerificationSettings( - ModelArtifactVerificationSettings(enabled = true), - ) - .build() - - assertThat(seenModels).hasSize(1) - assertThat(tramai.verificationReceipts()).hasSize(1) + .isInstanceOf(dev.tramai.core.exception.ConfigurationException::class.java) + .hasMessageContaining("duplicates its primary route") } @Test diff --git a/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt index 25aad92f..e2514250 100644 --- a/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt +++ b/tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt @@ -6,6 +6,7 @@ import dev.tramai.core.annotations.User import dev.tramai.core.exception.ModelDisabledException import dev.tramai.core.exception.ModelNotRegisteredException import dev.tramai.core.exception.PolicyViolationException +import dev.tramai.core.exception.ConfigurationException import dev.tramai.core.model.ClassifiedDocument import dev.tramai.core.model.ModelRequest import dev.tramai.core.model.ModelResponse @@ -111,6 +112,33 @@ class SovereignTramaiTest { .hasMessage("Tramai runtime is closed") } + @Test + fun `sovereign validation failure does not poison builder reuse`() { + // Profile allows two models; the builder only registers one, so the first + // build fails sovereign validation AFTER generic plan construction caches + // the incomplete plan. + val profile = defaultConfig.copy( + allowedModels = setOf("test-model", "extra-model"), + ) + val builder = SovereignTramai.builder() + .profile(profile) + .modelRegistry(defaultRegistry) + .auditStore(InMemoryAuditStore()) + .provider(FakeProvider(), name = "local-provider", default = true) + .model("test-model", "local-provider") + + assertThatThrownBy { builder.build() } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("has no primary route") + + // Add the missing route and retry: the cached plan must be invalidated by the + // routing mutation, so the retry validates and installs the complete plan. + builder.model("extra-model", "local-provider") + + val retried = builder.build() + retried.close() + } + @Test fun `sovereign runtime returns the same wrapper instance`() { val tramai = validBuilder().build() @@ -250,8 +278,8 @@ class SovereignTramaiTest { .provider(FakeProvider(), name = "local-provider", default = true) .model("test-model", "unknown-provider") .build() - }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("routes to unknown provider") + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("targets unknown provider") } @Test @@ -280,7 +308,7 @@ class SovereignTramaiTest { .provider(FakeProvider(), name = "local-provider") .model("test-model", "local-provider") .build() - }.isInstanceOf(IllegalArgumentException::class.java) + }.isInstanceOf(ConfigurationException::class.java) .hasMessageContaining("Duplicate provider") } diff --git a/tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt b/tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt index 11b64b06..adef050f 100644 --- a/tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt +++ b/tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt @@ -151,40 +151,52 @@ class TramaiAutoConfiguration { ?: ModelRegistrySettings(enabled = properties.security.modelRegistry.enabled) builder.modelRegistrySettings(settings) - // Register property-backed providers first so explicit provider beans can override them when needed. - resolveSecret( - directValue = properties.providers.anthropic.apiKey, - secretRef = properties.providers.anthropic.apiKeySecretRef, - fieldName = "tramai.providers.anthropic.apiKey", - secretResolver = secretResolver, - )?.let { apiKey -> - builder.provider( - provider = AnthropicProvider( + val propertyProviders = listOfNotNull( + resolveSecret( + directValue = properties.providers.anthropic.apiKey, + secretRef = properties.providers.anthropic.apiKeySecretRef, + fieldName = "tramai.providers.anthropic.apiKey", + secretResolver = secretResolver, + )?.let { apiKey -> + "anthropic" to AnthropicProvider( apiKey = apiKey, baseUrl = properties.providers.anthropic.baseUrl ?: "https://api.anthropic.com", - ), - name = "anthropic", - ) - } - - resolveOpenAiProvider(properties.providers.openai, secretResolver)?.let { provider -> - builder.provider(provider = provider, name = provider.providerId()) - } - - resolveOpenAiCompatibleProvider(properties.providers.openaiCompatible, secretResolver)?.let { provider -> - builder.provider(provider = provider, name = provider.providerId()) - } + ) + }, + resolveOpenAiProvider(properties.providers.openai, secretResolver)?.let { provider -> + provider.providerId() to provider + }, + resolveOpenAiCompatibleProvider(properties.providers.openaiCompatible, secretResolver)?.let { provider -> + provider.providerId() to provider + }, + properties.providers.ollama.baseUrl?.takeIf { it.isNotBlank() }?.let { baseUrl -> + "ollama" to OllamaProvider(baseUrl = baseUrl) + }, + ) - properties.providers.ollama.baseUrl?.takeIf { it.isNotBlank() }?.let { baseUrl -> - builder.provider( - provider = OllamaProvider(baseUrl = baseUrl), - name = "ollama", - ) + val beanProviders = dependencies.modelProviders.orderedStream().toList() + val beanProviderCounts = beanProviders.groupingBy { it.providerId() }.eachCount() + // Unique beans override property-backed providers; genuine user duplicates are + // registered as-is so the canonical plan builder rejects them deterministically. + val uniqueBeanProviders = beanProviders.filter { beanProviderCounts.getValue(it.providerId()) == 1 } + val duplicateBeanProviders = beanProviders.filter { beanProviderCounts.getValue(it.providerId()) > 1 } + + // Only bean-over-property precedence is intentional. A property-vs-property + // duplicate (e.g. OpenAI plus an openai-compatible provider explicitly named + // "openai") must NOT be silently collapsed — pass both through so the canonical + // plan builder rejects the collision deterministically. + val propertyProviderCounts = propertyProviders.groupingBy { it.first }.eachCount() + val duplicatePropertyProviders = propertyProviders.filter { propertyProviderCounts.getValue(it.first) > 1 } + val uniquePropertyProviders = propertyProviders.filter { propertyProviderCounts.getValue(it.first) == 1 } + + val providersById = uniquePropertyProviders.toMap() + uniqueBeanProviders.associate { it.providerId() to it } + providersById.forEach { (providerId, provider) -> + builder.provider(provider, name = providerId) } - - dependencies.modelProviders.orderedStream().forEach { provider -> - builder.provider(provider, name = provider.providerId()) + duplicatePropertyProviders.forEach { (providerId, provider) -> + builder.provider(provider, name = providerId) } + duplicateBeanProviders.forEach { provider -> builder.provider(provider, name = provider.providerId()) } properties.models.forEach { (model, providerName) -> builder.model(model, providerName) diff --git a/tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt b/tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt index 4b6e877d..15dbd87c 100644 --- a/tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt +++ b/tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt @@ -4,6 +4,7 @@ import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer import dev.tramai.core.annotations.AiService import dev.tramai.core.annotations.Operation +import dev.tramai.core.exception.ConfigurationException import dev.tramai.core.model.ModelRegistrySettings import dev.tramai.core.exception.ProviderException import dev.tramai.core.exception.TokenBudgetExceededException @@ -100,6 +101,57 @@ class TramaiAutoConfigurationTest { } } + @Test + fun `property providers with colliding ids fail deterministically instead of collapsing`() { + // OpenAI plus an openai-compatible provider explicitly named "openai" must NOT + // silently collapse into one (last-wins). Both reach the canonical plan builder, + // which rejects the duplicate provider id. + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/v1/chat/completions") { exchange -> + respond( + exchange = exchange, + body = """ + { + "model": "gpt-5.1-chat-latest", + "choices": [ + { + "message": { + "role": "assistant", + "content": "unused" + }, + "finish_reason": "stop" + } + ] + } + """.trimIndent(), + ) + } + server.start() + + try { + val contextRunner = ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(TramaiAutoConfiguration::class.java), + ) + .withUserConfiguration(TestApplication::class.java) + .withPropertyValues( + "tramai.models.gpt-5.1-chat-latest=openai", + "tramai.providers.openai.apiKey=test-openai-key", + "tramai.providers.openai.baseUrl=http://localhost:${server.address.port}/v1", + "tramai.providers.openai-compatible.baseUrl=http://localhost:${server.address.port}/v1", + "tramai.providers.openai-compatible.providerName=openai", + "tramai.providers.openai-compatible.apiKey=test-compatible-key", + ) + + contextRunner.run { context -> + assertThat(context).hasFailed() + assertThat(context.startupFailure?.message).contains("Duplicate provider 'openai'") + } + } finally { + server.stop(0) + } + } + @Test fun `creates an openai provider from configuration properties`() { var capturedAuthorization = "" @@ -154,6 +206,61 @@ class TramaiAutoConfigurationTest { } } + @Test + fun `custom provider bean overrides property backed provider with the same id`() { + val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TramaiAutoConfiguration::class.java)) + .withUserConfiguration(TestApplication::class.java) + .withBean("openAiOverrideProvider", ModelProvider::class.java, Supplier { OpenAiOverrideProvider() }) + .withPropertyValues( + "tramai.default-provider=openai", + "tramai.models.gpt-5.1-chat-latest=openai", + "tramai.providers.openai.apiKey=property-openai-key", + ) + + contextRunner.run { context -> + val analyzer = context.getBean(TestInvoiceAnalyzer::class.java) + + assertThat(runBlocking { analyzer.analyze("invoice-123") }).isEqualTo("bean override") + } + } + + @Test + fun `duplicate custom provider bean ids fail during context construction`() { + val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TramaiAutoConfiguration::class.java)) + .withUserConfiguration(TestApplication::class.java) + .withBean("firstDuplicateProvider", ModelProvider::class.java, Supplier { FixedProvider("duplicate") }) + .withBean("secondDuplicateProvider", ModelProvider::class.java, Supplier { FixedProvider("duplicate") }) + + contextRunner.run { context -> + assertThat(context).hasFailed() + assertThat(context).getFailure() + .hasRootCauseInstanceOf(ConfigurationException::class.java) + .hasRootCauseMessage("Duplicate provider 'duplicate'") + } + } + + @Test + fun `invalid fallback route fails during context construction`() { + val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TramaiAutoConfiguration::class.java)) + .withUserConfiguration(TestApplication::class.java, ProviderConfiguration::class.java) + .withPropertyValues( + "tramai.models.gpt-5.1-chat-latest=stub", + "tramai.fallbacks.gpt-5.1-chat-latest[0].provider=missing", + ) + + contextRunner.run { context -> + assertThat(context).hasFailed() + assertThat(context).getFailure() + .hasRootCauseInstanceOf(ConfigurationException::class.java) + .hasRootCauseMessage( + "Fallback route for model 'gpt-5.1-chat-latest' targets unknown provider 'missing'", + ) + } + } + @Test fun `creates an openai provider from secret references`() { var capturedAuthorization = "" @@ -981,6 +1088,18 @@ class PrimaryFailingProvider : ModelProvider { override fun providerId(): String = "primary" } +class OpenAiOverrideProvider : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = ModelResponse(content = "bean override") + + override fun providerId(): String = "openai" +} + +class FixedProvider(private val id: String) : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = ModelResponse(content = id) + + override fun providerId(): String = id +} + class FallbackSuccessProvider : ModelProvider { val requests = mutableListOf() diff --git a/tramai-standalone/api/tramai-standalone.api b/tramai-standalone/api/tramai-standalone.api index 9ed3dcbb..90a2f245 100644 --- a/tramai-standalone/api/tramai-standalone.api +++ b/tramai-standalone/api/tramai-standalone.api @@ -13,6 +13,7 @@ public final class dev/tramai/standalone/Tramai$Builder { public final fun approvalGateCoordinator (Ldev/tramai/core/approval/ApprovalGateCoordinator;)Ldev/tramai/standalone/Tramai$Builder; public final fun approvalLifecycleAudit (Ldev/tramai/core/approval/ApprovalLifecycleAuditEmitter;)Ldev/tramai/standalone/Tramai$Builder; public final fun build ()Ldev/tramai/standalone/Tramai; + public final fun buildRoutingPlan ()Ldev/tramai/core/provider/ProviderRoutingPlan; public final fun cache (Ldev/tramai/engine/OperationResponseCache;)Ldev/tramai/standalone/Tramai$Builder; public final fun circuitBreaker (Ldev/tramai/engine/CircuitBreakerSettings;)Ldev/tramai/standalone/Tramai$Builder; public final fun clock (Ljava/time/Clock;)Ldev/tramai/standalone/Tramai$Builder; diff --git a/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt b/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt index a81fbb2b..42b9d029 100644 --- a/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt +++ b/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt @@ -175,7 +175,8 @@ class Tramai private constructor( * Builder for the standalone Tramai composition module. */ class Builder { - private val registryBuilder = ProviderRegistry.builder() + private val registryBuilder = dev.tramai.core.provider.ProviderRoutingPlan.builder() + private var builtRoutingPlan: dev.tramai.core.provider.ProviderRoutingPlan? = null // Raw tools are kept until build() so the runtime is resolved against // a frozen snapshot of the builder state (immutability of the built // Tramai instance). @@ -216,6 +217,7 @@ class Tramai private constructor( default: Boolean = false, ): Builder = apply { registryBuilder.provider(name, provider, default) + invalidateRoutingPlan() } /** @@ -242,6 +244,7 @@ class Tramai private constructor( providerName: String, ): Builder = apply { registryBuilder.model(modelName, providerName) + invalidateRoutingPlan() } /** @@ -253,6 +256,7 @@ class Tramai private constructor( providerName: String, ): Builder = apply { registryBuilder.fallbackModel(requestedModelName, fallbackModelName, providerName) + invalidateRoutingPlan() } /** @@ -263,6 +267,7 @@ class Tramai private constructor( providerName: String, ): Builder = apply { registryBuilder.fallbackProvider(modelName, providerName) + invalidateRoutingPlan() } /** @@ -270,6 +275,7 @@ class Tramai private constructor( */ fun defaultProvider(providerName: String): Builder = apply { registryBuilder.defaultProvider(providerName) + invalidateRoutingPlan() } /** @@ -474,6 +480,26 @@ class Tramai private constructor( this.clock = clock } + /** + * Freezes and returns the authoritative [dev.tramai.core.provider.ProviderRoutingPlan] + * for the current builder state. The returned instance is exactly the plan later + * installed in the built [Tramai] runtime — callers (e.g. sovereign validation) can + * validate this same instance before [build] is invoked, without reaching through + * internal members. The cache is invalidated by every routing mutation, so the next + * call reflects the builder's current routing state. + */ + fun buildRoutingPlan(): dev.tramai.core.provider.ProviderRoutingPlan = + builtRoutingPlan ?: registryBuilder.build().also { builtRoutingPlan = it } + + /** + * Drops the cached routing plan. Called by every routing mutator so a builder can + * be reused: the previously built runtime keeps its immutable plan while the next + * [buildRoutingPlan]/[build] sees the new routing state. + */ + private fun invalidateRoutingPlan() { + builtRoutingPlan = null + } + /** * Builds an immutable standalone Tramai instance. * @@ -501,7 +527,7 @@ class Tramai private constructor( // snapshotted now, so mutating this builder after build() can never // redirect diagnostics of the built runtime. return Tramai( - providerRegistry = registryBuilder.build(), + providerRegistry = ProviderRegistry.from(buildRoutingPlan()), toolRegistry = ToolRegistry( tools.mapValues { (_, tool) -> createResolvedTool(tool, handler, toolFailureDiagnosticObserver) diff --git a/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt index 3646df3d..b82be553 100644 --- a/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt +++ b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt @@ -5,6 +5,7 @@ import dev.tramai.core.annotations.Operation import dev.tramai.core.approval.ApprovalContinuationStore import dev.tramai.core.approval.ApprovalGateCoordinator import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.exception.ConfigurationException import dev.tramai.core.model.ModelRequest import dev.tramai.core.model.ModelResponse import dev.tramai.core.observation.NoOpOperationObservation @@ -24,6 +25,32 @@ import org.junit.jupiter.api.Test */ class TramaiComponentCompositionTest { + @Test + fun `invalid provider routing fails at build`() { + val provider = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = ModelResponse(content = "unused") + + override fun providerId(): String = "primary" + } + + assertThatThrownBy { + Tramai.builder() + .provider(provider, name = "duplicate") + .provider(provider, name = "duplicate") + .build() + } + .isInstanceOf(ConfigurationException::class.java) + .hasMessage("Duplicate provider 'duplicate'") + + assertThatThrownBy { + Tramai.builder() + .defaultProvider("missing") + .build() + } + .isInstanceOf(ConfigurationException::class.java) + .hasMessage("Default provider 'missing' is not registered") + } + @Test fun `partial approval configuration fails at build`() { val store = collaborator() @@ -70,7 +97,7 @@ class TramaiComponentCompositionTest { override suspend fun complete(request: ModelRequest): ModelResponse = ModelResponse(content = "provider-a:${providerACalls.incrementAndGet()}") - override fun providerId(): String = "mock" + override fun providerId(): String = "primary" } val observerACalls = AtomicInteger() val observerA = OperationObserver { @@ -78,8 +105,9 @@ class TramaiComponentCompositionTest { NoOpOperationObservation } - val builder = baseBuilder() + val builder = Tramai.builder() .provider(providerA, default = true) + .model("mock-model", "primary") .observer(observerA) val runtimeA = builder.build() @@ -104,6 +132,44 @@ class TramaiComponentCompositionTest { assertThat(observerACalls.get()).isEqualTo(1) } + @Test + fun `builder routing mutation after build is visible to a later build`() { + val providerACalls = AtomicInteger() + val providerA = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "provider-a:${providerACalls.incrementAndGet()}") + + override fun providerId(): String = "primary" + } + val providerBCalls = AtomicInteger() + val providerB = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "provider-b:${providerBCalls.incrementAndGet()}") + + override fun providerId(): String = "other" + } + + val builder = Tramai.builder() + .provider(providerA, default = true) + + val runtimeA = builder.build() + + // Mutate routing after the first build: runtime A stays frozen on provider A, + // but the next build must reflect the new routing state (not a cached plan). + builder + .provider(providerB, default = true) + + val runtimeB = builder.build() + + val serviceA = runtimeA.create() + assertThat(runBlocking { serviceA.greet("world") }).isEqualTo("provider-a:1") + + val serviceB = runtimeB.create() + assertThat(runBlocking { serviceB.greet("world") }).isEqualTo("provider-b:1") + assertThat(providerACalls.get()).isEqualTo(1) + assertThat(providerBCalls.get()).isEqualTo(1) + } + @Test fun `no-op default behaviour remains unchanged`() { val tramai = baseBuilder().build() diff --git a/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt index 516a0f42..092ef7b5 100644 --- a/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt +++ b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt @@ -229,7 +229,6 @@ class TramaiTest { val tramai = Tramai { provider(a, name = "a") provider(b, name = "b", default = true) - model("m", "a") model("m", "b") } val service = tramai.create()