Skip to content

AnthropicCredentials.kt

src/jvmMain/kotlin/afield/ai/models/AnthropicCredentials.kt · 226 lines

package afield.ai.models

import com.anthropic.client.AnthropicClient
import com.anthropic.client.okhttp.AnthropicOkHttpClient
import java.io.File
import java.util.Properties
import java.util.concurrent.TimeUnit

Credential + client construction for the Anthropic (Claude) provider.

Field's enterprise setup can't mint a raw ANTHROPIC_API_KEY, so the default path here authenticates the way the official clients do: a short-lived OAuth bearer token minted from an ant auth login profile via ant auth print-credentials --access-token, sent as Authorization: Bearer plus the anthropic-beta: oauth-2025-04-20 header. A real API key (ANTHROPIC_API_KEY) or a pre-supplied bearer token (ANTHROPIC_AUTH_TOKEN) are honored first when present, so nothing here is ant-only.

This mirrors openAiClientFromFieldConfig / activeAiProviderRuntimeConfig for the OpenAI/OpenRouter providers; the client it returns exposes the same client.messages() surface the AnthropicBridge drives.

const val ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"

const val ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN"

const val ANTHROPIC_BASE_URL_ENV = "ANTHROPIC_BASE_URL"

const val ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"

Where the SDK sends requests when configuredAnthropicBaseUrl is null.

const val ANTHROPIC_PROFILE_ENV = "ANTHROPIC_PROFILE"

const val AFIELD_ANT_BIN_ENV = "AFIELD_ANT_BIN"

The OAuth beta header required whenever we authenticate with a bearer token rather than an API key.

private const val ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20"

Access tokens from ant are short-lived; re-mint proactively well inside their lifetime.

private const val OAUTH_TOKEN_TTL_MS = 5 * 60 * 1000L

class MissingAnthropicCredentialException(message: String) : IllegalStateException(message)

data class AnthropicAuth

Resolved Anthropic auth for one request. isOAuth selects the header shape: bearer token + oauth beta header vs. x-api-key.

data class AnthropicAuth(
    val token: String,
    val source: String,
    val isOAuth: Boolean
)

data class AnthropicStatus

data class AnthropicStatus(
    val available: Boolean,
    val source: String,
    val detail: String,
    val baseUrl: String,
    val profile: String
)

private fun anthropicProfileFile(): File =
    File(File(System.getProperty("user.home"), ".afield"), "anthropic-profile.txt")

private fun aiModelSettingsPropsFile(): File =
    File(File(System.getProperty("user.home"), ".afield"), "ai-models.properties")

fun configuredAnthropicProfile(): String?

The ant profile to bind to, if any: env → ~/.afield/anthropic-profile.txt → active/default.

