TramAI - governed AI workflows for Java and Kotlin

Module: tramai-engine

One-liner: Runtime engine that turns annotated service interfaces into AI-backed proxies with retry, circuit-breaking, caching, and token budgeting. Module type: core


L1: Quick Start (30-second read)

What

tramai-engine is the execution core of Tramai. It takes a Kotlin/Java interface annotated with @AiService / @Operation and generates a java.lang.reflect.Proxy that routes each method invocation through an LLM provider. It owns the full execution pipeline: provider selection, retry logic, circuit breaking, response caching, token budget enforcement, structured-output retry loops, and tool-call orchestration.

Why

Without tramai-engine, you have only the raw SPI contracts in tramai-core. The engine bridges annotated interfaces to provider calls, turning a declarative service definition into working AI invocations. It adds production hardening (retries, circuit breakers, timeouts, caches, token budgets) transparently — you never write provider-routing or error-handling code by hand.

When to use

Always. tramai-engine is the mandatory runtime for any Tramai-based application. It is automatically included by:

  • tramai-standalone (framework-free entry point)
  • tramai-spring (Spring Boot auto-configuration)

You only interact with it directly if you are building a custom integration or need to configure engine-level settings (cache, circuit breaker, retry policy, token budgets).

How to add

Gradle:

dependencies {
    implementation(platform("dev.tramai:tramai-bom:0.6.0"))
    implementation("dev.tramai:tramai-engine")
}

Maven:

<dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-engine</artifactId>
    <version>0.6.0</version>
</dependency>

Where to go next

TopicLink
Quickstart with all modulesGet Started
Framework-free standalone setuptramai-standalone
Spring Boot auto-configurationtramai-spring
Defining tools for the enginetramai-testing
Engine and core designArchitecture overview
Production hardeningProduction hardening guide
Tool callingTool calling guide

L2: Usage Guide (5-minute read)

Quick usage

The most common pattern: create a TramaiEngine with a single provider, then call engine.create<T>() to obtain a proxy.

import dev.tramai.engine.TramaiEngine
import dev.tramai.core.annotations.AiService
import dev.tramai.core.annotations.Operation
import dev.tramai.core.annotations.SystemPrompt
import dev.tramai.openai.OpenAiProvider

// 1. Define your AI service interface
@AiService
@SystemPrompt("You are a helpful assistant.")
interface Summarizer {
    @Operation(
        prompt = "Summarize the following text in one sentence",
        model = "gpt-4o",
    )
    fun summarize(text: String): String
}

// 2. Create the engine
val provider = OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY"))
val engine = TramaiEngine(provider)

// 3. Get a proxy and call it
val service = engine.create<Summarizer>()
val summary = service.summarize("Long article text...")
println(summary) // "The article discusses..."

// 4. Clean up
engine.close()

Suspend variant:

@AiService
@SystemPrompt("You are a precise billing assistant.")
interface SuspendAnalyzer {
    @Operation(
        prompt = "Analyze the invoice and return a raw summary",
        model = "claude-sonnet-4-20250514",
    )
    suspend fun analyze(invoiceId: String): String
}

// Usage
val result = runBlocking { service.analyze("invoice-123") }

Advanced usage

Retry policy configuration

The engine retries retryable ProviderExceptions (e.g., HTTP 429, 503) and TimeoutExceptions per route: @Operation(providerRetries) retries means providerRetries + 1 total provider attempts on that route (default providerRetries = 3).

val engine = TramaiEngine(
    provider = provider,
    retryPolicySettings = RetryPolicySettings(
        maxRetryAfterMillis = 30_000,   // cap for provider-supplied Retry-After headers
        jitterRatio = 0.2,              // 20% random jitter added to delay
    ),
)

The per-route retry budget comes from @Operation(providerRetries) — defaults to the engine-level default (3 retries). Set it per operation:

@Operation(
    prompt = "Analyze under rate limiting",
    model = "gpt-4o",
    providerRetries = 5,   // up to 5 retries on this route before fallback
)
suspend fun analyze(id: String): String

