Skip to content

Commit d40d889

Browse files
authored
Let a deployment add a BYOK provider without naming an SPI type (#1949)
* Let a deployment add a BYOK provider without naming an SPI type Closes #1945. * Name the provider first in both endpoint cases, and pin what the tests missed Two sibling cases took their two Strings in opposite orders, so a Java caller copying one line onto the other compiled and silently swapped provider and base URL. The base URL reaching the client, and the Anthropic resolver path, were both untested: dropping either left the suite green. Both new tests fail under that mutation. * Address review: log the factories, raw-string the warning, say how to add a provider Sealed so a case and the client that can talk to it stay together; adding a provider is a resolver returning an existing case, which the KDoc now says at the type.
1 parent 30ca408 commit d40d889

12 files changed

Lines changed: 760 additions & 163 deletions

File tree

embabel-agent-anthropic/src/main/kotlin/com/embabel/agent/anthropic/AnthropicModelFactory.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.embabel.agent.spi.LlmService
2222
import com.embabel.agent.spi.support.springai.SpringAiLlmService
2323
import com.embabel.chat.UserMessage
2424
import com.embabel.common.ai.model.LlmOptions
25+
import com.embabel.common.ai.model.PricingModel
2526
import com.embabel.common.byok.ByokFactory
2627
import com.embabel.common.byok.InvalidApiKeyException
2728
import com.embabel.common.byok.requireUsableApiKey
@@ -35,6 +36,7 @@ import org.springframework.beans.factory.ObjectProvider
3536
import org.springframework.retry.support.RetryTemplate
3637
import org.springframework.web.client.RestClient
3738
import java.time.Duration
39+
import java.time.LocalDate
3840

3941
/**
4042
* Builds Anthropic [LlmService] instances from a raw API key.
@@ -114,12 +116,23 @@ open class AnthropicModelFactory(
114116
* is ignored — retries are handled at the ChatClientLlmOperations layer via spring-retry.
115117
*
116118
* @param model Model identifier, e.g. [AnthropicModels.CLAUDE_HAIKU_4_5].
119+
* @param provider Provider name the built service reports. Defaults to [AnthropicModels.PROVIDER];
120+
* override it when the endpoint is a gateway fronting Anthropic, so cost and metadata lookups
121+
* key on the gateway rather than on Anthropic itself.
122+
* @param pricingModel What the call costs the deployment. Null - the default - is unknown rather
123+
* than free; a BYOK caller passes [com.embabel.common.ai.model.PricingModel.ALL_YOU_CAN_EAT],
124+
* since the user's own key is billed.
125+
* @param knowledgeCutoffDate Reaches the LLM as a prompt contribution, so state it only if you
126+
* know it for this model.
117127
*/
118128
@JvmOverloads
119129
fun build(
120130
model: String,
121131
@Suppress("UNUSED_PARAMETER")
122132
retryTemplate: RetryTemplate? = null,
133+
provider: String = AnthropicModels.PROVIDER,
134+
pricingModel: PricingModel? = null,
135+
knowledgeCutoffDate: LocalDate? = null,
123136
): LlmService<*> {
124137
val chatModel = AnthropicChatModel.builder()
125138
.options(AnthropicChatOptions.builder().model(model).build())
@@ -133,8 +146,10 @@ open class AnthropicModelFactory(
133146
return SpringAiLlmService(
134147
name = model,
135148
chatModel = chatModel,
136-
provider = AnthropicModels.PROVIDER,
149+
provider = provider,
137150
optionsConverter = AnthropicOptionsConverter,
151+
knowledgeCutoffDate = knowledgeCutoffDate,
152+
pricingModel = pricingModel,
138153
thinkingSupported = true,
139154
)
140155
}

embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelFactoryTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package com.embabel.agent.anthropic
1717

1818
import com.embabel.agent.api.models.AnthropicModels
1919
import com.embabel.agent.spi.support.springai.SpringAiLlmService
20+
import com.embabel.common.ai.model.PricingModel
21+
import com.embabel.common.ai.prompt.KnowledgeCutoffDate
2022
import com.embabel.common.byok.BLANK_API_KEY_MESSAGE
2123
import com.embabel.common.byok.InvalidApiKeyException
2224
import com.sun.net.httpserver.HttpServer
@@ -28,13 +30,15 @@ import io.mockk.mockk
2830
import org.junit.jupiter.api.AfterEach
2931
import org.junit.jupiter.api.Assertions.assertEquals
3032
import org.junit.jupiter.api.Assertions.assertNotNull
33+
import org.junit.jupiter.api.Assertions.assertNull
3134
import org.junit.jupiter.api.Assertions.assertTrue
3235
import org.junit.jupiter.api.BeforeEach
3336
import org.junit.jupiter.api.Test
3437
import org.junit.jupiter.api.assertThrows
3538
import org.springframework.beans.factory.ObjectProvider
3639
import org.springframework.web.client.RestClient
3740
import java.net.InetSocketAddress
41+
import java.time.LocalDate
3842
import java.util.function.Supplier
3943

4044
class AnthropicModelFactoryTest {
@@ -56,6 +60,40 @@ class AnthropicModelFactoryTest {
5660
assertEquals(AnthropicModels.PROVIDER, service.provider)
5761
}
5862

63+
@Test
64+
fun `build states the provider, price and cutoff a BYOK caller asks for`() {
65+
// A gateway fronting Anthropic reports itself, not Anthropic, so cost and metadata lookups
66+
// key on the gateway; and a BYOK call is billed to the user's key, not the deployment's.
67+
val factory = AnthropicModelFactory(
68+
apiKey = "test-key",
69+
observationRegistry = ObservationRegistry.NOOP,
70+
restClientBuilder = restClientBuilder,
71+
)
72+
val service = factory.build(
73+
model = AnthropicModels.CLAUDE_HAIKU_4_5,
74+
provider = "OurGateway",
75+
pricingModel = PricingModel.ALL_YOU_CAN_EAT,
76+
knowledgeCutoffDate = LocalDate.of(2026, 1, 31),
77+
) as SpringAiLlmService
78+
assertEquals("OurGateway", service.provider)
79+
assertEquals(PricingModel.ALL_YOU_CAN_EAT, service.pricingModel)
80+
assertEquals(LocalDate.of(2026, 1, 31), service.knowledgeCutoffDate)
81+
assertTrue(service.promptContributors.any { it is KnowledgeCutoffDate })
82+
}
83+
84+
@Test
85+
fun `build defaults to Anthropic itself, at an unstated price`() {
86+
val factory = AnthropicModelFactory(
87+
apiKey = "test-key",
88+
observationRegistry = ObservationRegistry.NOOP,
89+
restClientBuilder = restClientBuilder,
90+
)
91+
val service = factory.build(model = AnthropicModels.CLAUDE_HAIKU_4_5) as SpringAiLlmService
92+
assertEquals(AnthropicModels.PROVIDER, service.provider)
93+
assertNull(service.pricingModel)
94+
assertNull(service.knowledgeCutoffDate)
95+
}
96+
5997
@Test
6098
fun `build with custom baseUrl constructs without error`() {
6199
val factory = AnthropicModelFactory(

embabel-agent-api/src/main/kotlin/com/embabel/common/ai/model/ConfigurableModelProvider.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ class ConfigurableModelProvider @JvmOverloads constructor(
532532
?.also { credentialLlmServices[key] = it }
533533
if (llmService == null) {
534534
logger.warn(
535-
"No CredentialLlmServiceFactory handles provider '{}', needed for role '{}'",
535+
"""Nothing built a service for provider '{}', needed for role '{}'. Register a CredentialEndpointResolver for it, and check that the module speaking its wire protocol is on the classpath""",
536536
credential.provider, role,
537537
)
538538
return null
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright 2024-2026 Embabel Pty Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.embabel.common.ai.model
17+
18+
import java.time.LocalDate
19+
20+
/**
21+
* Where a user's key should be sent, and what the service built from it should say about itself.
22+
*
23+
* A value rather than a service: the application states which endpoint speaks for a provider, and
24+
* the platform builds the client, so nothing here names a type from `com.embabel.agent.spi`. That
25+
* is the point - [CredentialLlmServiceFactory] is the other way round, and requires a package the
26+
* documentation asks application code not to depend on.
27+
*
28+
* The case is the wire protocol, because that is what decides which client can talk to the
29+
* endpoint. Everything that varies within a protocol is a field.
30+
*
31+
* Adding a provider therefore does not mean adding a case, and cannot: the interface is sealed, so
32+
* only this module can. Nearly every provider speaks one of the protocols below, and reaching it is
33+
* a [CredentialEndpointResolver] returning [OpenAiCompatible] or [Anthropic] with your base URL -
34+
* no framework change, no new type.
35+
*
36+
* A case earns its place only when a protocol needs a *client* this framework does not have, and
37+
* whoever adds one has to add that client too. Sealed so that the two stay together: an open
38+
* hierarchy would let an application define a case nothing here can build, and it would fail at
39+
* runtime with a key already in hand. Until such a protocol is shipped, reach it by registering a
40+
* [CredentialLlmServiceFactory] and building the service yourself, accepting the SPI dependency
41+
* that carries.
42+
*/
43+
sealed interface CredentialEndpoint {
44+
45+
/**
46+
* Provider name the built service reports, which is what cost accounting and metadata lookups
47+
* key on. Usually the same name the credential carries, but it need not be: a credential holds
48+
* whatever spelling the application stored, and this is the framework's own.
49+
*/
50+
val provider: String
51+
52+
/**
53+
* Base URL to talk to, or null for the protocol's default host.
54+
*
55+
* Non-null is the usual case for a gateway or a proxy - which, along with a provider this
56+
* framework does not ship, is the reason to write a resolver at all. Set it. Null on an
57+
* [OpenAiCompatible] endpoint means OpenAI's own servers, so a null that reached one by
58+
* accident - an unset property, say - would send a gateway's key to OpenAI under the gateway's
59+
* name. The one legitimate null is OpenAI itself, which is why the type still permits it.
60+
*
61+
* [OpenAiCompatible] states it and [Anthropic] defaults it: "Anthropic's protocol" names a
62+
* host, "OpenAI-compatible" names none.
63+
*/
64+
val baseUrl: String?
65+
66+
/**
67+
* Defaults to [PricingModel.ALL_YOU_CAN_EAT] - zero - because a BYOK call is billed to the
68+
* user's own key rather than to the deployment, so charging it to the deployment's cost
69+
* accounting would be wrong in the one direction that matters. Set it only if you are
70+
* reselling the call and do want it counted.
71+
*/
72+
val pricingModel: PricingModel
73+
74+
/**
75+
* Null unless you know it for the model the role named. The name comes from configuration and
76+
* may be one this framework version has never heard of, so a cutoff stated here would be a
77+
* guess, and it reaches the LLM as a prompt contribution.
78+
*/
79+
val knowledgeCutoffDate: LocalDate?
80+
81+
/**
82+
* The OpenAI wire protocol, which most providers now speak: OpenAI itself, DeepSeek, Mistral,
83+
* Gemini and Atlas Cloud all reach the platform this way, as does the average self-hosted
84+
* gateway.
85+
*/
86+
data class OpenAiCompatible @JvmOverloads constructor(
87+
override val provider: String,
88+
override val baseUrl: String?,
89+
override val pricingModel: PricingModel = PricingModel.ALL_YOU_CAN_EAT,
90+
override val knowledgeCutoffDate: LocalDate? = null,
91+
) : CredentialEndpoint
92+
93+
/**
94+
* Anthropic's own protocol, for Anthropic and anything fronting it.
95+
*/
96+
data class Anthropic @JvmOverloads constructor(
97+
override val provider: String,
98+
override val baseUrl: String? = null,
99+
override val pricingModel: PricingModel = PricingModel.ALL_YOU_CAN_EAT,
100+
override val knowledgeCutoffDate: LocalDate? = null,
101+
) : CredentialEndpoint
102+
}
103+
104+
/**
105+
* Says where a user's key should be sent, for the providers this application knows about.
106+
*
107+
* Application API, not SPI: you implement it and register it as a bean, and the platform calls it.
108+
* The extension point for BYOK against a provider the framework does not ship. Register as many as
109+
* you like: the first non-null answer wins, in [org.springframework.core.Ordered] order, so a
110+
* resolver answers for the providers it knows and returns null for the rest. What reads them is
111+
* the `embabel-agent-starter-byok` machinery that builds services from user keys, so a deployment
112+
* without that starter can register these and never be asked.
113+
*
114+
* What these beans say is the first word, and the endpoints `embabel-agent-starter-byok` knows for
115+
* Anthropic, OpenAI, DeepSeek, Mistral, Gemini and Atlas Cloud are the last: answering for one of
116+
* those overrides it, for a proxy or a custom base URL, with no `@Order` needed. The shipped
117+
* endpoints are not beans, so they cannot tie with yours - `@Order` decides only which of *your*
118+
* resolvers is asked first, and ties between them fall back to bean registration order.
119+
*
120+
* ```kotlin
121+
* @Bean
122+
* fun ourGatewayEndpoint() = CredentialEndpointResolver { credential, _ ->
123+
* if (!credential.provider.equals("OurGateway", ignoreCase = true)) null
124+
* else CredentialEndpoint.OpenAiCompatible(provider = "OurGateway", baseUrl = GATEWAY_URL)
125+
* }
126+
* ```
127+
*
128+
* Return null rather than an endpoint for a provider you do not handle: a resolver that answers for
129+
* everything would point someone else's key at your gateway.
130+
*
131+
* The platform caches the service it builds, per (provider, key, model), so a resolver is consulted
132+
* on a cache miss rather than on every call - though possibly more than once within one, as each
133+
* wire protocol's builder gets its turn. Implementations must be thread-safe, and should be pure
134+
* and cheap for the same reason.
135+
*
136+
* That cache is keyed on (provider, key, model) and not on what you return here, so a resolver that
137+
* answers differently for the same three - per tenant, say, read from some ambient context - will
138+
* have its first answer serve every later caller sharing them.
139+
*/
140+
fun interface CredentialEndpointResolver {
141+
142+
/**
143+
* @param credential the user's key, and the provider name it was stored under
144+
* @param model the model the role named, which may be one this framework version does not know
145+
* @return where to send that key, or null to let the next resolver decide
146+
*/
147+
fun resolve(credential: ProviderCredential, model: String): CredentialEndpoint?
148+
}

embabel-agent-api/src/main/kotlin/com/embabel/common/ai/model/RoleResolver.kt

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ sealed interface RoleResolution {
4141

4242
/**
4343
* A provider key. The platform looks the role up for that provider and builds the service
44-
* through a [CredentialLlmServiceFactory], caching it.
44+
* through a [CredentialEndpointResolver], caching it.
4545
*/
4646
data class Credential(
4747
val credential: ProviderCredential,
@@ -85,31 +85,23 @@ fun interface RoleResolver {
8585
}
8686

8787
/**
88-
* Builds an [LlmService] from a user-supplied key.
88+
* Builds an [LlmService] from a user-supplied key, for a wire protocol the framework has no client
89+
* for.
8990
*
90-
* `embabel-agent-starter-byok` ships these for every provider BYOK supports - Anthropic, OpenAI,
91-
* DeepSeek, Mistral, Gemini and Atlas Cloud - so per-user keys work with no application code at
92-
* all; see `com.embabel.agent.config.models.byok.CredentialLlmServiceFactoryConfig`. Register one
93-
* of your own only for a provider outside that set, or to override a shipped one for a custom base
94-
* URL or a proxy: the shipped beans stand aside for a bean of the same name.
91+
* The second of two tiers, and the one to reach for last: it names [LlmService], which lives in
92+
* `com.embabel.agent.spi` - a package application code is asked not to depend on. Adding a provider
93+
* that speaks the OpenAI or Anthropic protocol - which is nearly all of them - is a
94+
* [CredentialEndpointResolver] returning a value instead, with no SPI type in sight.
9595
*
96-
* Without a factory that handles the provider, a role resolving to [RoleResolution.Credential]
97-
* fails with [NoSuitableModelException] and a log line naming the provider nothing handled. An
98-
* implementation is a one-liner over the provider's own BYOK factory:
96+
* `embabel-agent-starter-byok` ships an implementation per wire protocol, covering every provider
97+
* BYOK supports - Anthropic, OpenAI, DeepSeek, Mistral, Gemini and Atlas Cloud - so per-user keys
98+
* work with no application code at all; see
99+
* `com.embabel.agent.config.models.byok.CredentialEndpointConfig`. The shipped beans stand aside
100+
* for a bean of the same name, but replacing one that way also replaces the code that builds what
101+
* [CredentialEndpointResolver]s resolve for that protocol.
99102
*
100-
* ```kotlin
101-
* @Bean
102-
* fun ourGatewayCredentialFactory() = CredentialLlmServiceFactory { credential, model ->
103-
* if (!credential.provider.equals("OurGateway", ignoreCase = true)) null
104-
* else OpenAiCompatibleModelFactory(baseUrl = GATEWAY_URL, apiKey = credential.apiKey)
105-
* .openAiCompatibleLlm(
106-
* model = model,
107-
* pricingModel = PricingModel.ALL_YOU_CAN_EAT,
108-
* provider = "OurGateway",
109-
* knowledgeCutoffDate = null,
110-
* )
111-
* }
112-
* ```
103+
* Without a factory that handles the provider, a role resolving to [RoleResolution.Credential]
104+
* fails with [NoSuitableModelException] and a log line naming the provider nothing handled.
113105
*
114106
* Return null for a provider this factory does not handle, rather than building something: the
115107
* platform tries each factory in turn, and a factory that answers for everything would hand back a

0 commit comments

Comments
 (0)