From ef231c49108a3d0dc9d1b6c852e5468377966f24 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 23 Jun 2026 23:27:00 +0200 Subject: [PATCH] fix(ai): resolve request-scoped auth before provider calls closes #6021 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth/resolve.ts | 30 ++++++++++++++++++++++--- packages/ai/src/images-models.ts | 5 ++++- packages/ai/src/models.ts | 11 ++++++++- packages/ai/test/models-runtime.test.ts | 24 ++++++++++++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2b9f13f2..10e3cce7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed request-scoped `apiKey` and `env` values to participate in provider auth resolution, so providers such as Cloudflare can derive request-specific base URLs from explicit call options ([#6021](https://github.com/earendil-works/pi/issues/6021)). - Restored temporary legacy per-API stream aliases such as `streamSimpleOpenAICompletions` on the compat entrypoint ([#6016](https://github.com/earendil-works/pi/issues/6016), [#6017](https://github.com/earendil-works/pi/issues/6017)). ## [0.80.1] - 2026-06-23 diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index 8ed866fd..81d7a270 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -1,4 +1,4 @@ -import type { Api, ImagesApi, ImagesModel, Model } from "../types.ts"; +import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv } from "../types.ts"; import type { ApiKeyAuth, ApiKeyCredential, @@ -13,6 +13,11 @@ import type { export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth"; +export interface AuthResolutionOverrides { + apiKey?: string; + env?: ProviderEnv; +} + export class ModelsError extends Error { readonly code: ModelsErrorCode; @@ -37,20 +42,39 @@ export async function resolveProviderAuth( model: AuthModel, credentials: CredentialStore, authContext: AuthContext, + overrides?: AuthResolutionOverrides, ): Promise { + const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext; + + if (overrides?.apiKey !== undefined && provider.auth.apiKey) { + return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, { + type: "api_key", + key: overrides.apiKey, + env: overrides.env, + }); + } + const stored = await readCredential(credentials, provider.id); if (stored) { if (stored.type === "oauth" && provider.auth.oauth) { return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored); } if (stored.type === "api_key" && provider.auth.apiKey) { - return resolveApiKey(authContext, provider.auth.apiKey, model, stored); + const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; + return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, credential); } return undefined; } // Ambient (env vars, AWS profiles, ADC files). - return provider.auth.apiKey ? resolveApiKey(authContext, provider.auth.apiKey, model, undefined) : undefined; + return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, model, undefined) : undefined; +} + +function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext { + return { + env: async (name) => env[name] || (await base.env(name)), + fileExists: (path) => base.fileExists(path), + }; } /** diff --git a/packages/ai/src/images-models.ts b/packages/ai/src/images-models.ts index 8dd655d4..0ca5f2da 100644 --- a/packages/ai/src/images-models.ts +++ b/packages/ai/src/images-models.ts @@ -184,7 +184,10 @@ class ImagesModelsImpl implements MutableImagesModels { throw new ModelsError("provider", `Unknown provider: ${model.provider}`); } - const resolution = await this.getAuth(model); + const resolution = await resolveProviderAuth(provider, model, this.credentials, this.authContext, { + apiKey: options?.apiKey, + env: options?.env, + }); const auth = resolution?.auth; if (!auth) { return provider.generateImages(model, context, options); diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 669b1525..f9cf27d3 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -231,7 +231,16 @@ class ModelsImpl implements MutableModels { model: Model, options: TOptions | undefined, ): Promise<{ requestModel: Model; requestOptions: TOptions | undefined }> { - const resolution = await this.getAuth(model); + const resolution = await resolveProviderAuth( + this.requireProvider(model), + model, + this.credentials, + this.authContext, + { + apiKey: options?.apiKey, + env: options?.env, + }, + ); const auth = resolution?.auth; if (!auth) return { requestModel: model, requestOptions: options }; diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index f55e035e..7f805e26 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -360,6 +360,30 @@ describe("Models runtime", () => { await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" }); }); + it("uses explicit request api key and env during provider auth resolution", async () => { + const calls: ProviderCall[] = []; + const apiKey: ApiKeyAuth = { + name: "Scoped", + resolve: async ({ credential, ctx }) => { + const account = credential?.env?.ACCOUNT_ID ?? (await ctx.env("ACCOUNT_ID")); + if (!credential?.key || !account) return undefined; + return { + auth: { apiKey: credential.key, baseUrl: `https://example.test/${account}` }, + env: { ACCOUNT_ID: account }, + }; + }, + }; + const models = createModels(); + models.setProvider(testProvider({ id: "p1", auth: { apiKey }, calls })); + const model = testModel("p1", "model-a"); + + await models.completeSimple(model, context, { apiKey: "explicit-key", env: { ACCOUNT_ID: "acct" } }); + + expect(calls[0].model.baseUrl).toBe("https://example.test/acct"); + expect(calls[0].options?.apiKey).toBe("explicit-key"); + expect(calls[0].options?.env).toEqual({ ACCOUNT_ID: "acct" }); + }); + it("merges resolved auth into stream options; explicit options win per field", async () => { const calls: ProviderCall[] = []; const apiKey: ApiKeyAuth = {