Skip to content

OpenAiCredentials.kt

src/jvmMain/kotlin/afield/ai/models/OpenAiCredentials.kt · 409 lines

Where Field's AI features find their provider, API key, endpoint and model names.

Each setting is read from an environment variable first and then from a file under ~/.afield/: the provider (AFIELD_AI_PROVIDER, else ai-provider.txt, defaulting to openai), the OpenAI key (OPENAI_PLATFORM_KEY or OPENAI_API_KEY, else openai-platform-key.txt), the OpenRouter key and the per-provider model settings. The /welcome page writes the files through saveAiSettings and saveOpenAiPlatformKey, and aiProviderConfigRevision changes on each saveAiSettings so cached clients know to rebuild. activeAiProviderRuntimeConfig resolves all of it into the one AiProviderRuntimeConfig a bridge is built from.

package afield.ai.models

import com.openai.client.OpenAIClient
import com.openai.client.okhttp.OpenAIOkHttpClient
import java.io.File
import java.util.Locale
import java.util.Properties
import java.util.concurrent.atomic.AtomicLong

const val OPENAI_PLATFORM_KEY_ENV = "OPENAI_PLATFORM_KEY"

const val OPENAI_API_KEY_ENV = "OPENAI_API_KEY"

const val OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY"

const val AFIELD_AI_PROVIDER_ENV = "AFIELD_AI_PROVIDER"

const val AFIELD_AI_BASE_URL_ENV = "AFIELD_AI_BASE_URL"

const val OPENROUTER_BASE_URL_ENV = "OPENROUTER_BASE_URL"

const val LOCAL_AI_BASE_URL_ENV = "AFIELD_LOCAL_AI_BASE_URL"

const val LOCAL_AI_API_KEY_ENV = "AFIELD_LOCAL_AI_API_KEY"

private const val OPENAI_BASE_URL = "https://api.openai.com/v1"
private const val OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"

const val LOCAL_AI_DEFAULT_BASE_URL = "http://127.0.0.1:8081/v1"

private val aiProviderConfigurationRevision = AtomicLong(0L)

fun aiProviderConfigRevision(): Long = aiProviderConfigurationRevision.get()

Changes whenever /welcome saves provider, credential, endpoint, or model settings.

class MissingOpenAiCredentialException(message: String) : IllegalStateException(message)

data class OpenAiCredentialStatus

data class OpenAiCredentialStatus(
    val configured: Boolean,
    val source: String,
    val configPath: String
)

data class AiModelSettings

data class AiModelSettings(
    val flagshipModel: String = "",
    val fastModel: String = "",
    val fallbackModel: String = "",
    val reasoningEffort: String = ""
)

data class AiProviderStatus

data class AiProviderStatus(
    val provider: String,
    val hasOpenAiPlatformKey: Boolean,
    val hasOpenRouterApiKey: Boolean,
    val source: String,
    val configPath: String,
    val openRouterConfigPath: String,
    val baseUrl: String,
    val modelSettings: AiModelSettings
)

data class AiSettingsSaveRequest

data class AiSettingsSaveRequest(
    val provider: String,
    val openAiPlatformKey: String?,
    val openRouterApiKey: String?,
    val openRouterBaseUrl: String?,
    val flagshipModel: String?,
    val fastModel: String?,
    val fallbackModel: String?,
    val localBaseUrl: String? = null,
    val localApiKey: String? = null
)

data class AiProviderRuntimeConfig

