Skip to content

BYOK: build an embedding service from a runtime-supplied key - #1892

Open
jasperblues wants to merge 8 commits into
mainfrom
feat/1883-byok-embedding
Open

BYOK: build an embedding service from a runtime-supplied key#1892
jasperblues wants to merge 8 commits into
mainfrom
feat/1883-byok-embedding

Conversation

@jasperblues

@jasperblues jasperblues commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #1883.

BYOK modelled LLMs but not embeddings. An EmbeddingService could only come from ModelProvider, which resolves against models registered at boot, so a key supplied at runtime could not produce one at all — a deployment that collects keys at first run had to restart before embeddings became available. That is the restart constraint BYOK already removes on the LLM side, still in place on the embedding side.

Guide sidesteps this today by using local ONNX embeddings, which need no key and so are unaffected by the boot-time resolution. That works, but it is a workaround rather than a choice: a deployment that wants a hosted embedding model with a user-supplied key has had no path at all.

What this adds

ByokFactory<EmbeddingService>, reached via openAiEmbedding() or byokEmbedding():

val embeddings: EmbeddingService =
    OpenAiCompatibleModelFactory.openAiEmbedding(userKey, "text-embedding-3-small")
        .buildValidated()

It probes with a single embed() and translates any provider error to InvalidApiKeyException — the same contract, and the same single failure mode, as the LLM side.

Two deliberate asymmetries with LLM BYOK

An embedding model is a schema commitment, not a stateless per-call service, so copying the LLM shape verbatim would be wrong in two specific ways.

No provider racing. detectProvider() is sound when any candidate that accepts the key is an acceptable answer. Here, whichever provider answered first would decide the index dimension. The model is always named explicitly, and detectProvider's KDoc now says why it is LLM-only.

No caller-supplied dimension. The width is whatever the model actually returned during validation. This started out as a configuredDimensions parameter with a mismatch check, and was dropped: the returned service now carries a reliable stamped width, so the caller can compare it against their index in one line, at the layer that actually owns the index. Keeping it would have added a second exception type to a contract that has exactly one.

Stamping the probed width also removes a hidden network call. Spring AI's AbstractEmbeddingModel.dimensions() does cache, but it resolves via dimensions(this, "Test", "Hello World") — passing the literal "Test" as the model name, so its known-dimensions lookup table can never hit and it always falls through to a live embed(), at whatever moment something first touches the property. We are already embedding to validate the key, so the width is free information.

Scope is installation or world level, not per-user. Per-user LLM keys are coherent; per-user embedding keys are not, because vectors from different models and widths would land in one shared index. Documented on the type so the per-user shape is not adopted by default.

Note for the consuming side

The mismatch guard did not disappear, it moved. Something now has to do check(embeddings.dimensions == index.dimensions) before writing into an existing index, and nothing enforces that it does. The docs section shows the check; worth confirming the index layer in embabel/me#735 has one.

Testing

  • OpenAiCompatibleModelFactoryByokEmbeddingTest — 9 cases against a fake embedding endpoint: factory types, single probe, error translation, empty vector rejected, dimension derived from the model, pricing passthrough.
  • 2 live cases added to OpenAiCompatibleModelFactoryByokIT (env-gated on OPENAI_API_KEY), both verified passing against the real API including the 1536-dimension assertion.
  • embabel-agent-openai, embabel-agent-byok, embabel-agent-ai: 37 run, 0 failures. openai / lmstudio / google-genai autoconfigure consumers rebuild clean.

openAiCompatibleEmbeddingService gains open (so the probe path is testable without network) and otherwise keeps its signature, so existing autoconfigure callers are untouched.

Docs: new "Embedding services" subsection in reference/customizing/page.adoc.

BYOK modelled LLMs but not embeddings, so an embedding service could only come
from ModelProvider, which resolves against models registered at boot. A key that
arrived after startup could not produce one at all: a deployment collecting keys
at first run had to restart before embeddings worked.

