LlmBridge.kt
src/jvmMain/kotlin/afield/ai/models/LlmBridge.kt · 142 lines
package afield.ai.models
import afield.ai.conversation.IdeChatHelpers
import afield.ai.conversation.TurnContext
import afield.ai.diagnostics.LogSink
import afield.ai.diagnostics.StdoutLog
import afield.ai.diagnostics.log
import afield.ai.tools.ToolExecutionContext
import afield.ai.conversation.AiChatModelIdentity
import com.openai.models.ReasoningEffort
The interface the IDE agent loop talks to, with one implementation per provider.
Both the OpenAI/OpenRouter engine (OpenAiIdeCodexBridge, via OpenAiLlmBridge)
and the Anthropic engine (AnthropicBridge) implement this, so IdeChatHelpers
and the installer can drive whichever provider is selected on /welcome without
caring which one is behind the interface.
The state/result types are the ones OpenAiIdeCodexBridge already defines — they
carry no OpenAI-SDK types in their signatures, so they double as the neutral
currency here. The typealiases below give them provider-neutral names without moving
the declarations their callers already reference.
typealias LlmThreadState = OpenAiIdeCodexBridge.ThreadState
typealias LlmSendResult = OpenAiIdeCodexBridge.SendResult
typealias LlmProgressUpdate = OpenAiIdeCodexBridge.ProgressUpdate
typealias LlmToolTranscriptEntry = OpenAiIdeCodexBridge.ToolTranscriptEntry
enum class LlmReasoningEffort { LOW, MEDIUM, HIGH }
fun configuredAiReasoningEffort(): LlmReasoningEffort
A provider-scoped local preference; explicit per-call choices still take precedence.
fun configuredAiReasoningEffort(): LlmReasoningEffort =
LlmReasoningEffort.entries.firstOrNull {
it.name.equals(configuredAiModelSettings().reasoningEffort, ignoreCase = true)
} ?: LlmReasoningEffort.MEDIUM
fun createLlmBridge
Provider-aware factory: builds the agent engine matching the /welcome provider setting. Shared by the IDE installer and the docs-search bridge so provider selection lives in exactly one place.
fun createLlmBridge(
model: String = Ai4ModelDefaults.flagshipModel(),
log: LogSink = StdoutLog,
reasoningEffort: LlmReasoningEffort = configuredAiReasoningEffort()
): LlmBridge =
if (configuredAiProvider() == "anthropic")
AnthropicBridge(model = model, log = log)
else
OpenAiLlmBridge(
OpenAiIdeCodexBridge(
model = model,
log = log,
reasoningEffort = when (reasoningEffort) {
LlmReasoningEffort.LOW -> ReasoningEffort.LOW
LlmReasoningEffort.MEDIUM -> ReasoningEffort.MEDIUM
LlmReasoningEffort.HIGH -> ReasoningEffort.HIGH
}
)
)
fun createIdeBridge(log: LogSink = StdoutLog): LlmBridge
The bridge every AI4 chat uses: the configured provider at the flagship model. The box panel and the
document chat build theirs here, and so does a live AgentTask, so a task runs on exactly the
engine a person's chat would.
fun createIdeBridge(log: LogSink = StdoutLog): LlmBridge = createLlmBridge(Ai4ModelDefaults.flagshipModel(), log)
interface LlmBridge
interface LlmBridge {
val modelIdentity: AiChatModelIdentity? get() = null
Configured identity while a request is pending; response metadata arrives through progress/results.
fun startThread
fun startThread(
fileKey: String,
fileText: String,
instructions: String = ""
): LlmThreadState
fun send
fun send(
state: LlmThreadState,
userPrompt: String,
fileText: String? = null,
ctx: ToolExecutionContext = ToolExecutionContext(),
vectorStoreIds: List<String> = emptyList(),
maxOutputTokens: Long = 20480,
maxToolRounds: Int = 24,
imageDataUrls: List<String> = emptyList(),
onTextDelta: ((String) -> Unit)? = null,
onProgress: ((LlmProgressUpdate) -> Unit)? = null,
// The turn's prompt in parts (TurnContext). A bridge that understands it sends the ephemeral
// instructions per request and stores only TurnContext.durablePrompt; one that does not sends
// userPrompt, which callers set to TurnContext.legacyPrompt. Null: userPrompt is everything.
turnContext: TurnContext? = null
): LlmSendResult
}
class OpenAiLlmBridge(private val inner: OpenAiIdeCodexBridge) : LlmBridge
Adapter that presents the existing (untouched) OpenAiIdeCodexBridge as an
LlmBridge. Overrides carry no default values (Kotlin forbids that on an
override); the defaults live on the interface and reach every call site typed
as LlmBridge. A blank startThread instruction defers to the inner bridge's
own default so existing behaviour is preserved exactly.
val delegate: OpenAiIdeCodexBridge get() = inner
override val modelIdentity: AiChatModelIdentity get() = inner.modelIdentity
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState
override fun startThread(fileKey: String, fileText: String, instructions: String): LlmThreadState =
if (instructions.isBlank()) inner.startThread(fileKey, fileText)
else inner.startThread(fileKey, fileText, instructions)
override fun send
override fun send(
state: LlmThreadState,
userPrompt: String,
fileText: String?,
ctx: ToolExecutionContext,
vectorStoreIds: List<String>,
maxOutputTokens: Long,
maxToolRounds: Int,
imageDataUrls: List<String>,
onTextDelta: ((String) -> Unit)?,
onProgress: ((LlmProgressUpdate) -> Unit)?,
turnContext: TurnContext?
): LlmSendResult =
inner.send(
state,
userPrompt,
fileText,
ctx,
vectorStoreIds,
maxOutputTokens,
maxToolRounds,
imageDataUrls,
onTextDelta,
onProgress,
turnContext
)
}