data class AiProviderRuntimeConfig(
    val provider: String,
    val apiKey: String,
    val source: String,
    val baseUrl: String,
    val modelSettings: AiModelSettings
) {

val isOpenRouter: Boolean get() = provider == "openrouter"

val usesStatelessResponses: Boolean get() = provider == "openrouter" || provider == "local"

private fun aiConfigDir(): File =
    System.getProperty("afield.ai.configDir")?.let(::File)
        ?: File(System.getProperty("user.home"), ".afield")

internal fun aiSettingsEnvironmentOverrides(): List<String>

internal fun aiSettingsEnvironmentOverrides(): List<String> = listOf(
    AFIELD_AI_PROVIDER_ENV, AFIELD_AI_BASE_URL_ENV, LOCAL_AI_BASE_URL_ENV,
    "AFIELD_AI_MODEL", "AFIELD_AI_FAST_MODEL", "AFIELD_AI_FALLBACK_MODEL"
).filter { !System.getenv(it).isNullOrBlank() }

fun openAiCredentialFile(): File

fun openAiCredentialFile(): File =
    File(aiConfigDir(), "openai-platform-key.txt")

fun openRouterCredentialFile(): File

fun openRouterCredentialFile(): File =
    File(aiConfigDir(), "openrouter-api-key.txt")

private fun localAiCredentialFile(): File =
    File(aiConfigDir(), "local-ai-api-key.txt")

internal fun readLocalAiCredential(): Pair<String, String>?

internal fun readLocalAiCredential(): Pair<String, String>? {
    System.getenv(LOCAL_AI_API_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return it to LOCAL_AI_API_KEY_ENV
    }
    val file = localAiCredentialFile()
    return runCatching { file.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotBlank() }?.let { it to file.absolutePath } }.getOrNull()
}

internal fun normalizeLocalAiBaseUrl(value: String): String

Accept an API base URL on this computer or another machine owned by the user.

