fix(ai): resolve request-scoped auth before provider calls
closes #6021
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AuthResult | undefined> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -231,7 +231,16 @@ class ModelsImpl implements MutableModels {
|
||||
model: Model<Api>,
|
||||
options: TOptions | undefined,
|
||||
): Promise<{ requestModel: Model<Api>; 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 };
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user