TramAI - governed AI workflows for Java and Kotlin

Module: tramai-core

One-liner: Annotations, data models, provider SPI, structured-output contracts, and exceptions for the Tramai AI library. Module type: coreGroup: dev.tramai, Version: 0.6.0Source files: 127 (across 20 packages), LOC: 7,132


L1: Quick Start (30-second read)

What

tramai-core is the foundational module of the Tramai ecosystem. It defines the contracts that every other module — engine, structured output, providers, orchestration, framework adapters — builds on. It contains no execution engine and no runtime wiring. It is the shared vocabulary of the platform.

Why

Without tramai-core, no two Tramai modules could communicate. It provides:

  • Annotations (@AiService, @Operation, @AiTool, @AiDescription, @AiRange, @AiMinItems, @System, @SystemPrompt, @User, @ConversationId) that let you declare AI-backed interfaces in pure Kotlin
  • Data models (Message, ModelRequest, ModelResponse, StreamChunk, ToolCall, ToolResult) that normalize provider interactions into a unified representation
  • Provider SPI (ModelProvider, ProviderCapability, ProviderRegistry, StreamCapable) that lets transport modules plug in without coupling to each other
  • Structured output contracts (StructuredOutputContract, StructuredOutputHandler, StructuredOutputResult, StructuredOutputFailureCode) that make typed, validated responses a first-class concept
  • Observation & interceptor interfaces (OperationObserver, OperationInterceptor) for non-invasive monitoring, PII redaction, and security auditing
  • Exception hierarchy (TramaiException, ProviderException, StructuredOutputException, plus approval- and policy-domain failures) rooted at the sealed TramaiException
  • Cancellation & bounded-read invariants (Throwable.rethrowIfCancellation(), readBoundedBodyBytes) that providers and the engine must honour
  • Infrastructure types (SecretValueResolver, NativeImageProxyConfig) for credential resolution and GraalVM native-image support

When to use

  • Always — every Tramai application depends on tramai-core, either directly or transitively through tramai-engine, tramai-standalone, or tramai-spring.
  • Directly — when writing custom ModelProvider implementations, custom SecretValueResolver strategies, custom OperationInterceptor implementations, or custom tool implementations via TramaiTool.
  • Never alone — tramai-core has no execution engine. Pair it with tramai-engine for proxy-based invocation, tramai-structured for typed outputs, and a provider module (tramai-openai, tramai-anthropic, tramai-ollama) for actual model calls.

How to add

Gradle (Kotlin DSL):

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

Maven:

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

Where to go next

If you want to...Go here
Execute an @AiService interfacetramai-engine
Parse and validate typed responsestramai-structured
Call OpenAI modelstramai-openai
Call Anthropic modelstramai-anthropic
Call Ollama modelstramai-ollama
Get a framework-free entry pointtramai-standalone
Use Spring Boot auto-configurationtramai-spring
Write tests with mock providerstramai-testing

L2: Usage Guide (5-minute read)

Quick usage

The most common way to use tramai-core types is through an @AiService interface. The following minimal example shows all core annotations in action:

import dev.tramai.core.annotations.*
import dev.tramai.core.model.*

// --- 1. Define a structured output type ---
data class Weather(
    @AiDescription("Temperature in Celsius")
    val temperature: Double,
    @AiDescription("Weather condition description")
    val condition: String,
    @AiRange(min = 0.0, max = 100.0)
    val humidity: Double,
)

// --- 2. Define a tool that the model can call ---
data class GetTimeInput(val timezone: String)

class GetTimeTool : TramaiTool<GetTimeInput, String> {
    override val name = "get_time"
    override val description = "Returns the current time for a given timezone"
    override val inputType = GetTimeInput::class

    override suspend fun execute(input: GetTimeInput, context: ToolExecutionContext): String {
        return "Current time in ${input.timezone} is 12:00 UTC"
    }
}

// --- 3. Define the AI service interface ---
@AiService
@SystemPrompt("You are a helpful weather assistant.")
interface WeatherService {

    @Operation(
        prompt = "What is the weather in {city}?",
        model = "gpt-4o",
        tools = ["get_time"],
        timeoutMillis = 15_000,
    )
    suspend fun getWeather(city: String): Weather

