diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a43f32f..58a32a70 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,9 +17,11 @@ - `fauxProvider()` returns a faux `Provider` for tests built on explicit `Models` collections. - Image generation mirrors the chat-side design: `createImagesModels()`/`ImagesProvider`/`createImagesProvider()` with sync model reads, explicit `refresh()`, provider-resolved auth, and never-rejecting `generateImages()`; `openrouterImagesProvider()` factory plus `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`. The `ImagesProvider` id type alias is renamed to `ImagesProviderId`; the old global image API stays on `/compat`. - When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). +- Provider auth results can carry provider-scoped environment values that `Models` and `ImagesModels` merge into API implementation options. ### Fixed +- Fixed Amazon Bedrock endpoint resolution to honor scoped `AWS_PROFILE` values. - Fixed OpenCode Go GLM-5.2 metadata to expose `xhigh` reasoning and send `reasoning_effort: "max"` ([#5967](https://github.com/earendil-works/pi/issues/5967)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 74bb45bf..6cb285cf 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.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 { OAuthCredentials } from "../utils/oauth/types.ts"; /** @@ -78,6 +78,8 @@ export interface AuthContext { /** Result of resolving auth for a model. */ export interface AuthResult { auth: ModelAuth; + /** Provider-scoped environment/config values resolved from credentials and ambient context. */ + env?: ProviderEnv; /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ source?: string; } diff --git a/packages/ai/src/images-models.ts b/packages/ai/src/images-models.ts index 67449b03..8dd655d4 100644 --- a/packages/ai/src/images-models.ts +++ b/packages/ai/src/images-models.ts @@ -192,11 +192,13 @@ class ImagesModelsImpl implements MutableImagesModels { const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; - // Explicit request options win per-field; headers merge per header. + // Explicit request options win per-field; headers/env merge per key. const apiKey = options?.apiKey ?? auth.apiKey; const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined; + const env = + resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; - return await provider.generateImages(requestModel, context, { ...options, apiKey, headers }); + return await provider.generateImages(requestModel, context, { ...options, apiKey, headers, env }); } catch (error) { return { api: model.api, diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 47ed7c8a..de17f8ec 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -236,10 +236,11 @@ class ModelsImpl implements MutableModels { const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; - // Explicit request options win per-field; headers merge per header. + // Explicit request options win per-field; headers/env merge per key. const apiKey = options?.apiKey ?? auth.apiKey; const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined; - const requestOptions = { ...options, apiKey, headers } as TOptions; + const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; + const requestOptions = { ...options, apiKey, headers, env } as TOptions; return { requestModel, requestOptions }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 48a6961e..35b79b7c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -239,6 +239,11 @@ export interface ProviderImages { export interface ImagesOptions { signal?: AbortSignal; apiKey?: string; + /** + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as endpoint placeholders and proxy variables. + */ + env?: ProviderEnv; /** * Optional callback for inspecting or replacing provider payloads before sending. * Return undefined to keep the payload unchanged. diff --git a/packages/ai/test/images-models.test.ts b/packages/ai/test/images-models.test.ts index 1b2e08da..0a2a9e56 100644 --- a/packages/ai/test/images-models.test.ts +++ b/packages/ai/test/images-models.test.ts @@ -102,6 +102,45 @@ describe("ImagesModels", () => { expect(calls[1].options?.apiKey).toBe("explicit"); }); + it("merges provider-resolved env into image options", async () => { + const calls: GenerateCall[] = []; + const models = createImagesModels(); + models.setProvider( + createImagesProvider({ + id: "p1", + auth: { + apiKey: { + name: "Test key", + resolve: async () => ({ + auth: { apiKey: "provider-key" }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }), + }, + }, + models: [testImageModel("p1", "model-a")], + api: { + generateImages: async (model, _context, options) => { + calls.push({ model, options }); + return okResult(model); + }, + }, + }), + ); + const model = models.getModel("p1", "model-a")!; + + await models.generateImages(model, context, { + apiKey: "request-key", + env: { REQUEST_ONLY: "request", SHARED: "request" }, + }); + + expect(calls[0].options?.apiKey).toBe("request-key"); + expect(calls[0].options?.env).toEqual({ + PROVIDER_ONLY: "provider", + REQUEST_ONLY: "request", + SHARED: "request", + }); + }); + it("returns an error result for unknown providers and unconfigured auth rejections", async () => { const models = createImagesModels({ authContext: fakeAuthContext({}) }); const ghost = await models.generateImages(testImageModel("ghost", "m"), context); diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index 3e6320a9..56a72b68 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -168,6 +168,47 @@ describe("createProvider", () => { expect(calls).toEqual(["a:model-a", "b:model-b"]); }); + it("merges provider-resolved env into stream options", async () => { + let capturedEnv: Record | undefined; + let capturedApiKey: string | undefined; + const envModel = { ...testModel("api-a", "model-a"), provider: "env-provider" }; + const provider = createProvider({ + id: "env-provider", + auth: { + apiKey: { + name: "Test", + resolve: async () => ({ + auth: { apiKey: "provider-key" }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }), + }, + }, + models: [envModel], + api: { + stream: (model, _context, options) => { + capturedEnv = options?.env; + capturedApiKey = options?.apiKey; + return recordingStreams("a", []).stream(model, _context, options); + }, + streamSimple: (model, _context, options) => { + capturedEnv = options?.env; + capturedApiKey = options?.apiKey; + return recordingStreams("a", []).streamSimple(model, _context, options); + }, + }, + }); + const models = createModels(); + models.setProvider(provider); + + await models.completeSimple(envModel, context, { + apiKey: "request-key", + env: { REQUEST_ONLY: "request", SHARED: "request" }, + }); + + expect(capturedApiKey).toBe("request-key"); + expect(capturedEnv).toEqual({ PROVIDER_ONLY: "provider", REQUEST_ONLY: "request", SHARED: "request" }); + }); + it("produces a stream error for a model whose api has no implementation", async () => { const provider = createProvider({ id: "mixed",