Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand All @@ -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.
</details>

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,13 @@ data object AIProvider_Anthropic : AIProvider<AITool_Claude, AIModel_Claude> {
url = ANTHROPIC_API_ENDPOINT,
)
}

internal fun withApiToken(apiToken: String): AIProvider<AITool_Claude, AIModel_Claude> =
RuntimeAIProvider(
id = id,
name = name,
apiToken = apiToken,
availableModels = availableModels,
baseUrl = ANTHROPIC_API_ENDPOINT,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,13 @@ data object AIProvider_Google : AIProvider<AITool_Gemini, AIModel_Gemini> {
url = GOOGLE_API_ENDPOINT,
)
}

internal fun withApiToken(apiToken: String): AIProvider<AITool_Gemini, AIModel_Gemini> =
RuntimeAIProvider(
id = id,
name = name,
apiToken = apiToken,
availableModels = availableModels,
baseUrl = GOOGLE_API_ENDPOINT,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@ data object AIProvider_OpenAI : AIProvider<AITool_OpenAI, AIModel_OpenAI> {
token = apiToken,
)
}

internal fun withApiToken(apiToken: String): AIProvider<AITool_OpenAI, AIModel_OpenAI> =
RuntimeAIProvider(
id = id,
name = name,
apiToken = apiToken,
availableModels = availableModels,
)
}
Original file line number Diff line number Diff line change
@@ -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<L>,
private val baseUrl: String? = null,
) : AIProvider<TD, L> {

override val client: Client by lazy {
AIProvider.createClient(
token = apiToken,
url = baseUrl,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ class KoogAgentFactory() {
aiConfiguration: AIConfiguration,
agent: KoreAgent,
): AIAgent<String, *>? {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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,
)

Expand Down Expand Up @@ -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(
Expand All @@ -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,
)

Expand Down Expand Up @@ -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(
Expand All @@ -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,
)

Expand All @@ -142,3 +157,9 @@ data class GeminiConfig(
backups = backups + config.toAIConfiguration(),
)
}

private fun <TD : AITool, L : AIModel> runtimeProviderOrDefault(
apiKey: String?,
defaultProvider: AIProvider<TD, L>,
runtimeProviderFactory: (String) -> AIProvider<TD, L>,
): AIProvider<TD, L> = apiKey?.let(runtimeProviderFactory) ?: defaultProvider
Original file line number Diff line number Diff line change
@@ -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<AIConfiguration_WithBackups>(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 },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<YamlAIProviderConfig> = 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",
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AIConfiguration_WithBackups>(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()
}
}
}