TramAI - governed AI workflows for Java and Kotlin

Adding a Provider

A provider adapter is a new module (for example tramai-<vendor>) that implements ModelProvider and proves vendor-wire conformance by enrolling in ProviderTck. Providers are not service-loaded: registration is explicit through ProviderRegistry.builder().

The Extension Point

ContractDeclaration
ModelProvidertramai-core/src/main/kotlin/dev/tramai/core/provider/ModelProvider.kt:19
suspend fun complete(request: ModelRequest): ModelResponseModelProvider.kt:23 — the only abstract member
fun providerId(): StringModelProvider.kt:28 — defaults to the simple class name; the stable identity used by the registry and the TCK
fun supportsCapability(capability: ProviderCapability): BooleanModelProvider.kt:34 — defaults to false
ProviderCapabilityModelProvider.kt:9-14 — VISION, TOOL_CALLING, STRUCTURED_OUTPUT, STREAMING
StreamCapabletramai-core/src/main/kotlin/dev/tramai/core/provider/StreamCapable.kt — fun stream(request: ModelRequest): Flow<StreamChunk>; declare it only if you claim STREAMING

There is no ModelProviderAdapter interface. The SPI lives in tramai-core; shared conformance fixtures live in tramai-testing.

Minimum Implementation

package com.example.gateway

import dev.tramai.core.coroutines.rethrowIfCancellation
import dev.tramai.core.model.ModelRequest
import dev.tramai.core.model.ModelResponse
import dev.tramai.core.provider.ModelProvider
import dev.tramai.core.provider.ProviderCapability
import dev.tramai.core.provider.providerTransportFailure

class InternalGatewayProvider(
    private val baseUrl: String,
    private val apiKey: String,
) : ModelProvider {

    override fun providerId(): String = "internal-gateway"

    override fun supportsCapability(capability: ProviderCapability): Boolean =
        capability == ProviderCapability.TOOL_CALLING

    override suspend fun complete(request: ModelRequest): ModelResponse {
        val response = try {
            postChatCompletion(request)
        } catch (e: Exception) {
            e.rethrowIfCancellation()
            throw providerTransportFailure(providerId(), e)
        }
        return toModelResponse(response, request)
    }
}

Failures are produced through the factories in tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderFailures.kt:

FactoryUse
providerHttpFailure(providerName, statusCode, body, retryAfterHeader)Non-2xx HTTP response (ProviderFailures.kt:60)
providerTransportFailure(providerName, error)I/O, timeout, connection, or SDK failure (ProviderFailures.kt:145)
safeProviderFailure(message, code, statusCode, retryable, retryAfterMillis)Caller-controlled message and explicit failure code (ProviderFailures.kt:37)
providerHttpFailureObserved(...) / providerTransportFailureObserved(...)Same, with sanitized delivery to a ProviderFailureDiagnosticObserver (ProviderFailures.kt:74, :152)

Both transport factories call rethrowIfCancellation() first, then return a sanitized ProviderException. Failure codes come from ProviderFailureCode (tramai-core/src/main/kotlin/dev/tramai/core/exception/ProviderFailureCode.kt:11-26): HTTP_REJECTED, TIMEOUT, CONNECTION_FAILED, TRANSPORT_FAILED, UNEXPECTED_FAILURE. HTTP status, retryability, and retry timing travel on ProviderException.statusCode, .retryable, and .retryAfterMillis rather than extra enum values. Never interpolate provider responses or throwable messages into a message that is emitted verbatim, and never leak raw vendor errors to callers.

Error bodies are bounded: readErrorBodyPreview(input, limitBytes) caps a diagnostic preview at PROVIDER_ERROR_BODY_LIMIT_BYTES (8 KiB) — ProviderFailures.kt:23, :249.

Registration Is Explicit

ProviderRegistry is a compatibility facade over a frozen ProviderRoutingPlan (tramai-core/src/main/kotlin/dev/tramai/core/provider/ProviderRegistry.kt:32); every operation delegates to that exact plan instance. Resolution is registry-based — a model name is mapped to a provider by explicit configuration, never by heuristic prefix matching.

val registry = ProviderRegistry.builder()
    .provider("gateway", InternalGatewayProvider(baseUrl, apiKey), default = true)
    .model("gpt-4o", "gateway")
    .build()

Applications normally configure the same plan through the runtime builders instead:

val tramai = Tramai {
    provider(InternalGatewayProvider(baseUrl, apiKey), name = "gateway", default = true)
    model("gpt-4o", "gateway")
}

Do not add a META-INF/services file. Providers are registered, not discovered.

Files That Change