Circuit breaker

Enable the circuit breaker to skip unhealthy provider routes for a cooldown period.

val engine = TramaiEngine(
    provider = provider,
    circuitBreakerSettings = CircuitBreakerSettings(
        enabled = true,
        failureThreshold = 3,         // 3 consecutive failures opens the circuit
        openDurationMillis = 30_000,  // stay open for 30 seconds
    ),
)

When the circuit is open, the engine automatically falls through to the next configured route (see provider routing below) or throws CircuitBreakerOpenException if no fallback exists.

Response cache

Cache non-streaming, non-tool results to avoid redundant LLM calls.

val engine = TramaiEngine(
    provider = provider,
    responseCache = InMemoryOperationResponseCache(
        maxEntries = 1_000,   // LRU eviction
    ),
)

Enable caching per operation with @Operation(cacheable = true):

@Operation(
    prompt = "Classify this text",
    model = "gpt-4o-mini",
    cacheable = true,
    cacheTtlMillis = 60_000,   // 1 minute TTL
)
fun classify(text: String): String

Caching is automatically skipped for:

  • Operations that return Flow<StreamChunk> (streaming)
  • Operations that declare tools (tool-invoking)
  • Requests carrying a conversation id (chat memory would not see the turn)
  • Any engine configured with a non-NoOp OperationInterceptor or DlpInterceptor

The cache key includes a schema version, the service interface name, method name, requested model, explicit provider (if set), a SHA-256 digest of the canonically encoded messages (system prompt + operation prompt + arguments), an operation fingerprint, and the security partition (data classification + classification source).

Token budget enforcement

Prevent runaway token consumption with per-attempt or per-operation budgets.

val engine = TramaiEngine(
    provider = provider,
    tokenBudgetSettings = TokenBudgetSettings(
        hardMaxTokensPerAttempt = 4_000,           // fail if a single response exceeds 4K tokens
        hardMaxTokensPerOperation = 16_000,         // fail if cumulative tokens across retries exceed 16K
        softMaxTokensPerOperation = 10_000,         // emit event (no failure) at 10K cumulative
    ),
)
  • hardMaxTokensPerAttempt — per-response token cap
  • hardMaxTokensPerOperation — cumulative across retries, structured-output retry loops, and tool-call loops
  • softMaxTokensPerOperation — emits tramai.token_budget.soft_limit_exceeded engine event

When a hard limit is exceeded, TokenBudgetExceededException is thrown with scope, limit, and observed token counts.

Provider routing and fallbacks

Use ProviderRegistry for multi-provider setups with fallback routes.

val registry = ProviderRegistry.builder()
    .provider("anthropic", AnthropicProvider(apiKey = "..."))
    .provider("fallback", OllamaProvider(baseUrl = "http://localhost:11434"))
    .model("claude-sonnet-4-20250514", "anthropic")
    .fallbackModel("claude-sonnet-4-20250514", "llama3.2", "fallback")
    .build()

val engine = TramaiEngine(providerRegistry = registry)

When the primary route fails with a retryable error (exhausting its per-route retries), the engine falls through to the next candidate route. Fallback routes receive the fallback model name, not the original.

Override the provider at the operation level:

@Operation(
    prompt = "Analyze with Ollama explicitly",
    model = "claude-sonnet-4-20250514",
    provider = "ollama",           // bypass model→provider resolution
)
suspend fun analyze(id: String): String

Structured output

Combine with tramai-structured for typed, validated responses.

import dev.tramai.structured.JacksonStructuredOutputHandler

data class InvoiceAnalysis(
    val total: Double,
    val currency: String,
    val isFraudulent: Boolean,
)

@AiService
interface InvoiceService {
    @Operation(
        prompt = "Analyze this invoice for fraud indicators",
        model = "claude-sonnet-4-20250514",
        maxRetries = 2,   // structured-output retries on parse failure
    )
    suspend fun analyze(invoiceId: String): InvoiceAnalysis
}

