feat(ai): pass provider-resolved env to APIs
This commit is contained in:
@@ -17,9 +17,11 @@
|
|||||||
- `fauxProvider()` returns a faux `Provider` for tests built on explicit `Models` collections.
|
- `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`.
|
- 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)).
|
- 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
|
||||||
|
|
||||||
|
- 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 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)).
|
- 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)).
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
import type { OAuthCredentials } from "../utils/oauth/types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +78,8 @@ export interface AuthContext {
|
|||||||
/** Result of resolving auth for a model. */
|
/** Result of resolving auth for a model. */
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
auth: ModelAuth;
|
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". */
|
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
|
||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,11 +192,13 @@ class ImagesModelsImpl implements MutableImagesModels {
|
|||||||
|
|
||||||
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
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 apiKey = options?.apiKey ?? auth.apiKey;
|
||||||
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
|
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) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
api: model.api,
|
api: model.api,
|
||||||
|
|||||||
@@ -236,10 +236,11 @@ class ModelsImpl implements MutableModels {
|
|||||||
|
|
||||||
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
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 apiKey = options?.apiKey ?? auth.apiKey;
|
||||||
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
|
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 };
|
return { requestModel, requestOptions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ export interface ProviderImages {
|
|||||||
export interface ImagesOptions {
|
export interface ImagesOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
apiKey?: string;
|
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.
|
* Optional callback for inspecting or replacing provider payloads before sending.
|
||||||
* Return undefined to keep the payload unchanged.
|
* Return undefined to keep the payload unchanged.
|
||||||
|
|||||||
@@ -102,6 +102,45 @@ describe("ImagesModels", () => {
|
|||||||
expect(calls[1].options?.apiKey).toBe("explicit");
|
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 () => {
|
it("returns an error result for unknown providers and unconfigured auth rejections", async () => {
|
||||||
const models = createImagesModels({ authContext: fakeAuthContext({}) });
|
const models = createImagesModels({ authContext: fakeAuthContext({}) });
|
||||||
const ghost = await models.generateImages(testImageModel("ghost", "m"), context);
|
const ghost = await models.generateImages(testImageModel("ghost", "m"), context);
|
||||||
|
|||||||
@@ -168,6 +168,47 @@ describe("createProvider", () => {
|
|||||||
expect(calls).toEqual(["a:model-a", "b:model-b"]);
|
expect(calls).toEqual(["a:model-a", "b:model-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges provider-resolved env into stream options", async () => {
|
||||||
|
let capturedEnv: Record<string, string> | 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 () => {
|
it("produces a stream error for a model whose api has no implementation", async () => {
|
||||||
const provider = createProvider({
|
const provider = createProvider({
|
||||||
id: "mixed",
|
id: "mixed",
|
||||||
|
|||||||
Reference in New Issue
Block a user