Adds ByokFactory<EmbeddingService>, reached via openAiEmbedding() or
byokEmbedding(). It probes the key with a single embed() and translates any
provider error to InvalidApiKeyException, matching the LLM contract exactly.

Two things deliberately differ from the LLM side, because an embedding model is
a schema commitment rather than a stateless per-call service:

No provider racing. detectProvider() is sound when any candidate accepting the
key is an acceptable answer. Here the provider that answers first would decide
the index dimension, so the model is always named explicitly.

No caller-supplied dimension. The width is whatever the model returned during
validation. Callers writing into an existing index compare it against that
index themselves; the check belongs to whoever owns the index. Stamping the
probed width also removes a hidden network call: Spring AI's
AbstractEmbeddingModel.dimensions() caches, but resolves via
dimensions(this, "Test", "Hello World") — the literal "Test" as model name, so
its known-dimensions table can never hit and it always falls through to a live
embed() at an arbitrary later moment.

Scope is installation or world level, not per-user: per-user embedding keys
would land vectors of different models and widths in one shared index.

Closes #1883

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jasperblues
jasperblues changed the base branch from main to feat/1888-starter-byok August 8, 2026 06:19
@jasperblues
jasperblues changed the base branch from feat/1888-starter-byok to main August 8, 2026 06:51
The LLM path treats a blank key as absent; the embedding path did not. Same deployment,
same set-but-empty environment variable, two different failures — an actionable error for
the chat key and an opaque provider error for the embedding key.

The message is duplicated rather than shared. #1888 grows the same rule for LLMs and a
shared helper is its right home, but reaching for that from here would couple two branches
for four lines. Collapse them once both have landed; the constant says so.

Signed-off-by: jasper blues <jasper@liberation-data.com>
@jasperblues

Copy link
Copy Markdown
Contributor Author

Follow-up for the duplicated blank-key message tracked in #1897 — collapse onto requireUsableApiKey once this and #1890 have both merged.

Any provider error during the probe became "invalid API key". On the LLM path the model is
defaulted, so the key really is the likely cause. Here it is not: the embedding model is
caller-supplied and mandatory, never defaulted and never detected, so a typo in it arrives
as the same provider error — and the caller is sent to re-check a key that was fine.

Signed-off-by: jasper blues <jasper@liberation-data.com>
@alexheifetz alexheifetz added this to the 2.0.0-Release milestone Aug 8, 2026

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasperblues - could you please see whether some artifacts can be used for other than OpenAI providers. thank you

Both failure modes — the probe throws, or it comes back empty — mean the same thing to the
caller, so they now share one exit instead of throwing from two places. The empty-vector case
becomes an ordinary require inside the try, which is also how it picks up the model name in
its message.

Message constant switched from concatenation to a raw string, per the convention this repo
follows everywhere else.

Signed-off-by: jasper blues <jasper@liberation-data.com>
Review point, and correct: nothing about "build a service, embed one probe, reject an empty
vector, rebuild stamping the width you observed" is OpenAI-specific. It now lives in
com.embabel.common.byok as validatedEmbeddingService(model, provider, build), taking the
provider's builder as a parameter. This factory supplies its own builder and the blank-key
guard; a future Bedrock or Vertex embedding path gets the validation without copying it.

That adds a byok -> embabel-agent-ai edge, for EmbeddingService. Acyclic and free in practice:
embabel-agent-ai does not reference byok, and byok's three consumers already depend on
embabel-agent-api, which brings ai transitively.

The spec classes stay where they are. Both construct OpenAiCompatibleModelFactory in
buildValidated(), and embabel-agent-openai already depends on byok, so moving them would close
a cycle - byok -> openai -> byok.

Signed-off-by: jasper blues <jasper@liberation-data.com>
The KDoc promised it throws on a blank key. It never sees a key — build() closes over
whatever credential the provider needs — so it cannot check one, and saying otherwise invites
a caller to skip their own guard. Says what it actually does now, and where the blank check
belongs.

Also noted why the second build() sits outside the try: it only constructs a service, so a
failure there is a bug in the builder rather than a rejected credential and should not be
reported as one.

