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
| Contract | Declaration |
|---|---|
ModelProvider | tramai-core/src/main/kotlin/dev/tramai/core/provider/ModelProvider.kt:19 |
suspend fun complete(request: ModelRequest): ModelResponse | ModelProvider.kt:23 — the only abstract member |
fun providerId(): String | ModelProvider.kt:28 — defaults to the simple class name; the stable identity used by the registry and the TCK |
fun supportsCapability(capability: ProviderCapability): Boolean | ModelProvider.kt:34 — defaults to false |
ProviderCapability | ModelProvider.kt:9-14 — VISION, TOOL_CALLING, STRUCTURED_OUTPUT, STREAMING |
StreamCapable | tramai-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:
| Factory | Use |
|---|---|
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
| File | Change |
|---|---|
tramai-<vendor>/ | New module: build.gradle.kts, the ModelProvider implementation, and the generated api/tramai-<vendor>.api dump |
settings.gradle.kts | Register the Gradle project |
config/quality/module-catalog.yml | New entry using the shared &provider anchor (:16) — see the tramai-deepseek template at :131-137 |
config/quality/module-boundaries.yml | Usually unchanged (defaults already forbid published → internal and cycles) |
tramai-<vendor>/src/test/kotlin/.../<Vendor>ProviderTckTest.kt | The TCK enrolment runner |
docs/modules/tramai-<vendor>.md | Module 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
| Test | Where |
|---|---|
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 specs | tramai-testing/src/testFixtures/kotlin/dev/tramai/testing/provider/ProviderTckHarness.kt:96-123 |
StubHttpClient, ProviderHttpFixtures, TrackingInputStream | same provider/ fixture package |
ProviderTckEnrollmentArchitectureTest | tramai-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.
ProviderExecutionCoordinatorowns those. Provider adapters do transport and vendor translation only. ProviderTckitself. It is an independent oracle; do not weaken it to make a provider pass.tramai-coreprovider registry. No changes needed unless the SPI itself is extended — that is a separatepublic-api-classified change.config/quality/0.6.0-baseline.json. Never edited in the same pull request as an implementation change.
Common Mistakes
| Symptom | Cause |
|---|---|
verify060Architecture fails on provider-contracts | Missing or misnamed <Vendor>ProviderTckTest runner |
verify060Architecture fails on catalog checks | Module not registered in config/quality/module-catalog.yml |
| Routing breaks after release | providerId() changed — it is a stable identity |
| Raw vendor JSON reaches callers | Vendor error leaked instead of a ProviderException with a ProviderFailureCode |
| Cancellation tests fail | CancellationException wrapped or swallowed instead of rethrown via rethrowIfCancellation() |
| Retry behaviour duplicated in the adapter | Retry/fallback/circuit logic belongs in tramai-engine, not the provider |
