TramAI

Module: tramai-openai

One-liner: OpenAI-compatible model provider with support for OpenAI API, Together AI, Groq, vLLM, and any /chat/completions endpoint. Module type: providerSource files: 2 — OpenAiProvider.kt (430 LOC) + ExperimentalCodexAuth.ktTest files: 2 — OpenAiProviderTest.kt, OpenAiProviderIntegrationTest.ktBuild: dev.tramai:tramai-openai:0.3.1


L1: Quick Start (30-second read)

What

tramai-openai is a ModelProvider + StreamCapable implementation that connects Tramai to any OpenAI-compatible /chat/completions REST endpoint. It ships two provider classes in a class-hierarchy:

  • OpenAiCompatibleProvider — generic provider for any OpenAI-compatible API (Together AI, Groq, vLLM, Ollama's OpenAI compatibility layer, Azure OpenAI, etc.)
  • OpenAiProvider — subclass pre-configured with https://api.openai.com/v1 as the default baseUrl, plus organization and project header support for OpenAI's public API

Why

OpenAI's /chat/completions format has become the de facto standard for LLM APIs. Virtually every major inference provider — Together AI, Groq, Fireworks, DeepSeek, vLLM, llama.cpp, Ollama — exposes an OpenAI-compatible endpoint. By targeting this single wire format, tramai-openai covers dozens of providers with one module. Separating it from tramai-core keeps provider-specific transport and JSON payload code out of the core SPI module.

When to use

  • OpenAI API — use OpenAiProvider(apiKey = "...") with the public OpenAI endpoint
  • OpenAI-compatible third parties — use OpenAiCompatibleProvider.bearerToken(...) with a custom baseUrl (Together AI, Groq, Fireworks, DeepSeek, etc.)
  • Self-hosted vLLM / llama.cpp — use OpenAiCompatibleProvider.bearerToken(...) pointed at your local server
  • Local testing without an API key — use OpenAiCompatibleProvider.codexAuth() to reuse your Codex CLI's ChatGPT OAuth token (experimental)

Don't use this module when you need a non-OpenAI-compatible provider — use tramai-anthropic (Claude's Messages API) or tramai-ollama (Ollama's native /api/chat endpoint) instead.

How to add

Gradle (Kotlin DSL):

dependencies {
    implementation("dev.tramai:tramai-openai:0.3.1")
}

Maven:

<dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-openai</artifactId>
    <version>0.3.1</version>
</dependency>

Bill of Materials:

implementation(platform("dev.tramai:tramai-bom:0.3.1"))
implementation("dev.tramai:tramai-openai")

Where to go next

If you want to...Go here
Wire a provider into a working appdocs/modules/tramai-standalone.md
Use Spring Boot auto-configurationdocs/modules/tramai-spring.md
Understand the provider SPI contractdocs/modules/tramai-core.md (L3: ModelProvider)
Learn about streaming in generaldocs/modules/tramai-engine.md (L2: Streaming)
See the full OpenAI-compatible wire format specdocs/specs/spec-003.md

L2: Usage Guide (5-minute read)

Quick usage

The simplest path: create an OpenAiProvider with an API key and wire it through TramaiEngine or Tramai (standalone).

import dev.tramai.core.annotations.AiService
import dev.tramai.core.annotations.Operation
import dev.tramai.openai.OpenAiProvider
import dev.tramai.standalone.Tramai

@AiService
@SystemPrompt("You are a helpful assistant.")
interface ChatService {
    @Operation(prompt = "What is the capital of Japan?", model = "gpt-4o")
    suspend fun ask(): String
}

suspend fun main() {
    val chat = Tramai
        .builder()
        .provider(OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY")), default = true)
        .build()
        .create<ChatService>()

    println(chat.ask()) // "Tokyo"
}

Using OpenAiProvider directly with TramaiEngine:

val provider = OpenAiProvider(
    apiKey = System.getenv("OPENAI_API_KEY"),
    organization = "org-abc123",   // optional: OpenAI organization ID
    project = "proj-def456",       // optional: OpenAI project ID
)
val engine = TramaiEngine(provider)
val service = engine.create<ChatService>()

Advanced usage

Custom base URL — Together AI / Groq / vLLM

Any provider that exposes an OpenAI-compatible /chat/completions endpoint works via OpenAiCompatibleProvider. Set baseUrl to the provider's root URL (the provider appends /chat/completions):

// Together AI
val together = OpenAiCompatibleProvider.bearerToken(
    bearerToken = System.getenv("TOGETHER_API_KEY"),
    baseUrl = "https://api.together.xyz/v1",
    providerName = "together",
)

// Groq
val groq = OpenAiCompatibleProvider.bearerToken(
    bearerToken = System.getenv("GROQ_API_KEY"),
    baseUrl = "https://api.groq.com/openai/v1",
    providerName = "groq",
)

// Self-hosted vLLM
val vllm = OpenAiCompatibleProvider.bearerToken(
    bearerToken = "not-needed",  // vLLM often doesn't require auth locally
    baseUrl = "http://localhost:8000/v1",
    providerName = "vllm",
)

Then register them in the provider registry:

val service = Tramai
    .builder()
    .provider(together, name = "together")
    .provider(groq, name = "groq")
    .model("mixtral-8x22b", "together")
    .model("llama-3.3-70b", "groq")
    .defaultProvider("groq")
    .build()
    .create<MultiModelService>()

Streaming

Streaming uses SSE (Server-Sent Events) — each data: {...} line is parsed and emitted as a StreamChunk. The provider implements StreamCapable:

import dev.tramai.core.model.StreamChunk
import dev.tramai.core.model.Message
import dev.tramai.core.model.MessageRole
import dev.tramai.core.model.ModelRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking

val provider = OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY"))

val request = ModelRequest(
    model = "gpt-4o",
    messages = listOf(Message(MessageRole.USER, "Count from 1 to 5")),
)

runBlocking {
    provider.stream(request).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("\n[Error: ${chunk.cause.message}]")
        }
    }
}