// Usage
val engine = TramaiEngine(
    provider = provider,
    structuredOutputHandler = JacksonStructuredOutputHandler(),
)
val service = engine.create<InvoiceService>()
val result = service.analyze("INV-042")
println(result.total)        // Double
println(result.isFraudulent) // Boolean

On parse failure:

  1. The assistant's raw response is appended as an ASSISTANT message
  2. A corrective USER message (generated by the structured output handler) is appended
  3. The call is retried, for maxRetries + 1 total attempts
  4. After exhaustion, the engine throws safeStructuredOutputFailure(...) — a StructuredOutputException with a fixed public message, attemptCount, and a typed StructuredOutputFailureCode. The raw response and validation detail go only to the structured-output diagnostic observer; originalPrompt, lastRawResponse, and validationError are not populated.

Streaming

import kotlinx.coroutines.flow.Flow
import dev.tramai.core.model.StreamChunk

@AiService
interface StreamingService {
    @Operation(
        prompt = "Stream a detailed analysis",
        model = "claude-sonnet-4-20250514",
    )
    fun stream(invoiceId: String): Flow<StreamChunk>
}

// Usage
runBlocking {
    service.stream("INV-042").collect { chunk ->
        when (chunk) {
            is StreamChunk.Token -> print(chunk.text)
            is StreamChunk.Complete -> println("\n[Done: ${chunk.usage.outputTokens} tokens]")
            is StreamChunk.Error -> System.err.println("Error: ${chunk.cause.message}")
        }
    }
}

Streaming supports fallback routing: if the primary provider emits an Error chunk before any tokens (startup failure), the engine retries on the next route. If any tokens have been emitted, the error is terminal.

Tool calling

import dev.tramai.core.model.ToolResult
import dev.tramai.core.model.ToolExecutionContext
import dev.tramai.core.model.ResolvedTool
import dev.tramai.core.model.SideEffectLevel

val lookupTool = object : ResolvedTool {
    override val name = "lookup"
    override val description = "Looks up a tenant by ID"
    override val inputSchemaJson = """{"type":"object","properties":{"id":{"type":"string"}}}"""
    override val idempotent = true     // allows retry on transient failure
    override val sideEffectLevel = SideEffectLevel.READ_ONLY

    override suspend fun execute(input: Any, context: ToolExecutionContext): ToolResult {
        val id = (input as Map<*, *>)["id"] as? String ?: return ToolResult.InvalidInput("missing id")
        return ToolResult.Success("""{"name":"Acme Corp"}""")
    }
}

val engine = TramaiEngine(
    provider = provider,
    toolRegistry = ToolRegistry(mapOf(lookupTool.name to lookupTool)),
)

@AiService
interface QueryService {
    @Operation(
        prompt = "Look up the tenant",
        model = "claude-sonnet-4-20250514",
        tools = ["lookup"],
    )
    suspend fun query(tenantId: String): String
}

The engine executes up to 5 tool-call loops, appending assistant tool-call messages and tool-result messages to the conversation. Idempotent tools (idempotent = true) are retried once on transient failure.

Expert usage

Custom cache implementation

Implement the OperationResponseCache interface for Redis, Memcached, or other distributed caches.

class RedisOperationResponseCache(
    private val redis: RedisClient,
) : OperationResponseCache {
    override fun get(key: OperationCacheKey): CachedOperationResult? {
        // Deserialize from Redis; return null on miss or expiry
    }

    override fun put(key: OperationCacheKey, value: CachedOperationResult, ttlMillis: Long) {
        // Serialize value.value + value.provenance and store in Redis with TTL
    }

    override fun invalidate(key: OperationCacheKey) {
        // Optional override; the default implementation is a no-op
    }
}

CachedOperationResult bundles the cached value with its CachedResponseProvenance (provider, model, data classification, model-registry revision/digest). Write payloads come from the engine already wrapped in that type.

Operation interceptor

Mutate requests and responses mid-flight via OperationInterceptor.