    @Operation(
        prompt = "Summarize the weather for the next week.",
        model = "gpt-4o-mini",
        cacheable = true,
    )
    suspend fun summarizeForecast(data: String): String
}

The @AiService annotation marks the interface for proxy generation. The @SystemPrompt annotation provides a system-level prompt prepended to every operation. Each @Operation defines a single AI-backed method with its prompt, model, optional tools, timeout, and retry settings.

Advanced usage

Custom ModelProvider implementation

To integrate a custom AI provider, implement the ModelProvider interface:

import dev.tramai.core.model.*
import dev.tramai.core.provider.*

class CustomProvider(
    private val apiKey: String,
) : ModelProvider {
    override suspend fun complete(request: ModelRequest): ModelResponse {
        // Translate ModelRequest -> your API format
        // Call your API
        // Translate response -> ModelResponse
        return ModelResponse(
            content = "Processed: ${request.messages.lastOrNull()?.content ?: ""}",
            inputTokens = 10,
            outputTokens = 5,
            modelUsed = "custom-model",
        )
    }

    override fun providerId(): String = "custom"
}

If your provider supports streaming, implement StreamCapable:

import dev.tramai.core.model.*
import dev.tramai.core.provider.*
import kotlinx.coroutines.flow.*

class CustomStreamingProvider : ModelProvider, StreamCapable {
    override suspend fun complete(request: ModelRequest): ModelResponse {
        return ModelResponse(content = "Hello!")
    }

    override suspend fun stream(request: ModelRequest): Flow<StreamChunk> = flow {
        emit(StreamChunk.Token("Hello"))
        emit(StreamChunk.Token(" world"))
        emit(StreamChunk.Complete(fullText = "Hello world"))
    }
}

Manual provider registry

The ProviderRegistry routes @Operation model names to concrete ModelProvider instances, with fallback support:

import dev.tramai.core.provider.*

val registry = ProviderRegistry.builder()
    .provider("openai", openAiProvider, default = true)
    .provider("anthropic", anthropicProvider)
    .model("gpt-4o", "openai")
    .model("claude-3-opus", "anthropic")
    .fallbackProvider("gpt-4o", "anthropic") // falls back to Anthropic if OpenAI fails
    .build()

Resolution order:

  1. Explicit @Operation(provider = "...") — highest priority
  2. Registered model-to-provider mapping
  3. Default provider (set via provider(..., default = true) or defaultProvider(...))
  4. Throws ConfigurationException if no route is found

Custom OperationInterceptor

Interceptors can inspect or modify request messages and provider responses, useful for PII redaction:

import dev.tramai.core.observation.*

class PiiRedactionInterceptor : OperationInterceptor {
    override fun interceptRequest(
        context: OperationCallContext,
        messages: List<Message>,
    ): List<Message> {
        return messages.map { msg ->
            msg.copy(
                content = msg.content.replace(Regex("\\b\\d{3}-\\d{2}-\\d{4}\\b"), "***-**-****")
            )
        }
    }
}

Secret resolution

Credentials can be resolved dynamically via SecretValueResolver. Built-in resolvers support env: and file: schemes; FileSecretValueResolver requires an explicit allowed directory and rejects paths outside it:

import dev.tramai.core.secret.*
import java.nio.file.Path

val resolver = CompositeSecretValueResolver(listOf(
    EnvironmentSecretValueResolver,
    FileSecretValueResolver(allowedDirectory = Path.of("/etc/secrets")),
))

val apiKey: String? = resolver.resolve("env:OPENAI_API_KEY")       // null when unset
val apiKey2: String? = resolver.resolve("file:key")               // resolved under the allowed directory

Expert usage

StructuredOutputHandler — custom parsing and validation

The StructuredOutputHandler SPI lets you plug in your own schema generation, parsing, and validation strategy. The reference implementation lives in tramai-structured (Jackson-based).

import dev.tramai.core.structured.*
import kotlin.reflect.KType

class CustomSchemaHandler : StructuredOutputHandler {
    override fun createContract(targetType: KType): StructuredOutputContract {
        val schema = generateSchema(targetType)
        return StructuredOutputContract(
            targetType = targetType,
            schemaJson = schema,
        )
    }

