feat(qa): remote NIM provider + headless-QA robustness

Enable autonomous QA through a remote OpenAI-compatible provider (NVIDIA NIM)
and harden the tool/approval path so unattended multi-stage runs complete.

- inference: add openai_compat provider (Bearer chat-completions for NIM/OpenAI),
  dispatched by provider type "nim"/"openai"; key via api_key/api_key_env.
- server: bind configured [server] host/port instead of a hardcoded 8080;
  POST /sessions accepts an optional `intent` (WS parity) for intent-driven workflows.
- kernel: thread the bound operator profile's approval_mode into per-tool gating so
  auto/yolo enable unattended approval (engine still consulted; policy/plane-2 BLOCK
  stays terminal); on a recoverable tool failure feed the tool's arg-schema back into
  context so the model self-corrects instead of repeating a malformed call.
- tools: split deletion out of file_write into a separate, explicitly-named file_delete
  tool — a model can no longer delete a file by getting a write-mode parameter wrong.
- server: add GET /metrics/tool-reliability — per-model tool-call validity from the
  event log (measurement groundwork for capability-aware routing).
- docs: update AGENTS.md across kernel, tools, server, inference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:50:16 +00:00
parent d26f20c316
commit 238d353653
27 changed files with 924 additions and 268 deletions
+5 -2
View File
@@ -2,7 +2,7 @@
## Purpose
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`) and the llama.cpp server adapter (`llama_cpp/`).
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`), the llama.cpp server adapter (`llama_cpp/`), and the remote OpenAI-compatible adapter (`openai_compat/`, e.g. NVIDIA NIM).
## Ownership
@@ -13,8 +13,9 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
- `DefaultProviderRegistry` implements `ProviderRegistry` from `core:inference`.
- `FirstAvailableRoutingStrategy` is the default routing policy; extend only in `core:inference`, not here.
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
- Network calls to the llama.cpp server are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
- Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
- `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`.
## Work Guidance
@@ -26,9 +27,11 @@ Standard adapter rules apply (see parent `AGENTS.md`). HTTP client code uses Kto
./gradlew :infrastructure:inference:test --rerun-tasks
./gradlew :infrastructure:inference:commons:test --rerun-tasks
./gradlew :infrastructure:inference:llama_cpp:test --rerun-tasks
./gradlew :infrastructure:inference:openai_compat:test --rerun-tasks
```
## Child DOX Index
- `commons/` — shared `ManagedInferenceProvider`, `ModelManager`, `ResourceProbe` (Nvidia/AMD), `ResidencyMode`; no separate AGENTS.md (sub-leaf, covered by this doc)
- `llama_cpp/``LlamaCppInferenceProvider`, `LlamaProcess` (spawns/owns llama-server), `LlamaCppEmbedder`, `LlamaCppTokenizer`, `GbnfGrammarConverter`; no separate AGENTS.md (sub-leaf, covered by this doc)
- `openai_compat/``OpenAiCompatInferenceProvider` (remote Bearer-auth chat completions for NVIDIA NIM/OpenAI), `HeuristicTokenizer`, `OpenAiApiModels`; no separate AGENTS.md (sub-leaf, covered by this doc)
@@ -0,0 +1,29 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
ext {
ktor_version = '3.0.3'
ext.ktor_version = '3.0.3'
}
dependencies {
implementation project(':core:inference')
implementation project(':core:events')
implementation project(':core:artifacts')
implementation project(':core:context')
implementation project(':infrastructure:inference:commons')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
implementation "org.slf4j:slf4j-api:2.0.16"
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,21 @@
package com.correx.infrastructure.inference.openai
import com.correx.core.inference.Token
import com.correx.core.inference.Tokenizer
/**
* Remote OpenAI-compatible backends (NIM, OpenAI) expose no `/tokenize` endpoint, so we can't
* get exact token ids without shipping a tokenizer. This is a length-based approximation
* (~4 chars/token) used only for context-budget estimates; the synthesized [Token] ids are
* placeholders and must not be treated as real model tokens.
*/
class HeuristicTokenizer(private val charsPerToken: Int = 4) : Tokenizer {
override suspend fun tokenize(text: String): List<Token> {
val count = countTokens(text)
return List(count) { Token(it) }
}
override suspend fun countTokens(text: String): Int =
if (text.isEmpty()) 0 else (text.length + charsPerToken - 1) / charsPerToken
}
@@ -0,0 +1,53 @@
package com.correx.infrastructure.inference.openai
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire models for the OpenAI-compatible `/chat/completions` endpoint (NVIDIA NIM, OpenAI,
* vLLM, etc.). Unlike the llama.cpp models there is no `grammar` field — structured output
* on these backends is `response_format`/tools, not GBNF. JSON artifacts rely on the
* orchestrator's validate-after-retry path (invariant #7).
*/
@Serializable
data class OpenAiChatCompletionRequest(
val model: String,
val messages: List<OpenAiChatMessage>,
val temperature: Double,
@SerialName("top_p") val topP: Double,
@SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String>? = null,
val seed: Long? = null,
val stream: Boolean = false,
val tools: List<ToolDefinition>? = null,
)
@Serializable
data class OpenAiChatMessage(
val role: String,
val content: String? = null,
@SerialName("tool_calls") val toolCalls: List<ToolCallRequest> = emptyList(),
@SerialName("tool_call_id") val toolCallId: String? = null,
)
@Serializable
data class OpenAiChatCompletionResponse(
val id: String? = null,
val choices: List<OpenAiChoice> = emptyList(),
val usage: OpenAiUsage? = null,
)
@Serializable
data class OpenAiChoice(
val message: OpenAiChatMessage,
@SerialName("finish_reason") val finishReason: String? = null,
)
@Serializable
data class OpenAiUsage(
@SerialName("prompt_tokens") val promptTokens: Int = 0,
@SerialName("completion_tokens") val completionTokens: Int = 0,
@SerialName("total_tokens") val totalTokens: Int = 0,
)
@@ -0,0 +1,169 @@
package com.correx.infrastructure.inference.openai
import com.correx.core.events.types.ProviderId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.PromptRenderer
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.TokenUsage
import com.correx.core.inference.Tokenizer
import com.correx.core.inference.ToolCallRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
private const val DEFAULT_REQUEST_TIMEOUT_MS = 600_000L
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) { requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS }
}
private val log = LoggerFactory.getLogger(OpenAiCompatInferenceProvider::class.java)
// Some models emit a tool call as a raw JSON blob in `content` instead of the native
// tool_calls array; salvage it so the orchestrator gets a real call rather than treating
// the blob as a (failing) artifact. Mirrors the llama.cpp provider's recovery path.
internal fun salvageToolCalls(content: String?): List<ToolCallRequest> {
val s = content?.trim().orEmpty()
if (s.isEmpty() || (s.first() != '{' && s.first() != '[')) return emptyList()
return runCatching { json.decodeFromString<List<ToolCallRequest>>(s) }
.recoverCatching { listOf(json.decodeFromString<ToolCallRequest>(s)) }
.getOrDefault(emptyList())
.filter { it.function.name.isNotBlank() }
}
/**
* [InferenceProvider] for OpenAI-compatible chat APIs reached over HTTPS with a Bearer key —
* NVIDIA NIM (`https://integrate.api.nvidia.com/v1`), OpenAI, vLLM, etc. Inference is fully
* remote: no local process, no GPU, no GGUF. [baseUrl] must already include the API version
* segment (e.g. `.../v1`); this class appends `/chat/completions` and `/models`.
*
* @param idPrefix label for the [ProviderId] (e.g. `nim`, `openai`).
* @param apiKey Bearer token; sent as `Authorization: Bearer <key>` when non-blank.
*/
@Suppress("TooGenericExceptionCaught", "MagicNumber")
class OpenAiCompatInferenceProvider(
private val modelId: String,
private val baseUrl: String,
private val apiKey: String,
private val capabilities: Set<CapabilityScore>,
private val idPrefix: String = "openai",
private val httpClient: HttpClient = defaultHttpClient(),
) : InferenceProvider {
override val id: ProviderId = ProviderId("$idPrefix:$modelId")
override val name: String = "OpenAI-compatible ($idPrefix:$modelId)"
override val tokenizer: Tokenizer = HeuristicTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
val startTime = System.currentTimeMillis()
val messages = PromptRenderer.render(request.contextPack).map { msg ->
// Strict OpenAI-compatible servers reject a `tool` role that isn't bound to a prior
// tool_calls turn (we don't carry tool_call_ids through the context pack). Fold any
// non-standard role into a user turn so the model still sees the content.
if (msg.role in STANDARD_ROLES) {
OpenAiChatMessage(role = msg.role, content = msg.content)
} else {
OpenAiChatMessage(role = "user", content = "[${msg.role}] ${msg.content}")
}
}
val tools = request.tools.takeIf { it.isNotEmpty() }
val body = OpenAiChatCompletionRequest(
model = modelId,
messages = messages,
temperature = request.generationConfig.temperature,
topP = request.generationConfig.topP,
maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences.ifEmpty { null },
seed = request.generationConfig.seed,
stream = false,
tools = tools,
)
val encoded = json.encodeToString(body)
log.debug("sending request to {}: {}", id.value, encoded)
val httpResponse = httpClient.post("$baseUrl/chat/completions") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
setBody(encoded)
}
if (httpResponse.status.value !in 200..299) {
val errorBody = httpResponse.bodyAsText()
error("$idPrefix returned ${httpResponse.status.value} ${httpResponse.status.description}: $errorBody")
}
val response = httpResponse.body<OpenAiChatCompletionResponse>()
log.debug("got response from {}: {}", id.value, response)
val choice = response.choices.firstOrNull()
?: error("$idPrefix returned no choices")
val message = choice.message
val salvaged = if (message.toolCalls.isEmpty()) salvageToolCalls(message.content) else emptyList()
val toolCalls = message.toolCalls.ifEmpty { salvaged }
val finishReason = when {
toolCalls.isNotEmpty() -> FinishReason.ToolCall
choice.finishReason?.lowercase() == "length" -> FinishReason.Length
else -> FinishReason.Stop
}
return InferenceResponse(
requestId = request.requestId,
text = if (salvaged.isNotEmpty()) "" else message.content ?: "",
finishReason = finishReason,
tokensUsed = TokenUsage(
promptTokens = response.usage?.promptTokens ?: 0,
completionTokens = response.usage?.completionTokens ?: 0,
),
latencyMs = System.currentTimeMillis() - startTime,
toolCalls = toolCalls,
)
}
override suspend fun healthCheck(): ProviderHealth = try {
val response = httpClient.get("$baseUrl/models") {
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
}
if (response.status.value in 200..299) {
ProviderHealth.Healthy
} else {
ProviderHealth.Unavailable("Health check returned non-2xx status: ${response.status}")
}
} catch (e: Exception) {
ProviderHealth.Unavailable("Health check failed: ${e.message}")
}
override fun capabilities(): Set<CapabilityScore> = capabilities
private companion object {
private val STANDARD_ROLES = setOf("system", "user", "assistant")
}
}
@@ -0,0 +1,130 @@
package com.correx.infrastructure.inference.openai
import com.correx.core.context.model.ContextPack
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.ModelCapability
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.HttpRequestData
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OpenAiCompatInferenceProviderTest {
private fun request() = InferenceRequest(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("s1"),
stageId = StageId("stage1"),
contextPack = ContextPack(
id = ContextPackId("cp1"),
sessionId = SessionId("s1"),
stageId = StageId("stage1"),
layers = emptyMap(),
budgetUsed = 0,
budgetLimit = 4096,
),
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = 256),
)
private fun clientReturning(body: String, capture: (HttpRequestData) -> Unit = {}): HttpClient {
val engine = MockEngine { req ->
capture(req)
respond(
content = body,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
return HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
}
private fun provider(client: HttpClient) = OpenAiCompatInferenceProvider(
modelId = "deepseek-ai/deepseek-v4-flash",
baseUrl = "https://integrate.api.nvidia.com/v1",
apiKey = "nvapi-test",
capabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
idPrefix = "nim",
httpClient = client,
)
@Test
fun `infer sends bearer auth to chat completions and returns text`(): Unit = runBlocking {
var seenAuth: String? = null
var seenUrl: String? = null
val client = clientReturning(
"""
{"id":"x","choices":[{"message":{"role":"assistant","content":"hello there"},
"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":3,"total_tokens":14}}
""".trimIndent(),
) { req ->
seenAuth = req.headers[HttpHeaders.Authorization]
seenUrl = req.url.toString()
}
val resp = provider(client).infer(request())
assertEquals("Bearer nvapi-test", seenAuth)
assertEquals("https://integrate.api.nvidia.com/v1/chat/completions", seenUrl)
assertEquals("hello there", resp.text)
assertEquals(FinishReason.Stop, resp.finishReason)
assertEquals(11, resp.tokensUsed.promptTokens)
assertEquals(3, resp.tokensUsed.completionTokens)
}
@Test
fun `infer surfaces native tool calls as ToolCall finish`(): Unit = runBlocking {
val client = clientReturning(
"""
{"id":"x","choices":[{"message":{"role":"assistant","content":null,
"tool_calls":[{"id":"c1","type":"function","function":{"name":"file_read","arguments":"{}"}}]},
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}
""".trimIndent(),
)
val resp = provider(client).infer(request())
assertEquals(FinishReason.ToolCall, resp.finishReason)
assertEquals(1, resp.toolCalls.size)
assertEquals("file_read", resp.toolCalls.first().function.name)
}
@Test
fun `infer salvages a tool call emitted as a JSON blob in content`(): Unit = runBlocking {
val client = clientReturning(
"""
{"id":"x","choices":[{"message":{"role":"assistant",
"content":"{\"function\":{\"name\":\"file_read\",\"arguments\":\"{}\"}}"},
"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":9,"total_tokens":13}}
""".trimIndent(),
)
val resp = provider(client).infer(request())
assertEquals(FinishReason.ToolCall, resp.finishReason)
assertEquals("file_read", resp.toolCalls.first().function.name)
assertEquals("", resp.text)
}
@Test
fun `salvageToolCalls leaves plain artifact JSON alone`() {
assertTrue(salvageToolCalls("""{"title":"a plan","steps":[]}""").isEmpty())
assertTrue(salvageToolCalls("not json").isEmpty())
}
}