val redactingInterceptor = object : OperationInterceptor {
    override fun interceptRequest(
        context: OperationCallContext,
        messages: List<Message>,
    ): List<Message> = messages.map {
        it.copy(content = it.content.replace(Regex("\\b\\d{16}\\b"), "****-****-****-****"))
    }

    override fun interceptResponse(
        context: OperationCallContext,
        response: ModelResponse,
    ): ModelResponse = response.copy(
        content = response.content.replace(Regex("\\b\\d{16}\\b"), "****-****-****-****"),
    )
}

val engine = TramaiEngine(
    provider = provider,
    operationInterceptor = redactingInterceptor,
)

Operation observer

Attach custom observability without modifying the engine.

class MetricCollectingObserver : OperationObserver {
    override fun onCallStarted(context: OperationCallContext): OperationObservation {
        val startTime = System.nanoTime()
        return object : OperationObservation {
            override fun onProviderResponse(response: ModelResponse) { /* record */ }
            override fun onProviderFailure(error: Throwable) { /* record */ }
            override fun onStructuredParseFailure(rawResponse: String, errorSummary: String) { /* record */ }
            override fun onEngineEvent(name: String, attributes: Map<String, Any?>) { /* record */ }
            override fun onCallCompleted(parseSuccess: Boolean?) {
                val elapsed = System.nanoTime() - startTime
                metrics.recordLatency(context.providerId, context.methodName, elapsed)
            }
        }
    }
}

Native Image support

TramaiEngine uses java.lang.reflect.Proxy, which requires proxy class pre-registration in GraalVM Native Image. The NativeImageSmokeTest provides a reference for setting this up. Use @TypeHint or a GraalVM reflection configuration file to register proxy interfaces.

Conversational memory

Inject conversation history into AI calls and persist responses automatically.

Requires adding tramai-memory to your dependencies:

dependencies {
    implementation(platform("dev.tramai:tramai-bom:0.6.0"))
    implementation("dev.tramai:tramai-memory")
}

Then configure a MessageWindowChatMemory (from tramai-memory) and pass it to the engine:

val memory = MessageWindowChatMemory(
    maxMessages = 20,        // non-system messages per conversation
    maxConversations = 1000, // active conversations before LRU eviction
)

val engine = TramaiEngine(
    provider = provider,
    chatMemory = memory,     // enables history injection + persistence
)

The @ConversationId annotation on a method parameter marks which argument identifies the conversation:

@AiService
interface ChatService {
    @Operation(prompt = "Answer the user's question", model = "claude-sonnet-4-20250514")
    suspend fun chat(
        @ConversationId sessionId: String,
        message: String,
    ): String
}

When chatMemory is configured, the handler:

  1. Resolves the conversation ID from the @ConversationId parameter
  2. Loads history from the memory store and prepends it to the request
  3. After a successful response, persists the user's messages and assistant's reply

Memory is injected for all return kinds (raw string, structured, streaming). A completed streaming call persists the turn (StreamingExecutionCoordinator calls persistTurn on StreamingRouteResult.Completed) — streaming turns are persisted in 0.6.0. System messages are deduplicated across turns (history takes precedence).

Configuration reference

PropertyTypeDefaultDescription
RetryPolicySettings.maxRetryAfterMillisLong30_000Cap for provider-supplied Retry-After delays
RetryPolicySettings.jitterRatioDouble0.2Fraction of random jitter added to retry delays
CircuitBreakerSettings.enabledBooleanfalseEnable circuit breaker
CircuitBreakerSettings.failureThresholdInt3Consecutive failures before circuit opens
CircuitBreakerSettings.openDurationMillisLong30_000Duration circuit stays open (ms)
TokenBudgetSettings.hardMaxTokensPerAttemptLong?nullHard cap per single provider response
TokenBudgetSettings.hardMaxTokensPerOperationLong?nullHard cap for cumulative operation tokens
TokenBudgetSettings.softMaxTokensPerOperationLong?nullSoft cap — emits event, does not fail
InMemoryOperationResponseCache.maxEntriesInt1_000Max cache entries (LRU eviction)
chatMemoryChatMemory?nullEnables conversational memory injection and persistence
conversationIdProviderConversationIdProviderUuidConversationIdProviderFallback ID generator when no @ConversationId annotation