Via @AiService with a Flow<StreamChunk> return type (requires tramai-engine):

@AiService
interface StreamingService {
    @Operation(prompt = "Tell me a story", model = "gpt-4o")
    fun stream(): Flow<StreamChunk>
}

Codex auth (experimental)

For local development, you can reuse the ChatGPT OAuth access token stored by Codex CLI (~/.codex/auth.json). This avoids manually managing API keys during testing.

import dev.tramai.openai.OpenAiProvider
import dev.tramai.openai.ExperimentalCodexAuth

@OptIn(ExperimentalCodexAuth::class)
fun localProvider(): OpenAiProvider = OpenAiProvider.codexAuth()

The codexAuth() factory reads the auth_mode == "chatgpt" section of the auth file and extracts tokens.access_token. It throws ConfigurationException with a clear message if the file is missing, unparseable, or not configured for ChatGPT authentication.

You can also use it with OpenAiCompatibleProvider for non-OpenAI endpoints:

@OptIn(ExperimentalCodexAuth::class)
val localCompat = OpenAiCompatibleProvider.codexAuth(
    baseUrl = "http://localhost:8000/v1",
    providerName = "local-vllm",
)

Tool calling

Tool definitions are passed through ModelRequest.tools and serialized to the OpenAI tool-calling format. The response's tool_calls array is parsed back into ToolCall objects:

val request = ModelRequest(
    model = "gpt-4o",
    messages = listOf(Message(MessageRole.USER, "What's the weather in Paris?")),
    tools = listOf(
        ToolDefinition(
            name = "get_weather",
            description = "Get current temperature for a city",
            inputSchemaJson = """{"type":"object","properties":{"city":{"type":"string"}}}""",
        ),
    ),
)
val response = provider.complete(request)
response.toolCalls?.forEach { toolCall ->
    println("Call ${toolCall.name} with ${toolCall.argumentsJson}")
}

Custom HTTP client and ObjectMapper

Both provider constructors accept custom HttpClient and ObjectMapper instances:

val customClient = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(15))
    .build()

val customMapper = JsonMapper.builder()
    .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
    .build()

val provider = OpenAiProvider(
    apiKey = System.getenv("OPENAI_API_KEY"),
    httpClient = customClient,
    objectMapper = customMapper,
)

Expert usage

Custom OpenAiAccessTokenSource

Beyond the built-in StaticOpenAiAccessTokenSource and CodexAuthFileTokenSource, you can implement your own token source for dynamic credential resolution:

class VaultTokenSource : OpenAiAccessTokenSource {
    override fun accessToken(): String {
        // Fetch dynamically from HashiCorp Vault, AWS Secrets Manager, etc.
        return fetchSecret("openai/api-key")
    }
}

val provider = OpenAiProvider(
    accessTokenSource = VaultTokenSource(),
)

Array-based content handling

OpenAI-compatible APIs sometimes return content as an array of content blocks (text, image URLs, etc.) instead of a plain string. The extractContent method in OpenAiCompatibleProvider handles both forms — it joins text blocks with newlines:

{
  "message": {
    "content": [
      { "type": "text", "text": "Hello" },
      { "type": "text", "text": "World" }
    ]
  }
}

"Hello\nWorld"

Configuration reference