FileChange
tramai-<vendor>/New module: build.gradle.kts, the ModelProvider implementation, and the generated api/tramai-<vendor>.api dump
settings.gradle.ktsRegister the Gradle project
config/quality/module-catalog.ymlNew entry using the shared &provider anchor (:16) — see the tramai-deepseek template at :131-137
config/quality/module-boundaries.ymlUsually unchanged (defaults already forbid published → internal and cycles)
tramai-<vendor>/src/test/kotlin/.../<Vendor>ProviderTckTest.ktThe TCK enrolment runner
docs/modules/tramai-<vendor>.mdModule card

A catalog entry mirrors this shape:

  - path: ":tramai-<vendor>"
    description: "<Vendor> provider integration for Tramai."
    <<: *provider
    rationale: "Provider adapter for <Vendor> models via the TramAI provider SPI."
    layer: provider-adapters
    publishability: published
    apiStability: preview

The Transport Boundary

Transport helpers are gated behind dev.tramai.core.provider.transport.ExperimentalProviderTransportApi, which sits outside the stable compatibility promise. Existing adapters opt in at file level:

@file:OptIn(dev.tramai.core.provider.transport.ExperimentalProviderTransportApi::class)

(tramai-openai/src/main/kotlin/dev/tramai/openai/OpenAiProvider.kt:1 shows the pattern.) Provider response bodies must be read through the bounded helpers — raw unbounded body reads are banned by verifyStaticSafetyGuards.

Mandatory Contract Tests

TestWhere
ProviderTck (abstract, abstract val harness: ProviderTckHarness)tramai-testing/src/testFixtures/kotlin/dev/tramai/testing/provider/ProviderTck.kt:51
ProviderTckHarness — expectedProviderId, expectedCapabilities, createProvider, transport, plus happy-path / error / tool / vision / structured / streaming specstramai-testing/src/testFixtures/kotlin/dev/tramai/testing/provider/ProviderTckHarness.kt:96-123
StubHttpClient, ProviderHttpFixtures, TrackingInputStreamsame provider/ fixture package
ProviderTckEnrollmentArchitectureTesttramai-testing/src/test/kotlin/dev/tramai/testing/ProviderTckEnrollmentArchitectureTest.kt

ProviderTck is a deterministic, offline behavioural contract: identity, capability, cancellation, safe-error redaction, HTTP wire behaviour, tools, vision, structured output, and streaming. All requests are answered by canned StubHttpClient responses — no network, no credentials. The harness pins expectedProviderId and expectedCapabilities, so a provider cannot make a contract test disappear by changing its own capability declaration.

Enrolment is architecture-enforced: every new ModelProvider implementation needs a runner named after it in its own module, or ProviderTckEnrollmentArchitectureTest fails. That gate runs inside verify060Architecture.

Runner templates: tramai-deepseek/src/test/kotlin/dev/tramai/deepseek/DeepSeekProviderTckTest.kt, tramai-azure-openai/src/test/kotlin/dev/tramai/azureopenai/AzureOpenAiProviderTckTest.kt, and the SDK-seam variant tramai-bedrock/src/test/kotlin/dev/tramai/bedrock/BedrockProviderTckTest.kt.

Binary compatibility is mandatory and generated:

./gradlew apiDump   # regenerates <module>/api/<module>.api — commit it; verified by apiCheck

Verification

./gradlew :tramai-<vendor>:test --tests '*ProviderTckTest'
./gradlew verify060Architecture        # provider-contracts + api-architecture
./gradlew apiCheck
./gradlew verifyPr                     # primary gate
./scripts/verify-zero-egress.sh        # CI-level Docker --network=none harness

What Not To Change In This Pull Request

  • Engine retry, fallback, and admission. ProviderExecutionCoordinator owns those. Provider adapters do transport and vendor translation only.
  • ProviderTck itself. It is an independent oracle; do not weaken it to make a provider pass.
  • tramai-core provider registry. No changes needed unless the SPI itself is extended — that is a separate public-api-classified change.
  • config/quality/0.6.0-baseline.json. Never edited in the same pull request as an implementation change.

Common Mistakes

SymptomCause
verify060Architecture fails on provider-contractsMissing or misnamed <Vendor>ProviderTckTest runner
verify060Architecture fails on catalog checksModule not registered in config/quality/module-catalog.yml
Routing breaks after releaseproviderId() changed — it is a stable identity
Raw vendor JSON reaches callersVendor error leaked instead of a ProviderException with a ProviderFailureCode
Cancellation tests failCancellationException wrapped or swallowed instead of rethrown via rethrowIfCancellation()
Retry behaviour duplicated in the adapterRetry/fallback/circuit logic belongs in tramai-engine, not the provider