@Operation annotation settings relevant to the engine:

AttributeTypeDefaultDescription
providerString""Explicit provider name (bypasses model→provider resolution)
providerRetriesInt3Retries per route before fallback (providerRetries + 1 attempts)
maxRetriesInt2Structured-output parse retries (non-String returns; total attempts = maxRetries + 1)
timeoutMillisLong30_000Per-call timeout
cacheableBooleanfalseEnable response caching
cacheTtlMillisLong60_000Cache TTL (only when cacheable = true)
toolsString[][]Tool names to make available

L3: Architecture & Mechanics (15-minute read)

Design philosophy

tramai-engine owns the orchestration of a single AI operation — from the moment an annotated interface method is called until a value (or error) is returned. It is deliberately not a workflow engine (that role belongs to tramai-orchestration). It handles:

  • Proxy generation (interface → JVM proxy)
  • Provider routing and fallback
  • Retry with backoff and jitter
  • Circuit breaking
  • Response caching
  • Token budget enforcement
  • Structured-output retry loops
  • Tool-call execution loops
  • Observability hooks (interceptors and observers)

The engine is stateless with respect to business logic: all state is either in configuration (settings objects), transient (in-flight observations), or external (cache, provider). This makes the engine testable and embeddable.

Module boundary

tramai-engine depends on tramai-core (api scope) and tramai-security (implementation scope), and uses tramai-structured only in tests. It defines no new annotations — it interprets @AiService, @Operation, @SystemPrompt, @System, @User, and @ConversationId from tramai-core.

Owns:

  • TramaiEngine — the public entry point, now accepts optional chatMemory and conversationIdProvider
  • RetryPolicySettings — retry delay computation
  • CircuitBreakerSettings — per-provider circuit state
  • TokenBudgetSettings — per-operation and per-attempt budgets
  • ToolResultFilteringSettings — per-tool aggregate text limits for tool results
  • OperationResponseCache / InMemoryOperationResponseCache / NoOpOperationResponseCache — caching contract and implementations
  • SuspendedInvocationStore / InMemorySuspendedInvocationStore — approval-suspension state
  • ToolRegistry — tool resolution
  • Proxy generation and invocation (TramaiInvocationHandler) — includes @ConversationId resolution and memory delegation
  • Route selection logic (via ProviderRegistry.resolveCandidates)
  • Structured output retry loop
  • Conversational memory injection and persistence (via ChatMemory SPI)

Does not own:

  • Annotations (@AiService, @Operation, etc.) — owned by tramai-core
  • Model provider implementations — owned by provider modules (tramai-openai, tramai-anthropic, tramai-ollama)
  • Structured output schema/parsing — owned by tramai-structured
  • Multi-step workflows — owned by tramai-orchestration
  • Framework integration — owned by adapters (tramai-spring, tramai-standalone)
  • Memory implementations — owned by tramai-memory (e.g., MessageWindowChatMemory, MemoryInterceptor)

Dependency graph

tramai-engine
  ├── api:    tramai-core (annotations, models, providers, exceptions, observation SPI)
  ├── impl:   tramai-security
  ├── impl:   kotlinx-coroutines-core, kotlin-reflect
  └── test:   tramai-structured (JacksonStructuredOutputHandler)

Verified against tramai-engine/build.gradle.kts at v0.6.0: api(project(":tramai-core")), implementation(project(":tramai-security")), implementation(libs.coroutines.core), implementation(libs.kotlin.reflect), testImplementation(project(":tramai-structured")).

No transitive dependency on any specific AI provider. No dependency on Spring, Micronaut, or any framework.

Inner mechanics

Proxy generation algorithm