    override fun analyze(rawResponse: String, targetType: KType): StructuredOutputResult {
        return try {
            val value = deserialize(parseJson(rawResponse), targetType)
            StructuredOutputResult.Success(value, rawResponse)
        } catch (e: Exception) {
            StructuredOutputResult.Failure(
                rawResponse = rawResponse,
                errorSummary = "Parse failed: ${e.message}",
                feedbackMessage = "Please respond with valid JSON matching the expected schema.",
            )
        }
    }

    override fun generateSchema(type: KType): String = buildSchemaFor(type)
    override fun deserialize(input: Any, targetType: KType): Any = customDeserialize(input, targetType)
    override fun serialize(value: Any): Any = customSerialize(value)
}

NativeImageProxyConfig — GraalVM native-image support

When compiling Tramai applications with GraalVM native-image, JDK proxy classes for @AiService interfaces must be registered explicitly:

import dev.tramai.core.nativeimage.*
import java.nio.file.*

NativeImageProxyConfig.write(
    outputPath = Path.of("META-INF/native-image/reflect-config.json"),
    WeatherService::class,
    StockAnalysisService::class,
)

This generates a JSON snippet that GraalVM's native-image agent uses to register dynamic proxies for those interfaces.

Custom OperationObserver

The observer SPI enables non-invasive monitoring — logging, metrics, tracing:

import dev.tramai.core.observation.*

class MetricsObserver : OperationObserver {
    override fun onCallStarted(context: OperationCallContext): OperationObservation {
        println("[START] ${context.serviceInterface}.${context.methodName} " +
                "attempt #${context.attempt} on ${context.providerId}")
        val startTime = System.nanoTime()
        return object : OperationObservation {
            override fun onProviderResponse(response: ModelResponse) {
                println("[TOKENS] in=${response.inputTokens} out=${response.outputTokens}")
            }
            override fun onProviderFailure(error: Throwable) {
                println("[FAIL] ${error.message}")
            }
            override fun onStructuredParseFailure(rawResponse: String, errorSummary: String) {
                println("[PARSE_FAIL] $errorSummary")
            }
            override fun onEngineEvent(name: String, attributes: Map<String, Any?>) {
                println("[EVENT] $name $attributes")
            }
            override fun onCallCompleted(parseSuccess: Boolean?) {
                val elapsed = (System.nanoTime() - startTime) / 1_000_000
                println("[END] ${context.methodName} took ${elapsed}ms")
            }
        }
    }
}

Configuration reference

tramai-core defines the annotation-level configuration. The table below lists @Operation parameters — the primary configuration surface for application developers.

PropertyTypeDefaultDescription
promptString""Base user instruction; {param} markers are replaced with argument values
modelString(required)Logical model name (resolved via ProviderRegistry)
providerString""Explicit provider ID — bypasses registry model resolution
toolsString[][]Tool names available to this operation
maxRetriesInt2Structured-output parse/validation retries
providerRetriesInt3Transient provider failure retries
timeoutMillisLong30_000Max duration per provider attempt
cacheableBooleanfalseWhether the engine may cache non-streaming responses
cacheTtlMillisLong60_000Cache TTL when cacheable is enabled

Other key annotation parameters:

AnnotationPropertyTypeDefaultDescription
@AiToolnameString""Explicit tool name (defaults to method name)
@AiTooldescriptionString(required)Tool description for model tool definitions
@AiToolidempotentBooleanfalseRepetition safety, not failure retryability
@AiToolsideEffectLevelSideEffectLevelUNKNOWNSide-effect classification
@AiToolpermissionString""Permission required to expose/execute the tool; non-empty opts into strict security metadata
@AiToolriskRiskLevelHIGHRisk classification used when permission is set
@AiToolapprovalApprovalModeHUMAN_REQUIREDApproval mode used when permission is set
@AiToolmanagedNetworkEgressManagedNetworkEgressDENYManaged egress policy when permission is set
@AiToolauditAuditDetailFULLAudit detail requested when permission is set
@AiDescriptionvalueString(required)Human-readable property description
@AiMinItemsvalueInt(required)Minimum collection size (collections only)
@AiRangeminDouble(required)Inclusive lower bound
@AiRangemaxDouble(required)Inclusive upper bound
@SystemPromptvalueString(required)Class-level system prompt prepended to each request
@SystemvalueString(required)Method-level system message with {param} interpolation; concatenated with newlines when repeated
@UservalueString(required)Method-level user message with {param} interpolation; repeated annotations emit separate user messages
@ConversationId———Marks the parameter holding the conversation identity (value parameter target)