ParameterTypeDefaultDescription
accessTokenSourceOpenAiAccessTokenSource(required)Source for the Authorization: Bearer token
baseUrlStringhttps://api.openai.com/v1 (OpenAiProvider) / (required) (OpenAiCompatibleProvider)Root URL of the OpenAI-compatible API
providerNameString"openai" / "openai-compatible"Value returned by providerId()
httpClientHttpClientHttpClient.newHttpClient()Java java.net.http.HttpClient instance
objectMapperObjectMapperObjectMapper()Jackson ObjectMapper for JSON serialization
organizationString?nullOpenAI-Organization header (OpenAiProvider only)
projectString?nullOpenAI-Project header (OpenAiProvider only)

Factory methods on OpenAiCompatibleProvider:

MethodToken sourceUse case
bearerToken(token, baseUrl, ...)StaticOpenAiAccessTokenSourceStandard API-key providers
codexAuth(baseUrl, ...)CodexAuthFileTokenSourceLocal testing with Codex CLI auth (experimental)

Factory methods on OpenAiProvider (adds defaults for baseUrl):

MethodSame as
OpenAiProvider(apiKey, ...)Constructor — uses StaticOpenAiAccessTokenSource
OpenAiProvider.bearerToken(...)Same as constructor with explicit bearer token
OpenAiProvider.codexAuth(...)Uses CodexAuthFileTokenSource with OpenAI's base URL

L3: Architecture & Mechanics (15-minute read)

Design philosophy

tramai-openai follows a hierarchical, composable design: a single generic base class (OpenAiCompatibleProvider) handles all OpenAI-compatible wire formats, and a thin subclass (OpenAiProvider) adds OpenAI-specific defaults and headers. There is no duplication between the two — OpenAiProvider is purely a configuration specialization.

The authentication model is strategy-based: OpenAiAccessTokenSource is a fun interface (SAM) so any token resolution strategy can be injected. The two built-in implementations (StaticOpenAiAccessTokenSource and CodexAuthFileTokenSource) cover 95% of use cases, but the SPI is open for custom implementations.

Module boundary

Public API:
  dev.tramai.openai
    OpenAiProvider                    — ModelProvider + StreamCapable (OpenAI-specific)
      constructor(apiKey, baseUrl?, httpClient?, objectMapper?, organization?, project?)
      constructor(accessTokenSource, ...)
      bearerToken(token, ...)         — factory method
      codexAuth(...)                  — factory method (@ExperimentalCodexAuth)
      providerId(): String            — always "openai"

    OpenAiCompatibleProvider          — ModelProvider + StreamCapable (generic)
      constructor(accessTokenSource, baseUrl, providerName?, ...)
      bearerToken(token, baseUrl, ...) — factory method
      codexAuth(baseUrl, ...)         — factory method (@ExperimentalCodexAuth)
      providerId(): String            — configurable via constructor

    OpenAiAccessTokenSource           — fun interface SAM
      fun accessToken(): String

    StaticOpenAiAccessTokenSource     — wraps a static string token
      constructor(token: String)

    CodexAuthFileTokenSource          — reads ~/.codex/auth.json (@ExperimentalCodexAuth)
      constructor(authFile?, objectMapper?)
      defaultAuthFile(): Path         — companion static method

    @ExperimentalCodexAuth            — opt-in annotation (RequiresOptIn)

Package: dev.tramai.openai
  2 source files, no sub-packages.

Class hierarchy

OpenAiCompatibleProvider  (open)     ← implements ModelProvider, StreamCapable
    │
    └── OpenAiProvider   (final)     ← pre-configures baseUrl, organization, project

OpenAiAccessTokenSource   (fun interface)
    ├── StaticOpenAiAccessTokenSource
    └── CodexAuthFileTokenSource     ← @ExperimentalCodexAuth

Dependency graph

tramai-openai
  Depends on:
    - tramai-core (api)        — ModelProvider, ModelRequest, ModelResponse,
                                 StreamCapable, StreamChunk, ProviderException
    - jackson-databind (impl)  — JSON serialization/deserialization of payloads
    - kotlinx-coroutines-core  — Flow-based streaming via stream()

  Depended on by:
    - tramai-standalone        — wired via Tramai.builder().provider()
    - tramai-spring            — auto-configuration discovers OpenAiProvider beans

Inner mechanics

/chat/completions endpoint flow (non-streaming)

1. User calls service.method() via @AiService proxy
2. Engine builds ModelRequest (model, messages, tools, parameters)
3. Engine resolves provider → calls OpenAiProvider.complete(request)
4. Provider serializes payload on Dispatchers.IO:

   POST {baseUrl}/chat/completions
   Authorization: Bearer <token>
   Content-Type: application/json
   OpenAI-Organization: <org>        (OpenAiProvider only)
   OpenAI-Project: <project>         (OpenAiProvider only)

   {
     "model": "gpt-4o",
     "stream": false,
     "messages": [
       {"role": "system", "content": "You are helpful."},
       {"role": "user", "content": "What is the capital of Japan?"}
     ],
     "tools": [ ... ],               (if tool definitions provided)
     "max_tokens": 200,
     "temperature": 0.7
   }