internal fun normalizeLocalAiBaseUrl(value: String): String {
    val uri = runCatching { java.net.URI(value.trim()) }.getOrNull()
    require(uri != null && uri.scheme in setOf("http", "https") && !uri.host.isNullOrBlank() &&
        uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {
        "Enter an http:// or https:// API base URL, such as http://gpu-host:8081/v1."
    }
    val base = value.trim().trimEnd('/')
    return if (uri.path.isNullOrEmpty() || uri.path == "/") "$base/v1" else base
}

private fun aiProviderFile(): File =
    File(aiConfigDir(), "ai-provider.txt")

private fun aiModelSettingsFile(): File =
    File(aiConfigDir(), "ai-models.properties")

private fun normalizeProvider(provider: String?): String {
    val normalized = provider?.trim()?.lowercase(Locale.US)
    return when (normalized) {
        "openrouter", "router" -> "openrouter"
        "anthropic", "claude" -> "anthropic"
        "local" -> "local"
        else -> "openai"
    }
}

fun readOpenAiCredential(): Pair<String, String>?

fun readOpenAiCredential(): Pair<String, String>? {
    System.getenv(OPENAI_PLATFORM_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return it to OPENAI_PLATFORM_KEY_ENV
    }
    System.getenv(OPENAI_API_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return it to OPENAI_API_KEY_ENV
    }

    val file = openAiCredentialFile()
    return runCatching {
        file.takeIf { it.exists() }
            ?.readText(Charsets.UTF_8)
            ?.trim()
            ?.takeIf { it.isNotBlank() }
            ?.let { it to file.absolutePath }
    }.getOrNull()
}

fun readOpenRouterCredential(): Pair<String, String>?

fun readOpenRouterCredential(): Pair<String, String>? {
    System.getenv(OPENROUTER_API_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return it to OPENROUTER_API_KEY_ENV
    }

    val file = openRouterCredentialFile()
    return runCatching {
        file.takeIf { it.exists() }
            ?.readText(Charsets.UTF_8)
            ?.trim()
            ?.takeIf { it.isNotBlank() }
            ?.let { it to file.absolutePath }
    }.getOrNull()
}

fun configuredAiProvider(): String

fun configuredAiProvider(): String {
    System.getenv(AFIELD_AI_PROVIDER_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return normalizeProvider(it)
    }
    return runCatching {
        aiProviderFile().takeIf { it.exists() }
            ?.readText(Charsets.UTF_8)
            ?.trim()
            ?.takeIf { it.isNotBlank() }
            ?.let { normalizeProvider(it) }
    }.getOrNull() ?: "openai"
}

private fun modelSettingKey(provider: String, name: String): String = "$provider.$name"

private fun readAiModelSettings(provider: String = configuredAiProvider()): AiModelSettings {
    val props = Properties()
    runCatching {
        aiModelSettingsFile().takeIf { it.exists() }?.inputStream()?.use(props::load)
    }
    fun value(name: String): String {
        props.getProperty(modelSettingKey(provider, name), "").trim().takeIf { it.isNotBlank() }?.let { return it }
        // Files written before provider-scoped settings belong to the provider selected in that file.
        val savedProvider = runCatching {
            aiProviderFile().takeIf { it.exists() }?.readText(Charsets.UTF_8)?.trim()?.let(::normalizeProvider)
        }.getOrNull() ?: "openai"
        return if (savedProvider == provider) props.getProperty(name, "").trim() else ""
    }
    return AiModelSettings(value("flagshipModel"), value("fastModel"), value("fallbackModel"), value("reasoningEffort"))
}

fun configuredAiModelSettings(provider: String = configuredAiProvider()): AiModelSettings

fun configuredAiModelSettings(provider: String = configuredAiProvider()): AiModelSettings = readAiModelSettings(provider)

fun configuredLocalAiContextTokens(): Int?

The local inference server's context window in tokens: AFIELD_LOCAL_AI_CONTEXT_TOKENS, else local.contextTokens in ai-models.properties, else null, in which case the bridge learns it from the server's first refusal of an oversized request.

fun configuredLocalAiContextTokens(): Int? {
    System.getenv("AFIELD_LOCAL_AI_CONTEXT_TOKENS")?.trim()?.toIntOrNull()?.takeIf { it >= 2048 }?.let { return it }
    val props = Properties()
    runCatching { aiModelSettingsFile().takeIf { it.isFile }?.inputStream()?.use(props::load) }
    return props.getProperty("local.contextTokens", "").trim().toIntOrNull()?.takeIf { it >= 2048 }
}

fun configuredAiBaseUrl(provider: String = configuredAiProvider()): String

fun configuredAiBaseUrl(provider: String = configuredAiProvider()): String {
    System.getenv(AFIELD_AI_BASE_URL_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { return it.trimEnd('/') }
    if (provider == "local") {
        System.getenv(LOCAL_AI_BASE_URL_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { return normalizeLocalAiBaseUrl(it) }
        val props = Properties()
        runCatching { aiModelSettingsFile().takeIf { it.isFile }?.inputStream()?.use(props::load) }
        return props.getProperty("local.baseUrl", "").trim().takeIf { it.isNotBlank() }
            ?.let(::normalizeLocalAiBaseUrl) ?: LOCAL_AI_DEFAULT_BASE_URL
    }
    if (provider == "openrouter") {
        System.getenv(OPENROUTER_BASE_URL_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { return it.trimEnd('/') }
        runCatching {
            val props = Properties()
            aiModelSettingsFile().takeIf { it.exists() }?.inputStream()?.use(props::load)
            (props.getProperty(modelSettingKey("openrouter", "baseUrl"), "").ifBlank {
                props.getProperty("openRouterBaseUrl", "")
            }).trim().takeIf { it.isNotBlank() }?.trimEnd('/')
        }.getOrNull()?.let { return it }
        return OPENROUTER_BASE_URL
    }
    return OPENAI_BASE_URL
}

fun openAiCredentialStatus(): OpenAiCredentialStatus

fun openAiCredentialStatus(): OpenAiCredentialStatus {
    val file = openAiCredentialFile()
    val configured = readOpenAiCredential()
    return OpenAiCredentialStatus(
        configured = configured != null,
        source = configured?.second ?: "",
        configPath = file.absolutePath
    )
}

fun aiProviderStatus(): AiProviderStatus

fun aiProviderStatus(): AiProviderStatus {
    val provider = configuredAiProvider()
    val openAi = readOpenAiCredential()
    val openRouter = readOpenRouterCredential()
    val source = when (provider) {
        "local" -> readLocalAiCredential()?.second ?: "Local endpoint (no API key)"
        "openrouter" -> openRouter?.second
        else -> openAi?.second
    }
    return AiProviderStatus(
        provider = provider,
        hasOpenAiPlatformKey = openAi != null,
        hasOpenRouterApiKey = openRouter != null,
        source = source ?: "",
        configPath = openAiCredentialFile().absolutePath,
        openRouterConfigPath = openRouterCredentialFile().absolutePath,
        baseUrl = configuredAiBaseUrl(provider),
        modelSettings = configuredAiModelSettings(provider)
    )
}

fun saveOpenAiPlatformKey(key: String): OpenAiCredentialStatus

fun saveOpenAiPlatformKey(key: String): OpenAiCredentialStatus {
    val trimmed = key.trim()
    require(trimmed.isNotBlank()) { "OpenAI Platform key is blank." }
    val file = openAiCredentialFile()
    file.parentFile?.mkdirs()
    file.writeText("$trimmed\n", Charsets.UTF_8)
    return openAiCredentialStatus()
}

fun saveAiSettings(request: AiSettingsSaveRequest): AiProviderStatus

body — 66 lines, from line 284
fun saveAiSettings(request: AiSettingsSaveRequest): AiProviderStatus {
    val provider = normalizeProvider(request.provider)
    val suppliedOpenAiKey = request.openAiPlatformKey?.trim().orEmpty()
    val suppliedOpenRouterKey = request.openRouterApiKey?.trim().orEmpty()
    // Validate before writing any setting, so a failed save keeps the working provider.
    val localBaseUrl = if (provider == "local") normalizeLocalAiBaseUrl(request.localBaseUrl.orEmpty()) else null
    if (provider == "local") require(!request.flagshipModel.isNullOrBlank()) { "Enter the model ID served by your local endpoint." }
    val previousLocalBaseUrl = configuredAiBaseUrl("local")
    val previousSavedProvider = runCatching { normalizeProvider(aiProviderFile().readText()) }.getOrDefault("openai")
    if (provider == "openai" && suppliedOpenAiKey.isBlank() && readOpenAiCredential() == null) {
        throw IllegalArgumentException("OpenAI selected, but no OpenAI Platform key is configured.")
    }
    if (provider == "openrouter" && suppliedOpenRouterKey.isBlank() && readOpenRouterCredential() == null) {
        throw IllegalArgumentException("OpenRouter selected, but no OpenRouter API key is configured.")
    }
    aiProviderFile().parentFile?.mkdirs()
    aiProviderFile().writeText("$provider\n", Charsets.UTF_8)

    suppliedOpenAiKey.takeIf { it.isNotBlank() }?.let { saveOpenAiPlatformKey(it) }

    suppliedOpenRouterKey.takeIf { it.isNotBlank() }?.let { key ->
        val file = openRouterCredentialFile()
        file.parentFile?.mkdirs()
        file.writeText("$key\n", Charsets.UTF_8)
    }
    if (provider == "local") {
        val key = request.localApiKey?.trim().orEmpty()
        if (key.isNotBlank()) {
            localAiCredentialFile().apply { parentFile.mkdirs(); writeText("$key\n") }
        } else if (localBaseUrl != previousLocalBaseUrl) {
            // A credential saved for one endpoint must not follow a URL change.
            localAiCredentialFile().delete()
        }
    }

    val props = Properties()
    runCatching {
        aiModelSettingsFile().takeIf { it.exists() }?.inputStream()?.use(props::load)
    }
    // Older settings were global; move them to their original provider before switching.
    listOf("flagshipModel", "fastModel", "fallbackModel", "reasoningEffort").forEach { name ->
        val scoped = modelSettingKey(previousSavedProvider, name)
        val legacy = props.getProperty(name, "").trim()
        if (props.getProperty(scoped, "").isBlank() && legacy.isNotBlank()) props.setProperty(scoped, legacy)
    }
    props.getProperty("openRouterBaseUrl", "").trim().takeIf { it.isNotBlank() }?.let {
        if (props.getProperty("openrouter.baseUrl", "").isBlank()) props.setProperty("openrouter.baseUrl", it)
    }
    fun replace(name: String, value: String?) {
        val key = modelSettingKey(provider, name)
        val trimmed = value?.trim().orEmpty()
        if (trimmed.isBlank()) props.remove(key) else props.setProperty(key, trimmed)
    }
    replace("flagshipModel", request.flagshipModel)
    replace("fastModel", request.fastModel)
    replace("fallbackModel", request.fallbackModel)
    if (provider == "openrouter") replace("baseUrl", request.openRouterBaseUrl?.trimEnd('/'))
    if (provider == "local") replace("baseUrl", localBaseUrl)
    // Do not let legacy global overrides leak into another provider after this save.
    listOf("flagshipModel", "fastModel", "fallbackModel", "openRouterBaseUrl").forEach(props::remove)
    aiModelSettingsFile().parentFile?.mkdirs()
    aiModelSettingsFile().outputStream().use { props.store(it, "Field AI settings") }

    aiProviderConfigurationRevision.incrementAndGet()
    return aiProviderStatus()
}

fun activeAiProviderRuntimeConfig(): AiProviderRuntimeConfig

fun activeAiProviderRuntimeConfig(): AiProviderRuntimeConfig {
    val provider = configuredAiProvider()
    if (provider == "local") {
        val model = System.getenv("AFIELD_AI_MODEL")?.trim()?.takeIf { it.isNotBlank() }
            ?: configuredAiModelSettings("local").flagshipModel
        require(model.isNotBlank()) { "Choose a local model in Welcome → AI → Local." }
        val credential = readLocalAiCredential()
        return AiProviderRuntimeConfig("local", credential?.first ?: "local-no-key-required",
            credential?.second ?: "Local endpoint (no API key)", normalizeLocalAiBaseUrl(configuredAiBaseUrl("local")),
            configuredAiModelSettings("local"))
    }
    val credential = if (provider == "openrouter") readOpenRouterCredential() else readOpenAiCredential()
    val (key, source) = credential
        ?: throw MissingOpenAiCredentialException(
            if (provider == "openrouter") {
                "AI4 needs an OpenRouter API key. Set $OPENROUTER_API_KEY_ENV or save one from /welcome. " +
                    "Field stores the welcome setting at ${openRouterCredentialFile().absolutePath}."
            } else {
                "AI4 needs an OpenAI Platform key. Set $OPENAI_PLATFORM_KEY_ENV, set $OPENAI_API_KEY_ENV, " +
                    "or save one from /welcome. Field stores the welcome setting at ${openAiCredentialFile().absolutePath}."
            }
        )

    return AiProviderRuntimeConfig(
        provider = provider,
        apiKey = key,
        source = source,
        baseUrl = configuredAiBaseUrl(provider),
        modelSettings = configuredAiModelSettings(provider)
    )
}

internal val localRequestTimeout: com.openai.core.Timeout

internal val localRequestTimeout: com.openai.core.Timeout = com.openai.core.Timeout.builder()
    .request(java.time.Duration.ofMinutes(45))
    .read(java.time.Duration.ofMinutes(5))
    .build()

fun openAiClientFromFieldConfig(): OpenAIClient

fun openAiClientFromFieldConfig(): OpenAIClient {
    val config = activeAiProviderRuntimeConfig()
    return try {
        OpenAIOkHttpClient.builder()
            .apiKey(config.apiKey)
            .baseUrl(config.baseUrl)
            .apply {
                // The SDK's default caps a whole request at 10 minutes. A local model at ~15 tokens/s cannot
                // write a large tool call in that time: on 2026-10-28 a round was cut at 602 s, mid-answer.
                // Allow a long generation, but fail one that goes silent between chunks.
                if (config.provider == "local") timeout(localRequestTimeout)
                if (config.isOpenRouter) {
                    putHeader("HTTP-Referer", "https://localhost:9092")
                    putHeader("X-OpenRouter-Title", "Field4")
                }
            }
            .build()
    } catch (t: Throwable) {
        throw IllegalStateException("Could not initialize ${config.provider} client from ${config.source}: ${t.message}", t)
    }
}