L3: Architecture & Mechanics (15-minute read)

Design philosophy

tramai-core follows a contract-first, SPI-driven design philosophy. Every type in the module exists for one of three reasons:

  1. Declare intent — annotations (@AiService, @Operation, @AiTool) let developers describe what they want in pure Kotlin/Java, without coupling to any specific provider or execution strategy.
  2. Normalize the polyglot — data models (ModelRequest, ModelResponse, Message, ToolCall, StreamChunk) provide a single representation for the many different wire formats used by AI providers (OpenAI, Anthropic, Ollama, etc.).
  3. Define extension boundaries — interfaces (ModelProvider, StreamCapable, StructuredOutputHandler, OperationObserver, OperationInterceptor, SecretValueResolver) define explicit SPIs that transport modules, monitoring adapters, and security tooling implement without cross-coupling.

The module carries zero runtime dependencies beyond kotlinx-coroutines-core.

Module boundary

┌──────────────────────────────────────────────┐
│               tramai-core                     │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │      annotations (10 types)     │         │
│   │  @AiService  @Operation         │         │
│   │  @AiTool     @SystemPrompt      │         │
│   │  @System     @User              │         │
│   │  @AiDescription  @AiMinItems    │         │
│   │  @AiRange    @ConversationId    │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │        model (20 files)         │         │
│   │  Message  ModelRequest          │         │
│   │  ModelResponse  StreamChunk     │         │
│   │  ToolCall  ToolResult           │         │
│   │  ToolDefinition  ToolExecCtx    │         │
│   │  TramaiTool  ResolvedTool       │         │
│   │  ModelRegistry  ContentPart     │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │     provider (5 files)          │         │
│   │  ModelProvider  ProviderRegistry│         │
│   │  StreamCapable  ProviderFailures│         │
│   │  ProviderRoutingPlan            │         │
│   │  + transport/ (4 files)         │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │   structured (6 files)          │         │
│   │  StructuredOutputContract       │         │
│   │  StructuredOutputHandler        │         │
│   │  StructuredOutputResult         │         │
│   │  StructuredOutputFailureCode    │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │  observation (5 + event/ 7)     │         │
│   │  OperationInterceptor           │         │
│   │  OperationObserver (->Obs.)     │         │
│   │  RuntimeEventCatalogue (event/) │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │  exception (11 files)           │         │
│   │  TramaiException (sealed)       │         │
│   │  ProviderException              │         │
│   │  StructuredOutputException      │         │
│   │  ConfigurationException         │         │
│   │  TimeoutException               │         │
│   │  ProviderCapabilityException    │         │
│   │  CircuitBreakerOpenException    │         │
│   │  TokenBudgetExceededException   │         │
│   │  ToolInvalidInputException      │         │
│   │  + approval / policy failures   │         │
│   └─────────────────────────────────┘         │
│                                               │
│   ┌─────────────────────────────────┐         │
│   │  other packages (59 files)      │         │
│   │  approval(22+5) policy(7)       │         │
│   │  security(8) memory(6) util(2)  │         │
│   │  workflow(3) observation/       │         │
│   │  secondary(2) coroutines(1)     │         │
│   │  retry(1) secret(1) nativeimage(1)│       │
│   └─────────────────────────────────┘         │
└──────────────────────────────────────────────┘

What tramai-core does NOT include:

  • No execution engine or proxy generation
  • No JSON parsing or schema generation — that lives in tramai-structured
  • No workflow/orchestration — that lives in tramai-orchestration
  • No observability runtime — that lives in tramai-observability
  • No framework integration — that lives in tramai-spring / tramai-standalone