┌─────────────────────────────────────────────────────────────────────┐
│ TramaiEngine.create(ServiceType)                                    │
│   │                                                                 │
│   ├─ ServiceDefinition.create(serviceType, toolRegistry)            │
│   │   ├─ Validate: is interface? has @AiService?                    │
│   │   ├─ Read @SystemPrompt (optional)                              │
│   │   └─ For each method (excluding Object methods):                │
│   │       ├─ Read @Operation (required, fail if missing)            │
│   │       ├─ Resolve tool names → ToolDefinition[]                  │
│   │       ├─ Determine return kind (String/Unit/STRUCTURED/STREAMING│
│   │       │   via Kotlin reflection or Java fallback)               │
│   │       ├─ Detect suspend vs blocking via Continuation parameter  │
│   │       └─ Store as OperationDefinition in method→op map          │
│   │                                                                 │
│   └─ TramaiInvocationHandler(providerRegistry, cache, circuit,      │
│       retry, tokenBudget, observer, interceptor, definition,        │
│       chatMemory, conversationIdProvider)                           │
│         │                                                           │
│         └─ Proxy.newProxyInstance(loader, [ServiceType], handler)   │
│                                                                     │
│ On method invocation:                                               │
│   handler.invoke(proxy, method, args)                               │
│     ├─ Object method? → delegate to toString/hashCode/equals        │
│     ├─ Resolve conversationId from @ConversationId or provider      │
│     ├─ Suspend? → launch in engine scope, return COROUTINE_SUSPENDED│
│     └─ Blocking? → runBlocking { execute(op, args) }                │
│                                                                     │
│   execute(op, args):                                                │
│     ├─ ReturnKind.STRING → executeRaw → executeWithTools            │
│     ├─ ReturnKind.UNIT → executeRaw, return Unit                    │
│     ├─ ReturnKind.STRUCTURED → executeStructured (retry loop)       │
│     └─ ReturnKind.STREAMING → executeStreaming (Flow)               │
└─────────────────────────────────────────────────────────────────────┘

Key design decisions in proxy generation:

  1. No annotation processing at compile time — the engine uses java.lang.reflect.Proxy at runtime, reading annotations via standard Java reflection. This avoids KAPT/KSP build-time dependencies and keeps the module build-light.
  2. Kotlin reflection is optional — the engine prefers method.kotlinFunction for suspend detection and parameter names, but falls back to Java reflection (signature-based Continuation detection and Parameters API) when Kotlin metadata is absent.
  3. Return kind resolution — the engine classifies every method into one of four return kinds:
    • STRING: String return type → raw text response
    • UNIT: Unit / void → calls provider, discards content
    • STRUCTURED: any non-String object type → structured output contract
    • STREAMING: Flow<StreamChunk> → streaming route
  4. Cache eligibility — OperationCacheCoordinator.createKey returns null unless all of: @Operation(cacheable = true), return kind is not STREAMING, no tools declared, no conversation id, operationInterceptor === NoOpOperationInterceptor, and dlpInterceptor === NoOpDlpInterceptor.

Retry mechanics

The engine implements a two-level retry hierarchy:

Level 1 — Per-route retries (@Operation(providerRetries))

For each candidate route (primary → fallback1 → fallback2):
  Repeat up to (providerRetries + 1) times:
    Call provider
    If success → return
    If retryable error (TimeoutException, ProviderException(retryable=true)):
      Compute delay = min(retryAfterHint, maxRetryAfterMillis) + jitter
      Emit "tramai.retry.scheduled" event
      Delay and retry
    If non-retryable error → throw immediately
  After exhausting retries → fall through to next route

Level 2 — Structured-output parse retries (@Operation(maxRetries))

For structured return types, after Level 1 succeeds:
  Parse response through StructuredOutputHandler
  If Success → return value
  If Failure:
    If maxRetries not exceeded → append corrective messages, retry Level 1
    If exhausted → throw StructuredOutputException

Retry delay computation:

base    = error.retryAfterMillis                    (from ProviderException)
            capped at maxRetryAfterMillis
          ?: min(50ms shl retryIndex, 1_000ms)      (exponential backoff fallback)
jitter  = (base * jitterRatio * random[0.0, 1.0)).toLong()
delay   = base + jitter