The test's builder returned a Pair of a lambda and a recording list, which reads as noise at
every call site. A small named type instead.

Signed-off-by: jasper blues <jasper@liberation-data.com>
@jasperblues
jasperblues requested a review from igordayen August 9, 2026 01:20
…te test

The comment claimed two failure modes share the exit; there are three, because
build(null) is inside the try too. That is deliberate - for several providers a
malformed base URL or unusable credential surfaces while constructing the client
rather than on the first call - so the comment now says so, and says why the
second build stays outside.

The dimension test existed twice under different names with an identical body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasperblues - looks good. major inquiry about validation results caching and connection with (in future) with capability model. thanks

* @throws InvalidApiKeyException if the probe fails - an invalid key, an unreachable provider, a
* model the key cannot use - or if the model returns no vector
*/
fun validatedEmbeddingService(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the result cached? Please see how the probe is done for streaming

Also, is it strategically going to be part of the capability model (the one you wanted to have)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, effectively cached — and having looked at the streaming probe as you suggested, I would rather not copy it.

Here: the probe runs once inside buildValidated(), and the width it observes is stamped onto the returned service as configuredDimensions. So dimensions on the result never re-probes. One network call per service built, never per read.

Streaming: StreamingCapabilityVerifier.supportsStreaming(chatModel) opens a real stream with the prompt "Say 'test' to confirm streaming works", consumes it under a 100ms timeout, and caches nothing. SpringAiLlmService.supportsStreaming() calls it every time, and that is reachable per-turn — StreamingPromptRunner and DelegatingStreamingPromptRunner both call supportsStreaming() before streaming. So each check can be a live, billable provider request.

I think that is a bug rather than a pattern to follow, and I would rather raise it separately than have this PR match it. Happy to open that if you agree it is one.

On the capability model: my understanding is that is the traits API discussion, which has not moved yet. I have deliberately not designed against it here — nothing in this PR presupposes or forecloses it, and EmbeddingService gains no new capability surface. When traits do land, "has a usable key yet" and "what width does this produce" both look like things that would want expressing there, and I would rather fit them to the real shape than guess at it now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened it: #1900.

Reading the streaming probe properly for the comparison turned up a second thing beyond the cost, which I have included there — the two catch clauses:

} catch (e: UnsupportedOperationException) {
    false
} catch (e: Exception) {
    false
}

conflate "this model cannot stream" with "rate limited", "network blip", "bad key". A transient provider failure reports the model as non-streaming and the caller quietly takes a non-streaming path, so a wrong answer looks like a correct one. Arguably the worse of the two problems.

Nothing to change on this PR as a result — the embedding path already probes once per service built and stamps the result. Your question is what sent me to look, so thanks for it.

@@ -24,6 +24,16 @@
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not inherited?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — it was inherited all along, and removed now.

embabel-agent-byok was the only module in the tree declaring kotlin-stdlib explicitly. embabel-agent-ai, embabel-agent-openai and embabel-agent-anthropic all compile Kotlin without it. Deleted, and the module still builds.

val factory = FakeFactory { FloatArray(1536) }

val service = factory.buildValidatedEmbeddingService(
model = "text-embedding-3-small",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant for test?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. SMALL_MODEL, LARGE_MODEL and their widths are named in a companion now, rather than the ids being repeated across a dozen assertions.

One literal stays a literal deliberately — the deliberately-mistyped text-embedding-3-smal in the test that a validation failure names the model. Naming that one would hide the point of it.

Review points.

Model ids were repeated as literals across a dozen assertions, so a retired catalogue id would
be a hunt rather than an edit. Named constants now, widths alongside them.

embabel-agent-byok was the only module in the tree declaring kotlin-stdlib explicitly — no
sibling does, and it compiles Kotlin fine without it, so it was inherited all along.

Also carries a de-duplication that was already in the working tree: two of my tests asserted
the same thing about the probed width, and are now one, better named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jasper blues <jasper@liberation-data.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BYOK: no ByokFactory for EmbeddingService, so a runtime-supplied key cannot activate an embedding model

3 participants