Transport-adjacent invariants it does own: dev.tramai.core.provider.transport holds the low-level HTTP/stream machinery shared by the built-in adapters — request framing, Retry-After parsing, rejected-response handling, SSE framing, and the bounded-read helpers (readBoundedBody / readBoundedBodyBytes / readBoundedResponseBody, marked @ExperimentalProviderTransportApi). readBoundedBodyBytes reads at most limitBytes and throws IllegalArgumentException when the source exceeds it — no silent truncation. This package is cross-module implementation machinery, not stable application-facing API.

Dependency graph

tramai-core ─── kotlinx-coroutines-core (api)
     │
     ├── tramai-engine          (api)
     │       ├── tramai-security            (implementation)
     │       └── tramai-structured          (test only)
     │
     ├── tramai-openai
     ├── tramai-anthropic
     ├── tramai-ollama
     ├── tramai-standalone
     ├── tramai-spring
     └── tramai-testing

tramai-core is the single direct dependency of all transport, engine, and adapter modules. It sits at the bottom of the dependency stack.

Inner mechanics

Package structure (20 package directories, 127 main-source files; the approval row covers its gateway sub-package)

#PackageFilesPurpose
1dev.tramai.core.annotations10@AiService, @Operation, @AiTool, @System, @SystemPrompt, @User, @ConversationId, @AiDescription, @AiMinItems, @AiRange
2dev.tramai.core.model20Message, ContentPart, ModelRequest, ModelResponse, StreamChunk, ToolCall, ToolDefinition, TramaiTool, ResolvedTool, ToolResult, UsageMetrics, ToolExecutionContext, SideEffectLevel, MessageRole, FinishReason, ModelRegistry
3dev.tramai.core.provider5ModelProvider, ProviderCapability, ProviderRegistry, ProviderRoutingPlan, StreamCapable, ProviderFailures (utility functions)
4dev.tramai.core.provider.transport4Bounded body reads, request framing, Retry-After parsing (@ExperimentalProviderTransportApi)
5dev.tramai.core.structured6StructuredOutputContract, StructuredOutputHandler, StructuredOutputResult, StructuredOutputFailureCode
6dev.tramai.core.observation5OperationInterceptor, OperationObserver/OperationObservation, failure-isolating observers
7dev.tramai.core.observation.event7RuntimeEvent, RuntimeEvents catalogue and renderer
8dev.tramai.core.observation.secondary2Secondary-effect policy and internal-API opt-in
9dev.tramai.core.exception11TramaiException (sealed base), provider/structured/timeout/capability/circuit/budget/tool failures plus approval- and policy-domain exceptions
10dev.tramai.core.approval (+gateway)22 + 5Approval request/decision/token/continuation contracts and gateway types
11dev.tramai.core.policy7PolicyEngine, PolicyDecision, ToolSecurityMetadata, enforcement points
12dev.tramai.core.security8DLP interception, prompt sanitization, output validation
13dev.tramai.core.memory6ChatMemory, ChatMemoryStore, ConversationIdProvider, tokenizers
14dev.tramai.core.workflow3Workflow result types
15dev.tramai.core.util2ImageDownloader, outbound-address security
16dev.tramai.core.coroutines1Throwable.rethrowIfCancellation()
17dev.tramai.core.retry1RetryJitterSource seam
18dev.tramai.core.secret1SecretValueResolver, EnvironmentSecretValueResolver, FileSecretValueResolver, CompositeSecretValueResolver
19dev.tramai.core.nativeimage1NativeImageProxyConfig

Cancellation invariant (0.6.0): dev.tramai.core.coroutines.rethrowIfCancellation() — fun Throwable.rethrowIfCancellation() — rethrows when the throwable is a CancellationException. Providers, tools, and engine catch blocks call it before wrapping, logging, retrying, falling back, persisting failure state, or emitting failure evidence, so cancellation is always propagated and never converted into a retryable provider failure.

Class hierarchy

Kotlin annotations (runtime retention, must be documented)
  ├── @AiService              — marks an interface as a Tramai proxy target
  ├── @Operation              — declares one AI-backed method on an @AiService
  ├── @AiTool                 — registers a method as a callable tool (with optional governance metadata)
  ├── @SystemPrompt           — class-level system prompt
  ├── @System                 — method-level system message ({param} interpolation)
  ├── @User                   — method-level user message ({param} interpolation)
  ├── @ConversationId         — marks the conversation-identity parameter
  ├── @AiDescription          — schema hint for structured output properties
  ├── @AiMinItems             — minimum collection size constraint
  └── @AiRange                — numeric range constraint (min, max)