The backoff fallback is inlined in ProviderRetryPolicy (minOf(50L shl retryIndex, 1_000L)); there are no named delay constants. Only maxRetryAfterMillis caps a provider-supplied Retry-After value.

Tool retries:

  • Idempotent tools (idempotent = true) are retried once on TransientFailure
  • Non-idempotent tools are never retried
  • Maximum tool-call loops per operation: 5 (guard against infinite tool loops)

Error model

All exceptions are in dev.tramai.core.exception:

ExceptionRaised whenRetryable
ConfigurationExceptionInvalid interface (no @AiService), missing @Operation, no provider registeredNo (fail fast)
ProviderExceptionProvider returns an error; retryable flag from providerretryable boolean
TimeoutExceptionProvider call exceeds timeoutMillisYes
CircuitBreakerOpenExceptionCircuit is open and no fallback route existsNo (routes skipped silently if fallback exists)
TokenBudgetExceededExceptionHard token budget exceededNo (terminal for the operation)
StructuredOutputExceptionStructured parse failure after exhausting retriesNo
ProviderCapabilityExceptionStreaming (or vision) requested but the provider does not declare that capabilityNo

Circuit breaker state machine:

CLOSED ──(consecutive failures >= threshold)──→ OPEN
OPEN ──(openDurationMillis elapsed)──→ HALF_OPEN (next call probes)
HALF_OPEN ──(success)──→ CLOSED
HALF_OPEN ──(failure)──→ OPEN

The circuit breaker is per-provider (keyed by providerId), not per-model. Consecutive qualifying failures increment a counter until the threshold opens the circuit for openDurationMillis; only TimeoutException and retryable ProviderException count as qualifying failures. A success at CLOSED resets the failure counter to zero (the state entry is retained, not removed). OPEN→HALF_OPEN is atomic at exact expiry: exactly one caller gets the probe, every other caller is rejected until it resolves.

Testing strategy

At v0.6.0 the engine test source set holds 89 test files with 812 @Test methods. Two of the load-bearing ones:

TramaiEngineTest.kt (4,106 lines, 118 @Test methods) — the end-to-end proxy suite: return kinds (string/unit/structured/streaming), validation fast-fail, structured retry and exhaustion, provider retries and Retry-After handling, error wrapping, route selection and fallback, circuit-breaker behaviour, streaming fallback and token budget, caching, interceptors, and concurrency.

NativeImageSmokeTest.kt (50 lines, 1 @Test) — verifies TramaiEngine.create() works with java.lang.reflect.Proxy in a GraalVM-native compatible setup.

Key testing patterns (in TramaiEngineTest.kt):

  • RecordingProvider / NamedProvider — capture requests for assertion
  • SequencedProvider — return different responses on successive calls (for retry/structured-output tests)
  • RecordingObserver — capture engine events for observability assertions
  • StreamingProvider — emit controlled Flow<StreamChunk> sequences
  • All test interfaces are private to the test, avoiding API pollution

L4: API Reference (auto-generated from KDoc)

TypeKindDescription
TramaiEngineClassMain engine: creates AI-backed proxies from annotated interfaces
TramaiEngine.create()MethodReturns a JVM proxy implementing the given service type
TramaiEngine.close()MethodCancels the engine's coroutine job hierarchy
RetryPolicySettingsData classRetry delay computation: max Retry-After cap, jitter ratio
CircuitBreakerSettingsData classPer-provider circuit breaker: enabled, threshold, open duration
TokenBudgetSettingsData classToken budgets: hard per-attempt, hard/soft per-operation
OperationResponseCacheInterfaceCache contract for non-streaming operation results
OperationCacheKeyData classStable cache key: schema version, interface, method, requested model, explicit provider, request digest, operation fingerprint, security partition
CachedOperationResultData classCached value plus CachedResponseProvenance (provider, model, classification, model revision)
InMemoryOperationResponseCacheClassLRU-backed in-memory cache with TTL expiry
ToolRegistryClassName-based resolution of registered tools
NoOpOperationResponseCacheObjectNo-op cache singleton (default)