Bug
createEmbeddingProvider() in src/providers/embedding/index.ts constructs the OpenAI embedding provider like this:
case "openai":
return withDimensionGuard(new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY")!));
It passes OPENAI_API_KEY explicitly as the constructor argument. But OpenAIEmbeddingProvider's constructor (src/providers/embedding/openai.ts) is designed with its own fallback chain:
constructor(apiKey?: string) {
// Separate API key path: caller-passed wins, then OPENAI_EMBEDDING_API_KEY,
// then fall back to OPENAI_API_KEY. Allows e.g. a placeholder key for
// local endpoints that ignore Authorization (most do).
this.apiKey =
apiKey ||
getEnvVar("OPENAI_EMBEDDING_API_KEY") ||
getEnvVar("OPENAI_API_KEY") ||
"";
...
}
Since the factory always supplies a truthy apiKey (the value of OPENAI_API_KEY), the || short-circuits before OPENAI_EMBEDDING_API_KEY is ever read. The constructor's own docstring documents "a separate API key for the embedding endpoint," but the only call site that actually builds this provider makes that fallback unreachable. Confirmed via getEnvVar's own resolution and via reading the compiled bundle in a running container — not a build artifact issue.
Why this matters
Anyone trying to use a different, more narrowly-scoped credential for embeddings than for chat completions — e.g. a proxy (LiteLLM, etc.) issuing a virtual key restricted to only the embedding model — cannot do so. Every embedding call is authenticated with OPENAI_API_KEY, even when OPENAI_EMBEDDING_API_KEY is set to something else. If the chat-scoped key is restricted (as it should be, for least-privilege / attribution reasons) and doesn't include the embedding model in its allowed scope, every embedding call fails with a 403 from the proxy, while OPENAI_EMBEDDING_API_KEY — set correctly, and independently verified to work when used directly — sits unused.
Repro
- Set
OPENAI_API_KEY to a credential scoped to chat models only (e.g. a LiteLLM virtual key with models: ["some-chat-model"]).
- Set
OPENAI_EMBEDDING_API_KEY to a different, valid credential scoped to an embedding model (e.g. models: ["some-embedding-model"]), along with OPENAI_EMBEDDING_BASE_URL, OPENAI_EMBEDDING_MODEL, OPENAI_EMBEDDING_DIMENSIONS pointing at that model.
- Trigger an embedding (e.g. via
observe/memory_save).
- The request is authenticated with
OPENAI_API_KEY, not OPENAI_EMBEDDING_API_KEY, and fails if the chat key isn't also scoped to the embedding model.
Independently confirmed against LiteLLM_SpendLogs-equivalent proxy attribution: the failed embedding call is billed against the chat key's alias, never the embedding key's.
Verified present in the currently-published 0.9.28 (one patch above 0.9.27, which is what we had pinned) — not yet fixed upstream as far as I can tell.
Suggested fix
Don't pass an explicit apiKey from the factory when OPENAI_EMBEDDING_API_KEY should be allowed to take precedence — e.g.:
case "openai":
return withDimensionGuard(new OpenAIEmbeddingProvider());
and let the constructor's own designed fallback chain (apiKey || OPENAI_EMBEDDING_API_KEY || OPENAI_API_KEY) do what its comment already says it does. (Or, if createEmbeddingProvider() needs to pass something explicit for other reasons, have it resolve OPENAI_EMBEDDING_API_KEY || OPENAI_API_KEY itself before passing it in, rather than skipping straight to OPENAI_API_KEY.)
Happy to open a PR with this change if useful — wanted to file the report with the repro first in case there's context I'm missing about why the factory bypasses the constructor's own fallback.
Bug
createEmbeddingProvider()insrc/providers/embedding/index.tsconstructs the OpenAI embedding provider like this:It passes
OPENAI_API_KEYexplicitly as the constructor argument. ButOpenAIEmbeddingProvider's constructor (src/providers/embedding/openai.ts) is designed with its own fallback chain:Since the factory always supplies a truthy
apiKey(the value ofOPENAI_API_KEY), the||short-circuits beforeOPENAI_EMBEDDING_API_KEYis ever read. The constructor's own docstring documents "a separate API key for the embedding endpoint," but the only call site that actually builds this provider makes that fallback unreachable. Confirmed viagetEnvVar's own resolution and via reading the compiled bundle in a running container — not a build artifact issue.Why this matters
Anyone trying to use a different, more narrowly-scoped credential for embeddings than for chat completions — e.g. a proxy (LiteLLM, etc.) issuing a virtual key restricted to only the embedding model — cannot do so. Every embedding call is authenticated with
OPENAI_API_KEY, even whenOPENAI_EMBEDDING_API_KEYis set to something else. If the chat-scoped key is restricted (as it should be, for least-privilege / attribution reasons) and doesn't include the embedding model in its allowed scope, every embedding call fails with a 403 from the proxy, whileOPENAI_EMBEDDING_API_KEY— set correctly, and independently verified to work when used directly — sits unused.Repro
OPENAI_API_KEYto a credential scoped to chat models only (e.g. a LiteLLM virtual key withmodels: ["some-chat-model"]).OPENAI_EMBEDDING_API_KEYto a different, valid credential scoped to an embedding model (e.g.models: ["some-embedding-model"]), along withOPENAI_EMBEDDING_BASE_URL,OPENAI_EMBEDDING_MODEL,OPENAI_EMBEDDING_DIMENSIONSpointing at that model.observe/memory_save).OPENAI_API_KEY, notOPENAI_EMBEDDING_API_KEY, and fails if the chat key isn't also scoped to the embedding model.Independently confirmed against
LiteLLM_SpendLogs-equivalent proxy attribution: the failed embedding call is billed against the chat key's alias, never the embedding key's.Verified present in the currently-published
0.9.28(one patch above0.9.27, which is what we had pinned) — not yet fixed upstream as far as I can tell.Suggested fix
Don't pass an explicit
apiKeyfrom the factory whenOPENAI_EMBEDDING_API_KEYshould be allowed to take precedence — e.g.:and let the constructor's own designed fallback chain (
apiKey || OPENAI_EMBEDDING_API_KEY || OPENAI_API_KEY) do what its comment already says it does. (Or, ifcreateEmbeddingProvider()needs to pass something explicit for other reasons, have it resolveOPENAI_EMBEDDING_API_KEY || OPENAI_API_KEYitself before passing it in, rather than skipping straight toOPENAI_API_KEY.)Happy to open a PR with this change if useful — wanted to file the report with the repro first in case there's context I'm missing about why the factory bypasses the constructor's own fallback.