Data classes
  ├── Message(role, content, contentParts, toolCallId, toolCalls)
  ├── ModelRequest(model, messages, tools, maxTokens, temperature, timeoutMillis, ...)
  ├── ModelResponse(content, toolCalls, inputTokens, outputTokens, thinkingTokens, modelUsed, finishReason)
  ├── ToolCall(id, name, argumentsJson)
  ├── ToolDefinition(name, description, inputSchemaJson)
  ├── ToolExecutionContext(operationName, modelName, attemptNumber, conversationId, idempotencyKey, timeout, attributes)
  ├── UsageMetrics(inputTokens, outputTokens, thinkingTokens, imageCount, imageTokensEstimate)
  ├── StructuredOutputContract(targetType, schemaJson)
  ├── ProviderRoute(providerName, effectiveModelName)
  ├── ResolvedProviderRoute(providerName, provider, requestedModelName, effectiveModelName)
  ├── OperationCallContext(serviceInterface, methodName, providerId, requestedModel, attempt)

Sealed classes / interfaces
  ├── StreamChunk — sealed
  │   ├── Token(text)          — incremental text fragment
  │   ├── Complete(fullText, usage) — stream finished
  │   └── Error(cause)         — stream failed
  ├── ToolResult — sealed
  │   ├── Success(value)       — executed successfully
  │   ├── InvalidInput(message) — input rejected, feed back to model
  │   ├── TransientFailure(cause) — retry if idempotent
  │   └── PermanentFailure(message) — surface to caller
  ├── StructuredOutputResult — sealed interface
  │   ├── Success(value, rawResponse)
  │   └── Failure(rawResponse, errorSummary, feedbackMessage)
  └── TramaiException — sealed base for all exceptions

Interfaces
  ├── ModelProvider            — provider SPI: complete(request): ModelResponse, providerId(), supportsCapability(ProviderCapability)
  ├── ProviderCapability       — enum: VISION, TOOL_CALLING, STRUCTURED_OUTPUT, STREAMING
  ├── StreamCapable            — capability interface: stream(request): Flow<StreamChunk>
  ├── TramaiTool<I, O>         — user-facing tool contract (execute(input, context): O)
  ├── ResolvedTool             — engine-facing tool contract (execute(input, context): ToolResult)
  ├── StructuredOutputHandler  — pluggable schema/parse SPI
  ├── OperationObserver        — creates per-call observations
  ├── OperationObservation     — per-attempt callback set
  ├── OperationInterceptor     — request/response inspection + modification
  └── SecretValueResolver      — resolves external secret references

Utility objects / classes
  ├── CompositeOperationInterceptor — chains multiple interceptors
  ├── NoOpOperationInterceptor      — no-op default
  ├── NoOpOperationObserver         — no-op observer
  ├── NoOpOperationObservation      — no-op observation
  ├── EnvironmentSecretValueResolver — resolves "env:VAR" references
  ├── FileSecretValueResolver        — resolves "file:path" references under an allowed directory
  ├── CompositeSecretValueResolver   — chains multiple resolvers
  └── NativeImageProxyConfig         — generates GraalVM proxy metadata

SPI system

tramai-core defines five extension SPIs. Each has a well-defined lifecycle:

SPIInterfaceImplemented byRegistration
ProviderModelProvider (+ StreamCapable)tramai-openai, tramai-anthropic, tramai-ollama, custom providersProviderRegistry.builder().provider(...)
Structured OutputStructuredOutputHandlertramai-structured (Jackson handler)Engine constructor
ObservationOperationObservertramai-observability, custom observersEngine constructor
InterceptionOperationInterceptorCustom PII redaction, logging, auditingCompositeOperationInterceptor
Secret ResolutionSecretValueResolverBuilt-in env:/file: resolvers, custom resolversCompositeSecretValueResolver

Resolution pipeline (for ModelProvider):

@Operation(model="gpt-4o", provider="")
         │
         ▼
