Skip to content

Commit be552e4

Browse files
authored
AMPR-145 #451 add runtime provider API key injection (#452)
1 parent 7cda2eb commit be552e4

10 files changed

Lines changed: 227 additions & 14 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,12 @@ dependencies {
9898
```kotlin
9999
val team = AgentTeam.create {
100100
// Configure your AI provider
101-
config(AnthropicConfig(model = Claude.Sonnet4))
101+
config(
102+
AnthropicConfig(
103+
apiKey = System.getenv("ANTHROPIC_API_KEY"),
104+
model = Claude.Sonnet4,
105+
),
106+
)
102107

103108
// Add agents with personality traits
104109
agent(ProductManager) { personality { directness = 0.8 } }
@@ -119,6 +124,8 @@ team.events.collect { event ->
119124
}
120125
}
121126
```
127+
128+
`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.
122129
</details>
123130

124131
---

ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Anthropic.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,13 @@ data object AIProvider_Anthropic : AIProvider<AITool_Claude, AIModel_Claude> {
2424
url = ANTHROPIC_API_ENDPOINT,
2525
)
2626
}
27+
28+
internal fun withApiToken(apiToken: String): AIProvider<AITool_Claude, AIModel_Claude> =
29+
RuntimeAIProvider(
30+
id = id,
31+
name = name,
32+
apiToken = apiToken,
33+
availableModels = availableModels,
34+
baseUrl = ANTHROPIC_API_ENDPOINT,
35+
)
2736
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_Google.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,13 @@ data object AIProvider_Google : AIProvider<AITool_Gemini, AIModel_Gemini> {
2424
url = GOOGLE_API_ENDPOINT,
2525
)
2626
}
27+
28+
internal fun withApiToken(apiToken: String): AIProvider<AITool_Gemini, AIModel_Gemini> =
29+
RuntimeAIProvider(
30+
id = id,
31+
name = name,
32+
apiToken = apiToken,
33+
availableModels = availableModels,
34+
baseUrl = GOOGLE_API_ENDPOINT,
35+
)
2736
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/provider/AIProvider_OpenAI.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,12 @@ data object AIProvider_OpenAI : AIProvider<AITool_OpenAI, AIModel_OpenAI> {
2222
token = apiToken,
2323
)
2424
}
25+
26+
internal fun withApiToken(apiToken: String): AIProvider<AITool_OpenAI, AIModel_OpenAI> =
27+
RuntimeAIProvider(
28+
id = id,
29+
name = name,
30+
apiToken = apiToken,
31+
availableModels = availableModels,
32+
)
2533
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package link.socket.ampere.domain.ai.provider
2+
3+
import com.aallam.openai.client.OpenAI as Client
4+
import link.socket.ampere.domain.ai.model.AIModel
5+
import link.socket.ampere.domain.tool.AITool
6+
7+
internal data class RuntimeAIProvider<
8+
TD : AITool,
9+
L : AIModel,
10+
>(
11+
override val id: ProviderId,
12+
override val name: String,
13+
override val apiToken: String,
14+
override val availableModels: List<L>,
15+
private val baseUrl: String? = null,
16+
) : AIProvider<TD, L> {
17+
18+
override val client: Client by lazy {
19+
AIProvider.createClient(
20+
token = apiToken,
21+
url = baseUrl,
22+
)
23+
}
24+
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/koog/KoogAgentFactory.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ class KoogAgentFactory() {
1717
aiConfiguration: AIConfiguration,
1818
agent: KoreAgent,
1919
): AIAgent<String, *>? {
20-
val promptExecutor = when (val ai = aiConfiguration.provider) {
21-
is AIProvider_Anthropic -> simpleAnthropicExecutor(ai.apiToken)
22-
is AIProvider_Google -> simpleGoogleAIExecutor(ai.apiToken)
23-
is AIProvider_OpenAI -> simpleOpenAIExecutor(ai.apiToken)
20+
val provider = aiConfiguration.provider
21+
val promptExecutor = when (provider.id) {
22+
AIProvider_Anthropic.id -> simpleAnthropicExecutor(provider.apiToken)
23+
AIProvider_Google.id -> simpleGoogleAIExecutor(provider.apiToken)
24+
AIProvider_OpenAI.id -> simpleOpenAIExecutor(provider.apiToken)
25+
else -> return null
2426
}
2527
val llmModel = aiConfiguration.model.toKoogLLMModel() ?: return null
2628
return AIAgent(

ampere-core/src/commonMain/kotlin/link/socket/ampere/dsl/config/ProviderConfig.kt

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package link.socket.ampere.dsl.config
33
import link.socket.ampere.domain.ai.configuration.AIConfiguration
44
import link.socket.ampere.domain.ai.configuration.AIConfiguration_Default
55
import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups
6+
import link.socket.ampere.domain.ai.model.AIModel
67
import link.socket.ampere.domain.ai.model.AIModel_Claude
78
import link.socket.ampere.domain.ai.model.AIModel_Gemini
89
import link.socket.ampere.domain.ai.model.AIModel_OpenAI
10+
import link.socket.ampere.domain.ai.provider.AIProvider
911
import link.socket.ampere.domain.ai.provider.AIProvider_Anthropic
1012
import link.socket.ampere.domain.ai.provider.AIProvider_Google
1113
import link.socket.ampere.domain.ai.provider.AIProvider_OpenAI
14+
import link.socket.ampere.domain.tool.AITool
1215

1316
/**
1417
* Base interface for provider-specific configurations in the DSL.
@@ -31,7 +34,7 @@ sealed interface ProviderConfig {
3134
* )
3235
* ```
3336
*
34-
* @param apiKey Optional API key (falls back to environment variable if not provided)
37+
* @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value.
3538
* @param model The Claude model to use (defaults to Sonnet 4)
3639
*/
3740
data class AnthropicConfig(
@@ -42,7 +45,11 @@ data class AnthropicConfig(
4245

4346
override fun toAIConfiguration(): AIConfiguration {
4447
val primary = AIConfiguration_Default(
45-
provider = AIProvider_Anthropic,
48+
provider = runtimeProviderOrDefault(
49+
apiKey = apiKey,
50+
defaultProvider = AIProvider_Anthropic,
51+
runtimeProviderFactory = AIProvider_Anthropic::withApiToken,
52+
),
4653
model = model,
4754
)
4855

@@ -72,7 +79,7 @@ data class AnthropicConfig(
7279
* )
7380
* ```
7481
*
75-
* @param apiKey Optional API key (falls back to environment variable if not provided)
82+
* @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value.
7683
* @param model The OpenAI model to use (defaults to GPT-4.1)
7784
*/
7885
data class OpenAIConfig(
@@ -83,7 +90,11 @@ data class OpenAIConfig(
8390

8491
override fun toAIConfiguration(): AIConfiguration {
8592
val primary = AIConfiguration_Default(
86-
provider = AIProvider_OpenAI,
93+
provider = runtimeProviderOrDefault(
94+
apiKey = apiKey,
95+
defaultProvider = AIProvider_OpenAI,
96+
runtimeProviderFactory = AIProvider_OpenAI::withApiToken,
97+
),
8798
model = model,
8899
)
89100

@@ -113,7 +124,7 @@ data class OpenAIConfig(
113124
* )
114125
* ```
115126
*
116-
* @param apiKey Optional API key (falls back to environment variable if not provided)
127+
* @param apiKey Optional runtime API key. When omitted, falls back to the generated KotlinConfig value.
117128
* @param model The Gemini model to use (defaults to Flash 2.5)
118129
*/
119130
data class GeminiConfig(
@@ -124,7 +135,11 @@ data class GeminiConfig(
124135

125136
override fun toAIConfiguration(): AIConfiguration {
126137
val primary = AIConfiguration_Default(
127-
provider = AIProvider_Google,
138+
provider = runtimeProviderOrDefault(
139+
apiKey = apiKey,
140+
defaultProvider = AIProvider_Google,
141+
runtimeProviderFactory = AIProvider_Google::withApiToken,
142+
),
128143
model = model,
129144
)
130145

@@ -142,3 +157,9 @@ data class GeminiConfig(
142157
backups = backups + config.toAIConfiguration(),
143158
)
144159
}
160+
161+
private fun <TD : AITool, L : AIModel> runtimeProviderOrDefault(
162+
apiKey: String?,
163+
defaultProvider: AIProvider<TD, L>,
164+
runtimeProviderFactory: (String) -> AIProvider<TD, L>,
165+
): AIProvider<TD, L> = apiKey?.let(runtimeProviderFactory) ?: defaultProvider
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package link.socket.ampere.dsl.config
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertIs
6+
import kotlin.test.assertNotSame
7+
import kotlin.test.assertSame
8+
import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups
9+
import link.socket.ampere.domain.ai.model.AIModel_Claude
10+
import link.socket.ampere.domain.ai.model.AIModel_Gemini
11+
import link.socket.ampere.domain.ai.model.AIModel_OpenAI
12+
import link.socket.ampere.domain.ai.provider.AIProvider_Anthropic
13+
import link.socket.ampere.domain.ai.provider.AIProvider_Google
14+
import link.socket.ampere.domain.ai.provider.AIProvider_OpenAI
15+
16+
class ProviderConfigTest {
17+
18+
@Test
19+
fun `AnthropicConfig apiKey creates runtime provider with injected token`() {
20+
val configuration = AnthropicConfig(
21+
apiKey = "anthropic-runtime-key",
22+
model = AIModel_Claude.Opus_4_1,
23+
).toAIConfiguration()
24+
25+
assertEquals("anthropic-runtime-key", configuration.provider.apiToken)
26+
assertEquals(AIProvider_Anthropic.id, configuration.provider.id)
27+
assertEquals(AIProvider_Anthropic.name, configuration.provider.name)
28+
assertSame(AIModel_Claude.Opus_4_1, configuration.model)
29+
assertNotSame(AIProvider_Anthropic, configuration.provider)
30+
}
31+
32+
@Test
33+
fun `OpenAIConfig apiKey creates runtime provider with injected token`() {
34+
val configuration = OpenAIConfig(
35+
apiKey = "openai-runtime-key",
36+
model = AIModel_OpenAI.GPT_5,
37+
).toAIConfiguration()
38+
39+
assertEquals("openai-runtime-key", configuration.provider.apiToken)
40+
assertEquals(AIProvider_OpenAI.id, configuration.provider.id)
41+
assertEquals(AIProvider_OpenAI.name, configuration.provider.name)
42+
assertSame(AIModel_OpenAI.GPT_5, configuration.model)
43+
assertNotSame(AIProvider_OpenAI, configuration.provider)
44+
}
45+
46+
@Test
47+
fun `GeminiConfig apiKey creates runtime provider with injected token`() {
48+
val configuration = GeminiConfig(
49+
apiKey = "google-runtime-key",
50+
model = AIModel_Gemini.Pro_2_5,
51+
).toAIConfiguration()
52+
53+
assertEquals("google-runtime-key", configuration.provider.apiToken)
54+
assertEquals(AIProvider_Google.id, configuration.provider.id)
55+
assertEquals(AIProvider_Google.name, configuration.provider.name)
56+
assertSame(AIModel_Gemini.Pro_2_5, configuration.model)
57+
assertNotSame(AIProvider_Google, configuration.provider)
58+
}
59+
60+
@Test
61+
fun `provider configs without apiKey keep singleton providers and preserve backup tokens`() {
62+
val configuration = AnthropicConfig(model = AIModel_Claude.Sonnet_4)
63+
.withBackup(
64+
OpenAIConfig(
65+
apiKey = "backup-openai-key",
66+
model = AIModel_OpenAI.GPT_4_1,
67+
),
68+
)
69+
.toAIConfiguration()
70+
71+
val withBackups = assertIs<AIConfiguration_WithBackups>(configuration)
72+
73+
assertSame(AIProvider_Anthropic, withBackups.configurations.first().provider)
74+
assertEquals(
75+
listOf(AIProvider_Anthropic.id, AIProvider_OpenAI.id),
76+
withBackups.configurations.map { it.provider.id },
77+
)
78+
assertEquals(
79+
listOf(AIProvider_Anthropic.apiToken, "backup-openai-key"),
80+
withBackups.configurations.map { it.provider.apiToken },
81+
)
82+
}
83+
}

ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/AmpereConfigYaml.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import link.socket.ampere.dsl.config.ProviderConfig
2020
* ai:
2121
* provider: anthropic
2222
* model: sonnet-4
23+
* # Optional. Prefer injecting from your runtime environment instead of committing secrets.
24+
* apiKey: your-api-key
2325
* backups:
2426
* - provider: openai
2527
* model: gpt-4.1
@@ -54,13 +56,14 @@ internal data class YamlAmpereConfig(
5456
internal data class YamlAIProviderConfig(
5557
val provider: String,
5658
val model: String,
59+
val apiKey: String? = null,
5760
val backups: List<YamlAIProviderConfig> = emptyList(),
5861
) {
5962
fun toProviderConfig(): ProviderConfig {
6063
val baseConfig = when (provider.lowercase()) {
61-
"anthropic" -> AnthropicConfig(model = toClaudeModel(model))
62-
"openai" -> OpenAIConfig(model = toOpenAIModel(model))
63-
"gemini" -> GeminiConfig(model = toGeminiModel(model))
64+
"anthropic" -> AnthropicConfig(apiKey = apiKey, model = toClaudeModel(model))
65+
"openai" -> OpenAIConfig(apiKey = apiKey, model = toOpenAIModel(model))
66+
"gemini" -> GeminiConfig(apiKey = apiKey, model = toGeminiModel(model))
6467
else -> throw IllegalArgumentException(
6568
"Unknown provider: $provider. Supported: anthropic, openai, gemini",
6669
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package link.socket.ampere.api
2+
3+
import java.nio.file.Files
4+
import kotlin.io.path.deleteIfExists
5+
import kotlin.io.path.writeText
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertIs
9+
import link.socket.ampere.domain.ai.configuration.AIConfiguration_WithBackups
10+
11+
class AmpereConfigYamlTest {
12+
13+
@Test
14+
fun `fromYaml forwards api keys into provider configurations`() {
15+
val configFile = Files.createTempFile("ampere-config", ".yaml")
16+
configFile.writeText(
17+
"""
18+
ai:
19+
provider: anthropic
20+
model: sonnet-4
21+
apiKey: anthro-from-yaml
22+
backups:
23+
- provider: openai
24+
model: gpt-4.1
25+
apiKey: openai-from-yaml
26+
""".trimIndent(),
27+
)
28+
29+
try {
30+
val config = AmpereConfig.Builder().apply {
31+
fromYaml(configFile.toString())
32+
}.build()
33+
34+
val aiConfiguration = assertIs<AIConfiguration_WithBackups>(config.provider.toAIConfiguration())
35+
assertEquals(
36+
listOf("anthro-from-yaml", "openai-from-yaml"),
37+
aiConfiguration.configurations.map { it.provider.apiToken },
38+
)
39+
assertEquals(
40+
listOf("anthropic", "openai"),
41+
aiConfiguration.configurations.map { it.provider.id },
42+
)
43+
} finally {
44+
configFile.deleteIfExists()
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)