fun configuredAnthropicProfile(): String? {
    System.getenv(ANTHROPIC_PROFILE_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
    return runCatching {
        anthropicProfileFile().takeIf { it.exists() }
            ?.readText(Charsets.UTF_8)
            ?.trim()
            ?.takeIf { it.isNotBlank() }
    }.getOrNull()
}

fun configuredAnthropicBaseUrl(): String?

Base URL override: env → ai-models.propertiesanthropicBaseUrl`` → null (SDK default, api.anthropic.com).

fun configuredAnthropicBaseUrl(): String? {
    System.getenv(ANTHROPIC_BASE_URL_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { return it.trimEnd('/') }
    return runCatching {
        val props = Properties()
        aiModelSettingsPropsFile().takeIf { it.exists() }?.inputStream()?.use(props::load)
        props.getProperty("anthropicBaseUrl", "").trim().takeIf { it.isNotBlank() }?.trimEnd('/')
    }.getOrNull()
}

OAuth token minting via the ant CLI

private data class CachedToken(val token: String, val mintedAtMs: Long)

@Volatile
private var cachedOAuthToken: CachedToken? = null
private val tokenLock = Any()

Candidate ant binaries; the JVM's PATH often omits ~/go/bin, ~/.local/bin, etc.

private fun antBinaryCandidates(): List<String> {
    val home = System.getProperty("user.home")
    return buildList {
        System.getenv(AFIELD_ANT_BIN_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let { add(it) }
        add("ant") // rely on PATH
        add("$home/go/bin/ant")
        add("$home/.local/bin/ant")
        add("/usr/local/bin/ant")
        add("/opt/homebrew/bin/ant")
    }
}

Run ant [--profile P] <args> against the first candidate binary that exists, returning (exitCode, stdout, stderr). Returns null only if no ant binary is runnable at all.

private fun runAnt(profile: String?, vararg args: String): Triple<Int, String, String>? {
    for (bin in antBinaryCandidates()) {
        val cmd = buildList {
            add(bin)
            if (!profile.isNullOrBlank()) { add("--profile"); add(profile) }
            addAll(args)
        }
        val proc = try {
            ProcessBuilder(cmd).redirectErrorStream(false).start()
        } catch (_: Exception) {
            continue // binary not found at this path; try the next candidate
        }
        val out = proc.inputStream.bufferedReader().readText().trim()
        val err = proc.errorStream.bufferedReader().readText().trim()
        val finished = proc.waitFor(20, TimeUnit.SECONDS)
        if (!finished) {
            proc.destroyForcibly()
            return Triple(-1, out, "`ant` timed out after 20s")
        }
        return Triple(proc.exitValue(), out, err)
    }
    return null
}

private fun mintOAuthToken(profile: String?): String {
    val result = runAnt(profile, "auth", "print-credentials", "--access-token")
        ?: throw MissingAnthropicCredentialException(
            "Could not find the `ant` CLI. Install Anthropic's CLI and run `ant auth login`, " +
                "or set $AFIELD_ANT_BIN_ENV to its path (this is Anthropic's CLI, not Apache Ant)."
        )
    val (code, out, err) = result
    if (code != 0 || out.isBlank()) {
        throw MissingAnthropicCredentialException(
            "`ant auth print-credentials` failed (exit $code): ${err.take(240).ifBlank { "no output" }}. " +
                "Run `ant auth login` (or `ant --profile <name> auth login`) to authenticate."
        )
    }
    return out
}

fun invalidateAnthropicOAuthToken()

Force a fresh token on the next resolve — call this after a 401 from the API.

fun invalidateAnthropicOAuthToken() {
    synchronized(tokenLock) { cachedOAuthToken = null }
}

private fun anthropicOAuthToken(forceRefresh: Boolean): String {
    synchronized(tokenLock) {
        val now = System.currentTimeMillis()
        val cur = cachedOAuthToken
        if (!forceRefresh && cur != null && now - cur.mintedAtMs < OAUTH_TOKEN_TTL_MS) return cur.token
        val fresh = mintOAuthToken(configuredAnthropicProfile())
        cachedOAuthToken = CachedToken(fresh, now)
        return fresh
    }
}

fun resolveAnthropicAuth(forceRefresh: Boolean = false): AnthropicAuth

Resolve auth for a request. Precedence: explicit API key → explicit bearer token → ant OAuth profile. Only the OAuth/bearer paths set AnthropicAuth.isOAuth.

fun resolveAnthropicAuth(forceRefresh: Boolean = false): AnthropicAuth {
    System.getenv(ANTHROPIC_API_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return AnthropicAuth(it, ANTHROPIC_API_KEY_ENV, isOAuth = false)
    }
    System.getenv(ANTHROPIC_AUTH_TOKEN_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return AnthropicAuth(it, ANTHROPIC_AUTH_TOKEN_ENV, isOAuth = true)
    }
    val profile = configuredAnthropicProfile()
    val source = if (profile.isNullOrBlank()) "ant auth (active profile)" else "ant auth (profile: $profile)"
    return AnthropicAuth(anthropicOAuthToken(forceRefresh), source, isOAuth = true)
}

fun anthropicClientFromFieldConfig(forceRefresh: Boolean = false): AnthropicClient

fun anthropicClientFromFieldConfig(forceRefresh: Boolean = false): AnthropicClient {
    val auth = resolveAnthropicAuth(forceRefresh)
    val base = configuredAnthropicBaseUrl()
    return try {
        AnthropicOkHttpClient.builder()
            .apply {
                if (auth.isOAuth) {
                    authToken(auth.token)
                    putHeader("anthropic-beta", ANTHROPIC_OAUTH_BETA)
                } else {
                    apiKey(auth.token)
                }
                base?.let { baseUrl(it) }
            }
            .build()
    } catch (t: Throwable) {
        throw IllegalStateException(
            "Could not initialize Anthropic client from ${auth.source}: ${t.message}", t
        )
    }
}

fun anthropicStatus(): AnthropicStatus

Non-throwing status for the /welcome screen. Reports whether we can currently obtain a credential; the token mint is cached so repeat calls are cheap.

fun anthropicStatus(): AnthropicStatus {
    val base = configuredAnthropicBaseUrl() ?: ANTHROPIC_DEFAULT_BASE_URL
    val profile = configuredAnthropicProfile() ?: ""
    System.getenv(ANTHROPIC_API_KEY_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return AnthropicStatus(true, ANTHROPIC_API_KEY_ENV, "API key from environment.", base, profile)
    }
    System.getenv(ANTHROPIC_AUTH_TOKEN_ENV)?.trim()?.takeIf { it.isNotBlank() }?.let {
        return AnthropicStatus(true, ANTHROPIC_AUTH_TOKEN_ENV, "Bearer token from environment.", base, profile)
    }
    return runCatching {
        anthropicOAuthToken(forceRefresh = false)
        val src = if (profile.isBlank()) "ant auth (active profile)" else "ant auth (profile: $profile)"
        AnthropicStatus(true, src, "OAuth token minted from the ant CLI profile.", base, profile)
    }.getOrElse { e ->
        AnthropicStatus(false, "", e.message ?: "No Anthropic credential available.", base, profile)
    }
}