ProviderRoutingPlan.resolveCandidates(operation)   // via ProviderRegistry.resolveCandidates
    │
    ├── @Operation.provider != "" → lookup by provider name
    ├── routes[requestedModel] → mapped routes (primary + fallbacks)
    └── defaultProvider → fallback default
         │
         ▼
    List<ResolvedProviderRoute>(provider, requestedModelName, effectiveModelName)
         │
         ▼
    engine → provider.complete(request)

ProviderRegistry.resolve(operation) is the single-provider convenience form (returns the first ModelProvider); resolveCandidates(operation) returns the full route list the engine iterates for fallback.

Error model

tramai-core defines a sealed exception hierarchy rooted at TramaiException (which extends RuntimeException):

TramaiException (sealed, extends RuntimeException)
  ├── StructuredOutputException
  │   └── carries: originalPrompt, lastRawResponse, validationError, attemptCount
  ├── ProviderException
  │   └── carries: statusCode, retryable, retryAfterMillis
  ├── ConfigurationException
  ├── TimeoutException
  ├── ProviderCapabilityException
  │   └── carries: providerId, capability (String, e.g. "VISION" / "streaming")
  ├── CircuitBreakerOpenException
  │   └── carries: providerId, reopenAtEpochMillis
  ├── TokenBudgetExceededException
  │   └── carries: scope, limitTokens, observedTokens, providerId, modelName
  └── ToolInvalidInputException
     └── optional safeModelMessage; use withSafeModelMessage(...) to make text model-visible

The sealed hierarchy also carries the approval- and policy-domain failures (ApprovalException and its subclasses, ApprovalRequiredException, ApprovalSuspendedException, PolicyViolationException, ModelRegistryException).

Error handling rules:

  • ProviderException with retryable=true — engine should retry per @Operation.providerRetries. Retryable status codes: 408, 425, 429, 500, 502, 503, 504. Transport failures (timeouts, connection refused, IO errors) are classified as retryable.
  • ProviderException with retryable=false — non-retryable HTTP errors (4xx except 408/425/429), surfaced to caller immediately.
  • StructuredOutputException — the engine retries up to @Operation(maxRetries) times (total attempts = maxRetries + 1), appending the feedbackMessage from StructuredOutputResult.Failure to each subsequent prompt. Engine-produced instances come from safeStructuredOutputFailure(code, attemptCount), so they carry a fixed public message, attemptCount, and a typed StructuredOutputFailureCode — never the raw response or prompt; that detail goes to the structured-output diagnostic observer. The originalPrompt / lastRawResponse / validationError properties exist on the class and stay null for engine-produced instances.
  • ConfigurationException — raised at resolution time when providers are unregistered or misconfigured. Never retried.
  • CircuitBreakerOpenException — the engine either falls back to another provider route or surfaces the failure.
  • TokenBudgetExceededException — the engine terminates the current attempt and surfaces the failure.
  • ToolInvalidInputException — tool-authored validation failure; engine feeds back to the model for correction.

ProviderFailures.kt provides utility functions for normalizing provider errors:

fun providerHttpFailure(providerName, statusCode, body, retryAfterHeader): ProviderException
fun providerTransportFailure(providerName, error): ProviderException

Testing strategy

tramai-core has 38 test files (in src/test/kotlin). Three of the load-bearing ones:

Test fileWhat it covers
ProviderRegistryTest.ktRegistry resolution: explicit provider, model mapping, fallback routes, default provider, unknown provider errors, configuration failure edge cases
ProviderFailuresTest.ktHTTP failure normalization, transport failure mapping (timeout, connect, IO), retry-after header parsing
NativeImageProxyConfigTest.ktJSON generation format, validation of @AiService annotation presence, validation of interface requirement

Testing philosophy for consumers of tramai-core:

  • Annotations are compile-time contracts — test that they're present and carry expected values via reflection when building custom scanners.
  • Data models are pure data — no behavior to test beyond ModelResponse.totalTokens().
  • Provider implementations — test that ModelRequest → provider transport → ModelResponse round-trips correctly.
  • ProviderRegistry — test that resolution order matches the documented priority: @Operation.provider > model mapping > default provider > ConfigurationException.