diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af03700..873463c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,17 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/sunrise-node' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -31,6 +36,7 @@ jobs: timeout-minutes: 5 name: build runs-on: ${{ github.repository == 'stainless-sdks/sunrise-node' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork permissions: contents: read id-token: write @@ -66,6 +72,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/sunrise-node' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e7ca613..64f3cdd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.0" + ".": "0.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3c2da..631ecf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.8.0 (2025-07-10) + +Full Changelog: [v0.7.0...v0.8.0](https://github.com/ContextualAI/contextual-client-node/compare/v0.7.0...v0.8.0) + +### Features + +* **client:** add support for endpoint-specific base URLs ([4375198](https://github.com/ContextualAI/contextual-client-node/commit/4375198ddd324eadac0053fbf0e9284f0686f6a5)) + + +### Bug Fixes + +* **ci:** release-doctor — report correct token name ([2339044](https://github.com/ContextualAI/contextual-client-node/commit/2339044a28b8491311eb984fbe0f5b2497e57b32)) +* **client:** don't send `Content-Type` for bodyless methods ([70fe78d](https://github.com/ContextualAI/contextual-client-node/commit/70fe78dada5b43beb40a5efb9d371cdcb2ac8ea0)) +* publish script — handle NPM errors correctly ([9855ea4](https://github.com/ContextualAI/contextual-client-node/commit/9855ea41c55355a17d1a6282bc8d158b72da2f63)) + + +### Chores + +* **ci:** enable for pull requests ([23d5fa7](https://github.com/ContextualAI/contextual-client-node/commit/23d5fa79636f991e3d4ff7a454ee973bb1fc2691)) +* **ci:** only run for pushes and fork pull requests ([da8f41a](https://github.com/ContextualAI/contextual-client-node/commit/da8f41a626313a61f8eec1ccb52834021ec5c134)) +* **docs:** grammar improvements ([92210b2](https://github.com/ContextualAI/contextual-client-node/commit/92210b2f9cbfd930dfb7fcc8c6464a8d0c89bda7)) +* **docs:** use top-level-await in example snippets ([39783f4](https://github.com/ContextualAI/contextual-client-node/commit/39783f455c8d9d669d42a0671b239b51a196f743)) +* improve publish-npm script --latest tag logic ([bf8c320](https://github.com/ContextualAI/contextual-client-node/commit/bf8c320845e3781558b5c89c2d0dfda127138d13)) +* **internal:** make base APIResource abstract ([122895d](https://github.com/ContextualAI/contextual-client-node/commit/122895d96bcc6de97dc918dca7b62d8ababb0af5)) +* make some internal functions async ([cfbf5bc](https://github.com/ContextualAI/contextual-client-node/commit/cfbf5bc81cb65347306a63f6901c0682f2a827cf)) +* mention unit type in timeout docs ([4271686](https://github.com/ContextualAI/contextual-client-node/commit/427168601652284f4f664680cd66302d4ac5485a)) + + +### Refactors + +* **types:** replace Record with mapped types ([b50e9f1](https://github.com/ContextualAI/contextual-client-node/commit/b50e9f1b6306889aff9ee34fb2d29dcad6d8c3f7)) + ## 0.7.0 (2025-05-13) Full Changelog: [v0.6.0...v0.7.0](https://github.com/ContextualAI/contextual-client-node/compare/v0.6.0...v0.7.0) diff --git a/README.md b/README.md index db698e4..4d38e40 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,9 @@ const client = new ContextualAI({ apiKey: process.env['CONTEXTUAL_API_KEY'], // This is the default and can be omitted }); -async function main() { - const createAgentOutput = await client.agents.create({ name: 'Example' }); +const createAgentOutput = await client.agents.create({ name: 'Example' }); - console.log(createAgentOutput.id); -} - -main(); +console.log(createAgentOutput.id); ``` ### Request & Response types @@ -45,12 +41,8 @@ const client = new ContextualAI({ apiKey: process.env['CONTEXTUAL_API_KEY'], // This is the default and can be omitted }); -async function main() { - const params: ContextualAI.AgentCreateParams = { name: 'Example' }; - const createAgentOutput: ContextualAI.CreateAgentOutput = await client.agents.create(params); -} - -main(); +const params: ContextualAI.AgentCreateParams = { name: 'Example' }; +const createAgentOutput: ContextualAI.CreateAgentOutput = await client.agents.create(params); ``` Documentation for each method, request param, and response field are available in docstrings and will appear on hover in most modern editors. @@ -103,19 +95,15 @@ a subclass of `APIError` will be thrown: ```ts -async function main() { - const createAgentOutput = await client.agents.create({ name: 'Example' }).catch(async (err) => { - if (err instanceof ContextualAI.APIError) { - console.log(err.status); // 400 - console.log(err.name); // BadRequestError - console.log(err.headers); // {server: 'nginx', ...} - } else { - throw err; - } - }); -} - -main(); +const createAgentOutput = await client.agents.create({ name: 'Example' }).catch(async (err) => { + if (err instanceof ContextualAI.APIError) { + console.log(err.status); // 400 + console.log(err.name); // BadRequestError + console.log(err.headers); // {server: 'nginx', ...} + } else { + throw err; + } +}); ``` Error codes are as follows: diff --git a/SECURITY.md b/SECURITY.md index 92a473f..97e18f0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,11 +16,11 @@ before making any information public. ## Reporting Non-SDK Related Security Issues If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Contextual AI please follow the respective company's security reporting guidelines. +or products provided by Contextual AI, please follow the respective company's security reporting guidelines. ### Contextual AI Terms and Policies -Please contact support@contextual.ai for any questions or concerns regarding security of our services. +Please contact support@contextual.ai for any questions or concerns regarding the security of our services. --- diff --git a/bin/check-release-environment b/bin/check-release-environment index c2bca0c..e4b6d58 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${NPM_TOKEN}" ]; then - errors+=("The CONTEXTUAL_AI_NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets") + errors+=("The NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets") fi lenErrors=${#errors[@]} diff --git a/bin/publish-npm b/bin/publish-npm index 4c21181..fa2243d 100644 --- a/bin/publish-npm +++ b/bin/publish-npm @@ -4,19 +4,55 @@ set -eux npm config set '//registry.npmjs.org/:_authToken' "$NPM_TOKEN" -# Build the project yarn build - -# Navigate to the dist directory cd dist -# Get the version from package.json -VERSION="$(node -p "require('./package.json').version")" +# Get package name and version from package.json +PACKAGE_NAME="$(jq -r -e '.name' ./package.json)" +VERSION="$(jq -r -e '.version' ./package.json)" + +# Get latest version from npm +# +# If the package doesn't exist, npm will return: +# { +# "error": { +# "code": "E404", +# "summary": "Unpublished on 2025-06-05T09:54:53.528Z", +# "detail": "'the_package' is not in this registry..." +# } +# } +NPM_INFO="$(npm view "$PACKAGE_NAME" version --json 2>/dev/null || true)" + +# Check if we got an E404 error +if echo "$NPM_INFO" | jq -e '.error.code == "E404"' > /dev/null 2>&1; then + # Package doesn't exist yet, no last version + LAST_VERSION="" +elif echo "$NPM_INFO" | jq -e '.error' > /dev/null 2>&1; then + # Report other errors + echo "ERROR: npm returned unexpected data:" + echo "$NPM_INFO" + exit 1 +else + # Success - get the version + LAST_VERSION=$(echo "$NPM_INFO" | jq -r '.') # strip quotes +fi -# Extract the pre-release tag if it exists +# Check if current version is pre-release (e.g. alpha / beta / rc) +CURRENT_IS_PRERELEASE=false if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then - # Extract the part before any dot in the pre-release identifier - TAG="${BASH_REMATCH[1]}" + CURRENT_IS_PRERELEASE=true + CURRENT_TAG="${BASH_REMATCH[1]}" +fi + +# Check if last version is a stable release +LAST_IS_STABLE_RELEASE=true +if [[ -z "$LAST_VERSION" || "$LAST_VERSION" =~ -([a-zA-Z]+) ]]; then + LAST_IS_STABLE_RELEASE=false +fi + +# Use a corresponding alpha/beta tag if there already is a stable release and we're publishing a prerelease. +if $CURRENT_IS_PRERELEASE && $LAST_IS_STABLE_RELEASE; then + TAG="$CURRENT_TAG" else TAG="latest" fi diff --git a/package.json b/package.json index 7837a69..ce28838 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextual-client", - "version": "0.7.0", + "version": "0.8.0", "description": "The official TypeScript library for the Contextual AI API", "author": "Contextual AI ", "types": "dist/index.d.ts", diff --git a/scripts/build b/scripts/build index d4a6875..dba69bb 100755 --- a/scripts/build +++ b/scripts/build @@ -28,7 +28,7 @@ fi node scripts/utils/make-dist-package-json.cjs > dist/package.json # build to .js/.mjs/.d.ts files -npm exec tsc-multi +./node_modules/.bin/tsc-multi # copy over handwritten .js/.mjs/.d.ts files cp src/_shims/*.{d.ts,js,mjs,md} dist/_shims cp src/_shims/auto/*.{d.ts,js,mjs} dist/_shims/auto diff --git a/src/core.ts b/src/core.ts index 57e9e88..0824fa9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -170,6 +170,7 @@ export class APIPromise extends Promise { export abstract class APIClient { baseURL: string; + #baseURLOverridden: boolean; maxRetries: number; timeout: number; httpAgent: Agent | undefined; @@ -179,18 +180,21 @@ export abstract class APIClient { constructor({ baseURL, + baseURLOverridden, maxRetries = 2, timeout = 60000, // 1 minute httpAgent, fetch: overriddenFetch, }: { baseURL: string; + baseURLOverridden: boolean; maxRetries?: number | undefined; timeout: number | undefined; httpAgent: Agent | undefined; fetch: Fetch | undefined; }) { this.baseURL = baseURL; + this.#baseURLOverridden = baseURLOverridden; this.maxRetries = validatePositiveInteger('maxRetries', maxRetries); this.timeout = validatePositiveInteger('timeout', timeout); this.httpAgent = httpAgent; @@ -213,7 +217,7 @@ export abstract class APIClient { protected defaultHeaders(opts: FinalRequestOptions): Headers { return { Accept: 'application/json', - 'Content-Type': 'application/json', + ...(['head', 'get'].includes(opts.method) ? {} : { 'Content-Type': 'application/json' }), 'User-Agent': this.getUserAgent(), ...getPlatformHeaders(), ...this.authHeaders(opts), @@ -295,12 +299,12 @@ export abstract class APIClient { return null; } - buildRequest( + async buildRequest( inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, - ): { req: RequestInit; url: string; timeout: number } { + ): Promise<{ req: RequestInit; url: string; timeout: number }> { const options = { ...inputOptions }; - const { method, path, query, headers: headers = {} } = options; + const { method, path, query, defaultBaseURL, headers: headers = {} } = options; const body = ArrayBuffer.isView(options.body) || (options.__binaryRequest && typeof options.body === 'string') ? @@ -310,7 +314,7 @@ export abstract class APIClient { : null; const contentLength = this.calculateContentLength(body); - const url = this.buildURL(path!, query); + const url = this.buildURL(path!, query, defaultBaseURL); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; const httpAgent = options.httpAgent ?? this.httpAgent ?? getDefaultAgent(url); @@ -446,7 +450,9 @@ export abstract class APIClient { await this.prepareOptions(options); - const { req, url, timeout } = this.buildRequest(options, { retryCount: maxRetries - retriesRemaining }); + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); await this.prepareRequest(req, { url, options }); @@ -503,11 +509,12 @@ export abstract class APIClient { return new PagePromise(this, request, Page); } - buildURL(path: string, query: Req | null | undefined): string { + buildURL(path: string, query: Req | null | undefined, defaultBaseURL?: string | undefined): string { + const baseURL = (!this.#baseURLOverridden && defaultBaseURL) || this.baseURL; const url = isAbsoluteURL(path) ? new URL(path) - : new URL(this.baseURL + (this.baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); + : new URL(baseURL + (baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); const defaultQuery = this.defaultQuery(); if (!isEmptyObj(defaultQuery)) { @@ -792,6 +799,7 @@ export type RequestOptions< query?: Req | undefined; body?: Req | null | undefined; headers?: Headers | undefined; + defaultBaseURL?: string | undefined; maxRetries?: number; stream?: boolean | undefined; @@ -813,6 +821,7 @@ const requestOptionsKeys: KeysEnum = { query: true, body: true, headers: true, + defaultBaseURL: true, maxRetries: true, stream: true, diff --git a/src/index.ts b/src/index.ts index f17be44..5392a01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,8 @@ export interface ClientOptions { * * Note that request timeouts are retried by default, so in a worst-case scenario you may wait * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds */ timeout?: number | undefined; @@ -178,6 +180,7 @@ export class ContextualAI extends Core.APIClient { super({ baseURL: options.baseURL!, + baseURLOverridden: baseURL ? baseURL !== 'https://api.contextual.ai/v1' : false, timeout: options.timeout ?? 60000 /* 1 minute */, httpAgent: options.httpAgent, maxRetries: options.maxRetries, @@ -197,6 +200,13 @@ export class ContextualAI extends Core.APIClient { generate: API.Generate = new API.Generate(this); parse: API.Parse = new API.Parse(this); + /** + * Check whether the base URL is set to its default. + */ + #baseURLOverridden(): boolean { + return this.baseURL !== 'https://api.contextual.ai/v1'; + } + protected override defaultQuery(): Core.DefaultQuery | undefined { return this._options.defaultQuery; } diff --git a/src/resource.ts b/src/resource.ts index 7f9b100..adf0750 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -2,7 +2,7 @@ import type { ContextualAI } from './index'; -export class APIResource { +export abstract class APIResource { protected _client: ContextualAI; constructor(client: ContextualAI) { diff --git a/src/resources/datastores/documents.ts b/src/resources/datastores/documents.ts index f1c7b2a..b99fc48 100644 --- a/src/resources/datastores/documents.ts +++ b/src/resources/datastores/documents.ts @@ -181,7 +181,7 @@ export interface DocumentMetadata { */ status: 'pending' | 'processing' | 'retrying' | 'completed' | 'failed' | 'cancelled'; - custom_metadata?: Record; + custom_metadata?: { [key: string]: boolean | number | string }; /** * Timestamp of when the document was modified in ISO format. @@ -282,7 +282,7 @@ export interface DocumentIngestParams { } export interface DocumentSetMetadataParams { - custom_metadata?: Record; + custom_metadata?: { [key: string]: boolean | number | string }; } Documents.DocumentMetadataDocumentsPage = DocumentMetadataDocumentsPage; diff --git a/src/resources/users.ts b/src/resources/users.ts index 8339bf6..3edbb31 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -59,7 +59,7 @@ export interface InviteUsersResponse { * Details of the errors occurred while inviting users, where keys are the emails * and values are the error messages */ - error_details: Record; + error_details: { [key: string]: string }; /** * List of emails of the invited users diff --git a/src/version.ts b/src/version.ts index d9da9f7..23f967c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.7.0'; // x-release-please-version +export const VERSION = '0.8.0'; // x-release-please-version diff --git a/tests/index.test.ts b/tests/index.test.ts index 90855ce..1c96dbd 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -26,13 +26,13 @@ describe('instantiate client', () => { apiKey: 'My API Key', }); - test('they are used in the request', () => { - const { req } = client.buildRequest({ path: '/foo', method: 'post' }); + test('they are used in the request', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post' }); expect((req.headers as Headers)['x-my-default-header']).toEqual('2'); }); - test('can ignore `undefined` and leave the default', () => { - const { req } = client.buildRequest({ + test('can ignore `undefined` and leave the default', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': undefined }, @@ -40,8 +40,8 @@ describe('instantiate client', () => { expect((req.headers as Headers)['x-my-default-header']).toEqual('2'); }); - test('can be removed with `null`', () => { - const { req } = client.buildRequest({ + test('can be removed with `null`', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', headers: { 'X-My-Default-Header': null }, @@ -188,6 +188,28 @@ describe('instantiate client', () => { const client = new ContextualAI({ apiKey: 'My API Key' }); expect(client.baseURL).toEqual('https://api.contextual.ai/v1'); }); + + test('in request options', () => { + const client = new ContextualAI({ apiKey: 'My API Key' }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/option/foo', + ); + }); + + test('in request options overridden by client options', () => { + const client = new ContextualAI({ apiKey: 'My API Key', baseURL: 'http://localhost:5000/client' }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/client/foo', + ); + }); + + test('in request options overridden by env variable', () => { + process.env['CONTEXTUAL_AI_BASE_URL'] = 'http://localhost:5000/env'; + const client = new ContextualAI({ apiKey: 'My API Key' }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/env/foo', + ); + }); }); test('maxRetries option is correctly set', () => { @@ -218,20 +240,20 @@ describe('request building', () => { const client = new ContextualAI({ apiKey: 'My API Key' }); describe('Content-Length', () => { - test('handles multi-byte characters', () => { - const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: '—' } }); + test('handles multi-byte characters', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: '—' } }); expect((req.headers as Record)['content-length']).toEqual('20'); }); - test('handles standard characters', () => { - const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' } }); + test('handles standard characters', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' } }); expect((req.headers as Record)['content-length']).toEqual('22'); }); }); describe('custom headers', () => { - test('handles undefined', () => { - const { req } = client.buildRequest({ + test('handles undefined', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' },