diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 996a52c13d..6d91b4fee3 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,6 +1,6 @@ schema_version: 1 -generation_id: 9d640a0f-6abd-44f8-aaa0-df24b003656d +generation_id: 2e672b14-22c2-4e3e-b20d-2cdd0d719583 openapi_spec_hash: dd725fb7d43ceec7fb2de6f8713d14b6 openapi_transformed_spec_hash: 10930179c5f116288e24e0c6fda46559 -config_hash: 28f37c225c55c75547d4feb84ee71044 -codegen_sha: cfe310550a1bec7192f8673528a957c35703f034 +config_hash: 85382dd94c503b5d225adc7636a77c9f +codegen_sha: 2ee3a8abe63e13ed0fc033198d9b99b51d168ad3 diff --git a/src/azure.ts b/src/azure.ts index 501d4a840e..8731d36056 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -7,12 +7,16 @@ import { isObj, readEnv } from './internal/utils'; import { path } from './internal/utils/path'; import { OpenAI } from './client'; import type { ClientOptions } from './client'; +import { assertNoDataResidency } from './internal/data-residency'; /** API Client for interfacing with the Azure OpenAI API. */ -export interface AzureClientOptions extends Omit { +export interface AzureClientOptions extends Omit { /** AzureOpenAI does not support third-party provider configuration. */ provider?: never; + /** OpenAI data residency cannot be combined with Azure routing. */ + dataResidency?: never; + /** * Defaults to process.env['OPENAI_API_VERSION']. */ @@ -74,8 +78,10 @@ export class AzureOpenAI extends OpenAI { deployment, azureADTokenProvider, dangerouslyAllowBrowser, + dataResidency, ...opts }: AzureClientOptions = {}) { + assertNoDataResidency(dataResidency, 'AzureOpenAI'); if (!apiVersion) { throw new Errors.OpenAIError( "The OPENAI_API_VERSION environment variable is missing or empty; either provide it, or instantiate the AzureOpenAI client with an apiVersion option, like new AzureOpenAI({ apiVersion: 'My API Version' }).", @@ -131,6 +137,11 @@ export class AzureOpenAI extends OpenAI { this.deploymentName = deployment; } + /** Clones this client with Azure options; OpenAI data residency remains unsupported. */ + override withOptions(options: Partial): this { + return super.withOptions(options); + } + /** Builds an Azure request and inserts its deployment into model-scoped endpoint paths. */ override async buildRequest( options: FinalRequestOptions, diff --git a/src/bedrock.ts b/src/bedrock.ts index 1bc81f8fc8..6a938eef6e 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -1,6 +1,7 @@ import * as Errors from './error'; import { OpenAI } from './client'; import type { ApiKeySetter, ClientOptions } from './client'; +import { assertNoDataResidency } from './internal/data-residency'; import { assertBedrockRequestOrigin, brand_privateBedrockClient } from './internal/bedrock'; import type { RequestInit } from './internal/builtin-types'; import type { NullableHeaders } from './internal/headers'; @@ -15,7 +16,7 @@ import type * as ResponsesAPI from './resources/responses/responses'; /** Configures Amazon Bedrock's OpenAI-compatible endpoint and bearer-token authentication. */ export interface BedrockClientOptions extends Omit< ClientOptions, - 'apiKey' | 'adminAPIKey' | 'baseURL' | 'workloadIdentity' + 'apiKey' | 'adminAPIKey' | 'baseURL' | 'workloadIdentity' | 'dataResidency' > { /** * Bedrock bearer token used for authentication. @@ -38,6 +39,9 @@ export interface BedrockClientOptions extends Omit< */ adminAPIKey?: never; + /** OpenAI data residency cannot be combined with Bedrock routing. */ + dataResidency?: never; + /** * BedrockOpenAI only supports Bedrock bearer token authentication. */ @@ -129,8 +133,10 @@ export class BedrockOpenAI extends OpenAI { bedrockTokenProvider, adminAPIKey, workloadIdentity, + dataResidency, ...opts }: BedrockClientOptions = {}) { + assertNoDataResidency(dataResidency, 'BedrockOpenAI'); if (adminAPIKey || workloadIdentity) { throw new Errors.OpenAIError('BedrockOpenAI only supports Bedrock bearer token authentication.'); } diff --git a/src/client.ts b/src/client.ts index 7aefcb6c66..41c036f411 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,7 +3,7 @@ import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types'; import type { HTTPMethod, PromiseOrValue, MergedRequestInit, FinalizedRequestInit } from './internal/types'; import { uuid4 } from './internal/utils/uuid'; -import { validatePositiveInteger, isAbsoluteURL, safeJSON } from './internal/utils/values'; +import { validatePositiveInteger, isAbsoluteURL, safeJSON, hasOwn } from './internal/utils/values'; import { sleep } from './internal/utils/sleep'; export type { Logger, LogLevel } from './internal/utils/log'; import { castToError, isAbortError } from './internal/errors'; @@ -13,6 +13,8 @@ import * as Shims from './internal/shims'; import * as Opts from './internal/request-options'; import { stringifyQuery } from './internal/utils/query'; import { VERSION } from './version'; +import { resolveDataResidency, type DataResidency } from './internal/data-residency'; +export type { DataResidency } from './internal/data-residency'; import * as Errors from './core/error'; import * as Pagination from './core/pagination'; import type { WorkloadIdentity } from './auth/types'; @@ -269,6 +271,8 @@ function isRunningInBrowserOrBrowserWorker(): boolean { } const WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER = 'workload-identity-auth'; +const inheritedDataResidencySelection = Symbol('inheritedDataResidencySelection'); +type InternalClientOptions = ClientOptions & { [inheritedDataResidencySelection]?: boolean }; export type ApiKeySetter = () => Promise; @@ -314,6 +318,14 @@ export interface ClientOptions { */ baseURL?: string | null | undefined; + /** + * Select an OpenAI regional endpoint. This overrides an inherited or environment + * base URL and is mutually exclusive with an explicit `baseURL` or `provider`. + * Availability depends on your project and model; no fallback is performed. + * `null` and `undefined` leave ordinary base URL resolution unchanged. + */ + dataResidency?: DataResidency | null | undefined; + /** * The maximum amount of time (in milliseconds) that the client should wait for a response * from the server before timing out a single request. @@ -431,6 +443,8 @@ export class OpenAI { private fetch: Fetch; #encoder: Opts.RequestEncoder; + // Preserve an explicit global selection without storing a second routing URL. + #explicitDataResidency = false; #responseAttempts = new WeakMap(); protected idempotencyHeader?: string; protected _options: ClientOptions; @@ -456,11 +470,12 @@ export class OpenAI { * @param {boolean} [opts.dangerouslyAllowBrowser=false] - By default, client-side use of this library is not allowed, as it risks exposing your secret API credentials to attackers. */ constructor(clientOptions: ClientOptions = {}) { + const residencyBaseURL = resolveDataResidency(clientOptions); const provider = clientOptions.provider; if (provider) { - const conflictingOptions = (['apiKey', 'adminAPIKey', 'workloadIdentity', 'baseURL'] as const).filter( - (key) => clientOptions[key] != null, - ); + const conflictingOptions = ( + ['apiKey', 'adminAPIKey', 'workloadIdentity', 'baseURL', 'dataResidency'] as const + ).filter((key) => clientOptions[key] != null); if (conflictingOptions.length) { throw new Errors.OpenAIError( `The \`provider\` option cannot be used with ${conflictingOptions @@ -472,6 +487,8 @@ export class OpenAI { const { baseURL = provider ? null : readEnv('OPENAI_BASE_URL'), + dataResidency: _dataResidency, + [inheritedDataResidencySelection]: inheritedResidencySelection = false, apiKey = provider ? null : (readEnv('OPENAI_API_KEY') ?? null), adminAPIKey = provider ? null : (readEnv('OPENAI_ADMIN_KEY') ?? null), organization = provider ? null : (readEnv('OPENAI_ORG_ID') ?? null), @@ -479,7 +496,7 @@ export class OpenAI { webhookSecret = readEnv('OPENAI_WEBHOOK_SECRET') ?? null, workloadIdentity, ...opts - } = clientOptions; + } = clientOptions as InternalClientOptions; const providerRuntime = provider ? configureProvider(provider) : undefined; const options: ClientOptions = { apiKey, @@ -490,7 +507,7 @@ export class OpenAI { workloadIdentity, provider, ...opts, - baseURL: providerRuntime?.baseURL ?? (baseURL || `https://api.openai.com/v1`), + baseURL: providerRuntime?.baseURL ?? residencyBaseURL ?? (baseURL || `https://api.openai.com/v1`), }; if (apiKey && workloadIdentity) { @@ -510,6 +527,7 @@ export class OpenAI { } this.baseURL = options.baseURL!; + this.#explicitDataResidency = residencyBaseURL !== undefined || inheritedResidencySelection; this.timeout = options.timeout ?? OpenAI.DEFAULT_TIMEOUT; /* 10 minutes */ this.logger = options.logger ?? console; const defaultLogLevel = 'warn'; @@ -554,6 +572,7 @@ export class OpenAI { * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { + const residencyBaseURL = resolveDataResidency(options); const inheritedProvider = this._options.provider; const provider = options.provider ?? inheritedProvider; const inheritedOptions: ClientOptions = { @@ -572,6 +591,9 @@ export class OpenAI { project: this.project, webhookSecret: this.webhookSecret, }; + if (residencyBaseURL !== undefined) { + delete inheritedOptions.baseURL; + } if (provider) { delete inheritedOptions.apiKey; delete inheritedOptions.adminAPIKey; @@ -584,19 +606,28 @@ export class OpenAI { } } - const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + const clientOptions: InternalClientOptions = { ...inheritedOptions, ...options, provider, - }); - return client; + [inheritedDataResidencySelection]: + this.#explicitDataResidency && + residencyBaseURL === undefined && + !hasOwn(options, 'baseURL') && + !provider, + }; + return new (this.constructor as any as new (props: ClientOptions) => typeof this)(clientOptions); } /** * Check whether the base URL is set to its default. */ #baseURLOverridden(): boolean { - return this._provider !== undefined || this.baseURL !== 'https://api.openai.com/v1'; + return ( + this.#explicitDataResidency || + this._provider !== undefined || + this.baseURL !== 'https://api.openai.com/v1' + ); } protected defaultQuery(): Record | undefined { @@ -1619,6 +1650,7 @@ function isUndiciDispatcherVersionMismatchError(error: unknown): boolean { } export declare namespace OpenAI { + export { type DataResidency as DataResidency }; export type RequestOptions = Opts.RequestOptions; export import Page = Pagination.Page; diff --git a/src/index.ts b/src/index.ts index f9bb75b6a6..ef14d0dc44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { OpenAI as default } from './client'; export { type Uploadable, toFile, toStreamingFile } from './core/uploads'; export { APIPromise } from './core/api-promise'; -export { OpenAI, type ClientOptions } from './client'; +export { OpenAI, type ClientOptions, type DataResidency } from './client'; export { PagePromise } from './core/pagination'; export { OpenAIError, diff --git a/src/internal/data-residency.ts b/src/internal/data-residency.ts new file mode 100644 index 0000000000..d195494e4e --- /dev/null +++ b/src/internal/data-residency.ts @@ -0,0 +1,44 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +import { OpenAIError } from '../core/error'; +import { hasOwn } from './utils/values'; + +/** OpenAI endpoint regions selectable without specifying a full base URL. */ +export type DataResidency = 'global' | 'us' | 'eu' | 'ae'; + +const endpoints = new Map([ + ['global', 'https://api.openai.com/v1'], + ['us', 'https://us.api.openai.com/v1'], + ['eu', 'https://eu.api.openai.com/v1'], + ['ae', 'https://ae.api.openai.com/v1'], +]); + +/** Resolves an explicit residency selection before client options are inherited. */ +export function resolveDataResidency(options: { + /** Residency shorthand; null and undefined preserve ordinary URL resolution. */ + dataResidency?: DataResidency | null | undefined; + /** Explicit API root, mutually exclusive with a residency selection. */ + baseURL?: string | null | undefined; +}): string | undefined { + if (options.dataResidency === null || options.dataResidency === undefined) { + return undefined; + } + if (hasOwn(options, 'baseURL')) { + throw new OpenAIError('The `dataResidency` and `baseURL` options are mutually exclusive.'); + } + const endpoint = endpoints.get(options.dataResidency); + if (endpoint === undefined) { + throw new OpenAIError('Invalid `dataResidency`; expected one of: global, us, eu, ae.'); + } + return endpoint; +} + +/** Prevents legacy provider clients from selecting an OpenAI endpoint. */ +export function assertNoDataResidency( + dataResidency: DataResidency | null | undefined, + clientName: string, +): void { + if (dataResidency !== null && dataResidency !== undefined) { + throw new OpenAIError(`${clientName} does not support \`dataResidency\`.`); + } +} diff --git a/tests/lib/data-residency.test.ts b/tests/lib/data-residency.test.ts new file mode 100644 index 0000000000..a9263ff6b4 --- /dev/null +++ b/tests/lib/data-residency.test.ts @@ -0,0 +1,193 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +import { vi } from 'vitest'; +import OpenAI, { AzureOpenAI, BedrockOpenAI } from 'openai'; +import type { ClientOptions, DataResidency } from 'openai'; +import { createProvider } from 'openai/internal/provider'; + +const endpoints: [DataResidency, string][] = [ + ['global', 'https://api.openai.com/v1'], + ['us', 'https://us.api.openai.com/v1'], + ['eu', 'https://eu.api.openai.com/v1'], + ['ae', 'https://ae.api.openai.com/v1'], +]; +const endpoint = (region: DataResidency): string => new Map(endpoints).get(region)!; + +beforeEach(() => { + for (const name of ['OPENAI_API_KEY', 'OPENAI_ADMIN_KEY', 'OPENAI_BASE_URL', 'OPENAI_CUSTOM_HEADERS']) { + // Explicit undefined tells Vitest to remove the environment variable. + // oxlint-disable-next-line unicorn/no-useless-undefined + vi.stubEnv(name, undefined); + } +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe('data residency', () => { + test.each(endpoints)('routes %s through the existing base URL', async (dataResidency, endpoint) => { + const fetch = vi.fn(async () => Response.json({ id: 'resp_test', output: [] })); + const client = new OpenAI({ apiKey: 'test-key', fetch, dataResidency }); + await client.responses.create({ model: 'test-model', input: 'Hello' }); + + expect(client.baseURL).toBe(endpoint); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0] ?? []; + expect(url).toBe(`${endpoint}/responses`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer test-key'); + expect(new Headers(init?.headers).has('data-residency')).toBe(false); + expect(JSON.parse(String(init?.body))).toEqual({ model: 'test-model', input: 'Hello' }); + }); + + test('copies, replaces and clears routing without sticky residency state', () => { + const original = new OpenAI({ apiKey: 'test-key', baseURL: 'https://custom.example/v1' }); + const eu = original.withOptions({ dataResidency: 'eu' }); + const us = eu.withOptions({ dataResidency: 'us' }); + const custom = us.withOptions({ baseURL: 'https://another.example/v1' }); + expect(original.baseURL).toBe('https://custom.example/v1'); + expect(eu.baseURL).toBe(endpoint('eu')); + expect(eu.responses).not.toBe(original.responses); + expect(eu.withOptions({}).baseURL).toBe(eu.baseURL); + expect(eu.withOptions({ dataResidency: null }).baseURL).toBe(eu.baseURL); + expect(eu.withOptions({ dataResidency: undefined }).baseURL).toBe(eu.baseURL); + expect(us.baseURL).toBe(endpoint('us')); + expect(custom.baseURL).toBe('https://another.example/v1'); + expect(custom.withOptions({ dataResidency: 'global' }).baseURL).toBe(endpoint('global')); + }); + + test('overrides environment routing but treats null residency as omitted', () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://environment.example/v1'); + expect(new OpenAI({ apiKey: 'test-key', dataResidency: 'eu' }).baseURL).toBe(endpoint('eu')); + expect(new OpenAI({ apiKey: 'test-key', dataResidency: 'global' }).baseURL).toBe(endpoint('global')); + expect(new OpenAI({ apiKey: 'test-key', dataResidency: null }).baseURL).toBe( + 'https://environment.example/v1', + ); + }); + + test('keeps an explicit global selection ahead of per-resource defaults across copies', async () => { + const alternate = 'https://alternate.example/v1'; + const fetch = vi.fn(async () => Response.json({})); + const implicit = new OpenAI({ apiKey: 'test-key', fetch }); + const global = implicit.withOptions({ dataResidency: 'global' }); + const copied = global.withOptions({ timeout: 1000 }).withOptions({ dataResidency: null }); + expect(implicit.buildURL('/models', null, alternate)).toBe(`${alternate}/models`); + expect( + new OpenAI({ apiKey: 'test-key', dataResidency: 'global' }).buildURL('/models', null, alternate), + ).toBe(`${endpoint('global')}/models`); + expect(copied.buildURL('/models', null, alternate)).toBe(`${endpoint('global')}/models`); + await copied.request({ method: 'get', path: '/models', defaultBaseURL: alternate }); + expect(fetch).toHaveBeenCalledWith(`${endpoint('global')}/models`, expect.anything()); + expect(global.withOptions({ baseURL: null }).buildURL('/models', null, alternate)).toBe( + `${alternate}/models`, + ); + expect( + global.withOptions({ baseURL: 'https://custom.example/v1' }).buildURL('/models', null, alternate), + ).toBe('https://custom.example/v1/models'); + }); + + test('initializes copied residency before subclass fields and constructors use routing', () => { + const alternate = 'https://alternate.example/v1'; + class RoutedClient extends OpenAI { + readonly initializedURL = this.buildURL('/models', null, alternate); + readonly constructedURL: string; + + constructor(options: ClientOptions) { + // Common subclass forwarding must preserve the internal selection marker. + super({ ...options }); + this.constructedURL = this.buildURL('/responses', null, alternate); + } + } + + const implicit = new RoutedClient({ apiKey: 'test-key' }); + const global = implicit.withOptions({ dataResidency: 'global' }); + const copied = global.withOptions({ timeout: 1000 }).withOptions({ dataResidency: null }); + expect(implicit.withOptions({}).initializedURL).toBe(`${alternate}/models`); + expect(global.initializedURL).toBe(`${endpoint('global')}/models`); + expect(copied.initializedURL).toBe(`${endpoint('global')}/models`); + expect(copied.constructedURL).toBe(`${endpoint('global')}/responses`); + expect(global.withOptions({ baseURL: null }).constructedURL).toBe(`${alternate}/responses`); + }); + + test.each([ + { dataResidency: 'eu', baseURL: 'https://custom.example/v1' }, + { baseURL: 'https://custom.example/v1', dataResidency: 'eu' }, + { dataResidency: 'eu', baseURL: '' }, + { dataResidency: 'eu', baseURL: null }, + { baseURL: null, dataResidency: 'eu' }, + { dataResidency: 'eu', baseURL: undefined }, + { baseURL: undefined, dataResidency: 'eu' }, + ] satisfies ClientOptions[])('rejects conflicting options: %j', (options) => { + const client = new OpenAI({ apiKey: 'test-key' }); + expect(() => new OpenAI({ apiKey: 'test-key', ...options })).toThrow('mutually exclusive'); + expect(() => client.withOptions(options)).toThrow('mutually exclusive'); + }); + + test.each(['', 'EU', 'apac', '__proto__', 'constructor', 1, {}])( + 'rejects an invalid runtime value: %j', + (value) => { + const options = { dataResidency: value as DataResidency }; + expect(() => new OpenAI({ apiKey: 'test-key', ...options })).toThrow('Invalid `dataResidency`'); + expect(() => new OpenAI({ apiKey: 'test-key' }).withOptions(options)).toThrow( + 'Invalid `dataResidency`', + ); + }, + ); + + test('rejects active providers without configuring or sending credentials', () => { + const configure = vi.fn(() => ({ name: 'test', baseURL: 'https://provider.example/v1' })); + const provider = createProvider({ configure }); + const fetch = vi.fn(async () => Response.json({})); + expect(() => new OpenAI({ provider, dataResidency: 'eu', fetch })).toThrow('`dataResidency`'); + expect(configure).not.toHaveBeenCalled(); + const client = new OpenAI({ provider, fetch }); + expect(() => client.withOptions({ dataResidency: 'eu' })).toThrow('`dataResidency`'); + expect(fetch).not.toHaveBeenCalled(); + const regional = new OpenAI({ apiKey: 'test-key', dataResidency: 'eu' }); + expect(regional.withOptions({ provider }).baseURL).toBe('https://provider.example/v1'); + expect(client.withOptions({ dataResidency: null }).baseURL).toBe(client.baseURL); + }); + + test('legacy provider clients cannot route provider credentials to OpenAI', () => { + const azure = new AzureOpenAI({ + apiKey: 'azure-key', + apiVersion: 'test-version', + endpoint: 'https://test.openai.azure.com', + }); + const bedrock = new BedrockOpenAI({ apiKey: 'bedrock-key', awsRegion: 'us-east-1' }); + // @ts-expect-error OpenAI residency is not a valid Azure clone option. + expect(() => azure.withOptions({ dataResidency: 'eu' })).toThrow('does not support `dataResidency`'); + // Exercise JavaScript callers that do not receive the provider-specific type error. + expect(() => (bedrock as OpenAI).withOptions({ dataResidency: 'eu' })).toThrow( + 'does not support `dataResidency`', + ); + // @ts-expect-error OpenAI residency is not a valid Azure constructor option. + expect(() => new AzureOpenAI({ dataResidency: 'eu' })).toThrow('does not support `dataResidency`'); + // @ts-expect-error OpenAI residency is not a valid Bedrock constructor option. + expect(() => new BedrockOpenAI({ dataResidency: 'eu' })).toThrow('does not support `dataResidency`'); + }); + + test('streaming uses the derived endpoint', async () => { + const fetch = vi.fn( + async () => + new Response('data: [DONE]\n\n', { + headers: { 'content-type': 'text/event-stream' }, + }), + ); + const client = new OpenAI({ apiKey: 'test-key', fetch }); + const stream = await client.withOptions({ dataResidency: 'eu' }).responses.create({ + model: 'test-model', + input: 'Hello', + stream: true, + }); + for await (const _event of stream) { + /* The fixture completes without events. */ + } + expect(fetch).toHaveBeenCalledWith(`${endpoint('eu')}/responses`, expect.anything()); + expect(client.baseURL).toBe(endpoint('global')); + }); + + test('exposes the type from both public entrypoints', () => { + const named: DataResidency = 'eu'; + const namespaced: OpenAI.DataResidency = named; + expect(namespaced).toBe('eu'); + }); +});