diff --git a/README.md b/README.md index 67b76411..3e1ecb08 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,12 @@ dependencies { ```kotlin val team = AgentTeam.create { // Configure your AI provider - config(AnthropicConfig(model = Claude.Sonnet4)) + config( + AnthropicConfig( + apiKey = System.getenv("ANTHROPIC_API_KEY"), + model = Claude.Sonnet4, + ), + ) // Add agents with personality traits agent(ProductManager) { personality { directness = 0.8 } } @@ -119,6 +124,8 @@ team.events.collect { event -> } } ``` + +`apiKey` is optional. When you provide it, Ampere uses that runtime credential directly. When you omit it, provider clients fall back to the generated `KotlinConfig` values sourced from `local.properties` at build time. --- diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Anthropic.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Anthropic.kt index fa1f8780..80308aa3 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Anthropic.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Anthropic.kt @@ -24,4 +24,13 @@ data object AIProvider_Anthropic : AIProvider { url = ANTHROPIC_API_ENDPOINT, ) } + + internal fun withApiToken(apiToken: String): AIProvider = + RuntimeAIProvider( + id = id, + name = name, + apiToken = apiToken, + availableModels = availableModels, + baseUrl = ANTHROPIC_API_ENDPOINT, + ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Google.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Google.kt index f5beca56..bc13bb8f 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Google.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Google.kt @@ -24,4 +24,13 @@ data object AIProvider_Google : AIProvider { url = GOOGLE_API_ENDPOINT, ) } + + internal fun withApiToken(apiToken: String): AIProvider = + RuntimeAIProvider( + id = id, + name = name, + apiToken = apiToken, + availableModels = availableModels, + baseUrl = GOOGLE_API_ENDPOINT, + ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_OpenAI.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_OpenAI.kt index 64fae722..4c00caf6 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_OpenAI.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_OpenAI.kt @@ -22,4 +22,12 @@ data object AIProvider_OpenAI : AIProvider { token = apiToken, ) } + + internal fun withApiToken(apiToken: String): AIProvider = + RuntimeAIProvider( + id = id, + name = name, + apiToken = apiToken, + availableModels = availableModels, + ) } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/RuntimeAIProvider.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/RuntimeAIProvider.kt new file mode 100644 index 00000000..d0a134ad --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/RuntimeAIProvider.kt @@ -0,0 +1,24 @@ +package link.socket.ampere.domain.ai.provider + +import com.aallam.openai.client.OpenAI as Client +import link.socket.ampere.domain.ai.model.AIModel +import link.socket.ampere.domain.tool.AITool + +internal data class RuntimeAIProvider< + TD : AITool, + L : AIModel, + >( + override val id: ProviderId, + override val name: String, + override val apiToken: String, + override val availableModels: List, + private val baseUrl: String? = null, +) : AIProvider { + + override val client: Client by lazy { + AIProvider.createClient( + token = apiToken, + url = baseUrl, + ) + } +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/koog/KoogAgentFactory.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/koog/KoogAgentFactory.kt index 5d24a205..5ef8d508 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/koog/KoogAgentFactory.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/koog/KoogAgentFactory.kt @@ -17,10 +17,12 @@ class KoogAgentFactory() { aiConfiguration: AIConfiguration, agent: KoreAgent, ): AIAgent? { - val promptExecutor = when (val ai = aiConfiguration.provider) { - is AIProvider_Anthropic -> simpleAnthropicExecutor(ai.apiToken) - is AIProvider_Google -> simpleGoogleAIExecutor(ai.apiToken) - is AIProvider_OpenAI -> simpleOpenAIExecutor(ai.apiToken) + val provider = aiConfiguration.provider + val promptExecutor = when (provider.id) { + AIProvider_Anthropic.id -> simpleAnthropicExecutor(provider.apiToken) + AIProvider_Google.id -> simpleGoogleAIExecutor(provider.apiToken) + AIProvider_OpenAI.id -> simpleOpenAIExecutor(provider.apiToken) + else -> return null } val llmModel = aiConfiguration.model.toKoogLLMModel() ?: return null return AIAgent( diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/dsl/config/ProviderConfig.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/dsl/config/ProviderConfig.kt index fa5e2bc3..3faa9577 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/dsl/config/ProviderConfig.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/dsl/config/ProviderConfig.kt @@ -3,12 +3,15 @@ package link.socket.ampere.dsl.config import link.socket.ampere.domain.ai.configuration.AIConfiguration import link.socket.ampere.domain.ai.configuration.AIConfiguration_Default import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups +import link.socket.ampere.domain.ai.model.AIModel import link.socket.ampere.domain.ai.model.AIModel_Claude import link.socket.ampere.domain.ai.model.AIModel_Gemini import link.socket.ampere.domain.ai.model.AIModel_OpenAI +import link.socket.ampere.domain.ai.provider.AIProvider import link.socket.ampere.domain.ai.provider.AIProvider_Anthropic import link.socket.ampere.domain.ai.provider.AIProvider_Google import link.socket.ampere.domain.ai.provider.AIProvider_OpenAI +import link.socket.ampere.domain.tool.AITool /** * Base interface for provider-specific configurations in the DSL. @@ -31,7 +34,7 @@ sealed interface ProviderConfig { * ) * ``` * - * @param apiKey Optional API key (falls back to environment variable if not provided) + * @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value. * @param model The Claude model to use (defaults to Sonnet 4) */ data class AnthropicConfig( @@ -42,7 +45,11 @@ data class AnthropicConfig( override fun toAIConfiguration(): AIConfiguration { val primary = AIConfiguration_Default( - provider = AIProvider_Anthropic, + provider = runtimeProviderOrDefault( + apiKey = apiKey, + defaultProvider = AIProvider_Anthropic, + runtimeProviderFactory = AIProvider_Anthropic::withApiToken, + ), model = model, ) @@ -72,7 +79,7 @@ data class AnthropicConfig( * ) * ``` * - * @param apiKey Optional API key (falls back to environment variable if not provided) + * @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value. * @param model The OpenAI model to use (defaults to GPT-4.1) */ data class OpenAIConfig( @@ -83,7 +90,11 @@ data class OpenAIConfig( override fun toAIConfiguration(): AIConfiguration { val primary = AIConfiguration_Default( - provider = AIProvider_OpenAI, + provider = runtimeProviderOrDefault( + apiKey = apiKey, + defaultProvider = AIProvider_OpenAI, + runtimeProviderFactory = AIProvider_OpenAI::withApiToken, + ), model = model, ) @@ -113,7 +124,7 @@ data class OpenAIConfig( * ) * ``` * - * @param apiKey Optional API key (falls back to environment variable if not provided) + * @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value. * @param model The Gemini model to use (defaults to Flash 2.5) */ data class GeminiConfig( @@ -124,7 +135,11 @@ data class GeminiConfig( override fun toAIConfiguration(): AIConfiguration { val primary = AIConfiguration_Default( - provider = AIProvider_Google, + provider = runtimeProviderOrDefault( + apiKey = apiKey, + defaultProvider = AIProvider_Google, + runtimeProviderFactory = AIProvider_Google::withApiToken, + ), model = model, ) @@ -142,3 +157,9 @@ data class GeminiConfig( backups = backups + config.toAIConfiguration(), ) } + +private fun runtimeProviderOrDefault( + apiKey: String?, + defaultProvider: AIProvider, + runtimeProviderFactory: (String) -> AIProvider, +): AIProvider = apiKey?.let(runtimeProviderFactory) ?: defaultProvider diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/dsl/config/ProviderConfigTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/dsl/config/ProviderConfigTest.kt new file mode 100644 index 00000000..e97722c0 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/dsl/config/ProviderConfigTest.kt @@ -0,0 +1,83 @@ +package link.socket.ampere.dsl.config + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups +import link.socket.ampere.domain.ai.model.AIModel_Claude +import link.socket.ampere.domain.ai.model.AIModel_Gemini +import link.socket.ampere.domain.ai.model.AIModel_OpenAI +import link.socket.ampere.domain.ai.provider.AIProvider_Anthropic +import link.socket.ampere.domain.ai.provider.AIProvider_Google +import link.socket.ampere.domain.ai.provider.AIProvider_OpenAI + +class ProviderConfigTest { + + @Test + fun `AnthropicConfig apiKey creates runtime provider with injected token`() { + val configuration = AnthropicConfig( + apiKey = "anthropic-runtime-key", + model = AIModel_Claude.Opus_4_1, + ).toAIConfiguration() + + assertEquals("anthropic-runtime-key", configuration.provider.apiToken) + assertEquals(AIProvider_Anthropic.id, configuration.provider.id) + assertEquals(AIProvider_Anthropic.name, configuration.provider.name) + assertSame(AIModel_Claude.Opus_4_1, configuration.model) + assertNotSame(AIProvider_Anthropic, configuration.provider) + } + + @Test + fun `OpenAIConfig apiKey creates runtime provider with injected token`() { + val configuration = OpenAIConfig( + apiKey = "openai-runtime-key", + model = AIModel_OpenAI.GPT_5, + ).toAIConfiguration() + + assertEquals("openai-runtime-key", configuration.provider.apiToken) + assertEquals(AIProvider_OpenAI.id, configuration.provider.id) + assertEquals(AIProvider_OpenAI.name, configuration.provider.name) + assertSame(AIModel_OpenAI.GPT_5, configuration.model) + assertNotSame(AIProvider_OpenAI, configuration.provider) + } + + @Test + fun `GeminiConfig apiKey creates runtime provider with injected token`() { + val configuration = GeminiConfig( + apiKey = "google-runtime-key", + model = AIModel_Gemini.Pro_2_5, + ).toAIConfiguration() + + assertEquals("google-runtime-key", configuration.provider.apiToken) + assertEquals(AIProvider_Google.id, configuration.provider.id) + assertEquals(AIProvider_Google.name, configuration.provider.name) + assertSame(AIModel_Gemini.Pro_2_5, configuration.model) + assertNotSame(AIProvider_Google, configuration.provider) + } + + @Test + fun `provider configs without apiKey keep singleton providers and preserve backup tokens`() { + val configuration = AnthropicConfig(model = AIModel_Claude.Sonnet_4) + .withBackup( + OpenAIConfig( + apiKey = "backup-openai-key", + model = AIModel_OpenAI.GPT_4_1, + ), + ) + .toAIConfiguration() + + val withBackups = assertIs(configuration) + + assertSame(AIProvider_Anthropic, withBackups.configurations.first().provider) + assertEquals( + listOf(AIProvider_Anthropic.id, AIProvider_OpenAI.id), + withBackups.configurations.map { it.provider.id }, + ) + assertEquals( + listOf(AIProvider_Anthropic.apiToken, "backup-openai-key"), + withBackups.configurations.map { it.provider.apiToken }, + ) + } +} diff --git a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/AmpereConfigYaml.kt b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/AmpereConfigYaml.kt index 8a0f8d1e..06f83a4a 100644 --- a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/AmpereConfigYaml.kt +++ b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/AmpereConfigYaml.kt @@ -20,6 +20,8 @@ import link.socket.ampere.dsl.config.ProviderConfig * ai: * provider: anthropic * model: sonnet-4 + * # Optional. Prefer injecting from your runtime environment instead of committing secrets. + * apiKey: your-api-key * backups: * - provider: openai * model: gpt-4.1 @@ -54,13 +56,14 @@ internal data class YamlAmpereConfig( internal data class YamlAIProviderConfig( val provider: String, val model: String, + val apiKey: String? = null, val backups: List = emptyList(), ) { fun toProviderConfig(): ProviderConfig { val baseConfig = when (provider.lowercase()) { - "anthropic" -> AnthropicConfig(model = toClaudeModel(model)) - "openai" -> OpenAIConfig(model = toOpenAIModel(model)) - "gemini" -> GeminiConfig(model = toGeminiModel(model)) + "anthropic" -> AnthropicConfig(apiKey = apiKey, model = toClaudeModel(model)) + "openai" -> OpenAIConfig(apiKey = apiKey, model = toOpenAIModel(model)) + "gemini" -> GeminiConfig(apiKey = apiKey, model = toGeminiModel(model)) else -> throw IllegalArgumentException( "Unknown provider: $provider. Supported: anthropic, openai, gemini", ) diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereConfigYamlTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereConfigYamlTest.kt new file mode 100644 index 00000000..a43bece1 --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereConfigYamlTest.kt @@ -0,0 +1,47 @@ +package link.socket.ampere.api + +import java.nio.file.Files +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups + +class AmpereConfigYamlTest { + + @Test + fun `fromYaml forwards api keys into provider configurations`() { + val configFile = Files.createTempFile("ampere-config", ".yaml") + configFile.writeText( + """ + ai: + provider: anthropic + model: sonnet-4 + apiKey: anthro-from-yaml + backups: + - provider: openai + model: gpt-4.1 + apiKey: openai-from-yaml + """.trimIndent(), + ) + + try { + val config = AmpereConfig.Builder().apply { + fromYaml(configFile.toString()) + }.build() + + val aiConfiguration = assertIs(config.provider.toAIConfiguration()) + assertEquals( + listOf("anthro-from-yaml", "openai-from-yaml"), + aiConfiguration.configurations.map { it.provider.apiToken }, + ) + assertEquals( + listOf("anthropic", "openai"), + aiConfiguration.configurations.map { it.provider.id }, + ) + } finally { + configFile.deleteIfExists() + } + } +}