5. Response parsed:

   {
     "model": "gpt-4o-2024-08-06",
     "choices": [{
       "message": {
         "role": "assistant",
         "content": "Tokyo",
         "tool_calls": [...]         (if model requested tools)
       },
       "finish_reason": "stop"
     }],
     "usage": {
       "prompt_tokens": 25,
       "completion_tokens": 3
     }
   }

6. Provider constructs ModelResponse:
   - content          ← message.content (supports string or array-of-content-blocks)
   - toolCalls        ← message.tool_calls array → List<ToolCall>
   - inputTokens      ← usage.prompt_tokens
   - outputTokens     ← usage.completion_tokens
   - modelUsed        ← response.model
   - finishReason     ← choices[0].finish_reason ("stop"→STOP, "length"→LENGTH,
                       "tool_calls"→STOP, "content_filter"→CONTENT_FILTER, else→OTHER)

7. ModelResponse returned to engine

Streaming via SSE

For streaming, the provider sends the same request with "stream": true and consumes the response as a line-based SSE stream via HttpResponse.BodyHandlers.ofLines():

1. Provider builds payload with "stream": true
2. HTTP POST to {baseUrl}/chat/completions
3. Response consumed as Flow<StreamChunk> via kotlinx.coroutines.flow.flow { }

   SSE data lines arrive as:
     data: {"choices":[{"delta":{"role":"assistant"}}]}
     data: {"choices":[{"delta":{"content":"Tok"}}]}
     data: {"choices":[{"delta":{"content":"yo"}}]}
     data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}
     data: {"usage":{"prompt_tokens":25,"completion_tokens":3}}
     data: [DONE]

4. Each line parsed:
   - Starts with "data: " → strip prefix
   - "[DONE]" → end of stream
   - Otherwise → parse JSON, extract choices[0].delta.content
   - Non-empty content → emit StreamChunk.Token(text)
   - usage present → capture final UsageMetrics

5. On stream end → emit StreamChunk.Complete(fullText, usage)
6. On parse/transport error → emit StreamChunk.Error(exception)

   All SSE parsing happens on the IO dispatcher.
   The calling collect() runs on whatever coroutine context the caller provides.

Finish reason mapping

OpenAI finish_reasonTramai FinishReasonNotes
"stop"STOPModel finished naturally
"length"LENGTHHit max_tokens limit
"tool_calls"STOPMapped to STOP for simple orchestration — engine handles tool loop
"content_filter"CONTENT_FILTERContent filter flagged output
(anything else)OTHERUnknown/unspecified

Authentication

The provider never stores credentials — it delegates to OpenAiAccessTokenSource.accessToken() at request time. This means:

  • Short-lived tokens (OAuth, STS) can be refreshed dynamically
  • Token sources can be swapped without reconstructing the provider
  • CodexAuthFileTokenSource re-reads the file on every call (safe for rotated tokens)

Error model

ExceptionTriggerRetryableRecovery
ProviderException (via providerHttpFailure)Non-2xx HTTP status (4xx, 5xx)Yes for 408/425/429/500/502/503/504Check API key, rate limits, provider health
ProviderException (via providerTransportFailure)Network timeout, connection refused, IO errorYes — classified as transientVerify endpoint reachability and network
ProviderExceptionEmpty choices array in responseNo — malformed responseCheck API version compatibility
ConfigurationExceptionBlank/empty API tokenN/A — construction-timeProvide a non-blank token
ConfigurationException (CodexAuth)Missing auth file or wrong auth_modeN/A — construction-timeRun Codex CLI login or use a different token source

HTTP error handling details:

  • The Retry-After header is parsed from non-2xx responses and exposed via error.retryAfterMillis so the engine can honor provider-side rate-limiting guidance.
  • Error responses that include a JSON body are attached to the exception message for debugging.

Testing strategy

  • OpenAiProviderTest.kt — Uses a local HttpServer (com.sun.net.httpserver) to simulate an OpenAI-compatible endpoint without network calls. Covers:
    • API key authentication headers (Authorization, OpenAI-Organization, OpenAI-Project)
    • Request body serialization (model, messages, max_tokens, temperature)
    • Response parsing (content, token usage, finish reason, model name)
    • Array-based content blocks (for multi-modal / content-list responses)
    • OpenAiCompatibleProvider.bearerToken() factory
    • HTTP 429 rate-limiting with Retry-After header parsing
    • Empty choices array error handling
    • CodexAuthFileTokenSource: successful token reading
    • CodexAuthFileTokenSource: misconfigured auth file (auth_mode != "chatgpt")
  • OpenAiProviderIntegrationTest.kt — Integration test against a real OpenAI endpoint (requires TRAMAI_OPENAI_API_KEY environment variable or uses bearerToken fallback).