From 15fd4e7a3043c9e2c90b7ac2b544d268af137343 Mon Sep 17 00:00:00 2001 From: Kim Burgaard Date: Sun, 4 Oct 2026 21:42:50 -0700 Subject: [PATCH] Fixed version guard issues --- CHANGELOG.md | 18 ++ README.md | 70 ++++--- src/client.ts | 423 ++++++++++++++++++++++++++---------------- src/types.ts | 45 +++-- tests/client.test.ts | 427 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 768 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f28701..bf553ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.6.1] - 2026-10-05 + +### Changed + +- Throw `SeclaiError` from the list methods of version-gated endpoints when a successful response is not a list — an error-shaped object, text, or an empty body. Most of them handed that body back as if it were the list; the cloud-drive listings returned `[]`, which reads as "no results". An explicit `data: null` is still an empty list + +### Fixed + +- Return the declared type from every list method once `apiVersion` is `2026-07-27` or later. The API then answers list endpoints with `{data, pagination}`, and the methods that cast the body handed that object back as their array or keyed type: `getAgentCallers()`, `listModels()`, `listInboundEmailRejections()`, `listGovernanceAiConversations()` and `listSolutionConversations()` returned an object instead of an array, and `listAgentEmailOptOuts()`, `listBlockedEmailSenders()`, `setAutoBlockMode()`, `listAlertConfigs()`, `listOrganizationAlertPreferences()`, `listEmailDomains()`, `listKnowledgeBases()`, `listMemoryBanks()`, `getGenerationTiers()`, `listModelAlerts()` and `listExperiments()` lost their `items`, `configs`, `preferences`, `domains`, `knowledge_bases`, `memory_banks`, `tiers`, `alerts` or `experiments` key. The items are now where each type declares them on both shapes, and `data` and `pagination` stay on the object types, which declare them as optional fields ([#14](https://github.com/seclai/seclai-javascript/issues/14)) +- Fill the flat `total`, `page` and `limit` from `pagination` when `apiVersion` is `2026-07-27` or later, on `listEvaluationResults()`, `listAgentEvaluationResults()`, `listRunEvaluationResults()`, `listEvaluationRuns()`, `listCompatibleRuns()`, `listKnowledgeBases()`, `listMemoryBanks()` and the `total` of the keyed listings above. They were `undefined` although several of those types declare them required ([#14](https://github.com/seclai/seclai-javascript/issues/14)) +- Return an array from `listMemoryBankTemplates()` and `getAgentsUsingMemoryBank()` when `apiVersion` is `2026-07-27` or later, as they do by default. Both are typed `unknown` and returned the `{data, pagination}` object; code that worked around it by reading `.data` must now read the array itself ([#14](https://github.com/seclai/seclai-javascript/issues/14)) +- Reject an unknown `Seclai-Version` passed in the per-request `headers` of `request()` or `requestRaw()` with `SeclaiConfigurationError`, in any letter case. It was sent unchecked, bypassing the guard on `apiVersion`; a caller who relied on that to send a version this release does not know must now set `allowUnknownApiVersion` ([#15](https://github.com/seclai/seclai-javascript/issues/15)) +- Reject an empty `Seclai-Version` in `defaultHeaders` or per-request `headers`. It passed the guard and replaced the configured version with an empty header ([#15](https://github.com/seclai/seclai-javascript/issues/15)) +- Send exactly one value per header on the plain, download, upload and streaming paths. A default or per-request header that differed only in case from another layer's was sent beside it and joined by `fetch`: a default `X-API-Key` went out as `other, real`, a default `Authorization` beside the bearer token, and a per-request `Content-Type` could not replace the JSON one. Which layer wins is unchanged ([#15](https://github.com/seclai/seclai-javascript/issues/15)) +- Keep the multipart boundary on uploads when `defaultHeaders` sets a content type in any case other than `content-type` or `Content-Type` ([#15](https://github.com/seclai/seclai-javascript/issues/15)) +- Correct the README's API-versioning section: `2026-08-03` also rejects a non-zero `max_age_days` on `updateMemoryBank()`; `2026-09-30` breaks code that parses a run's or step's `output` as a JSON manifest; and `SeclaiApiVersion.Latest` moves with each SDK release — `1.6.0` moved it across that `2026-09-30` change — so pin a dated constant to keep behaviour fixed + ## [1.6.0] - 2026-10-04 ### Changed @@ -206,6 +223,7 @@ _Stable release. No functional changes since 0.0.1._ _Initial release._ +[1.6.1]: https://github.com/seclai/seclai-javascript/releases/tag/1.6.1 [1.6.0]: https://github.com/seclai/seclai-javascript/releases/tag/1.6.0 [1.5.0]: https://github.com/seclai/seclai-javascript/releases/tag/1.5.0 [1.4.0]: https://github.com/seclai/seclai-javascript/releases/tag/1.4.0 diff --git a/README.md b/README.md index 39d8f21..9add5a4 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,10 @@ Known versions are on `SeclaiApiVersion` (`V2026_07_01` through `V2026_10_03`, plus `Default` and `Latest`), imported from `@seclai/sdk`. A version this release was **not** built against throws at construction: a newer version can reshape responses, and this client would decode them incorrectly rather than reject them. -Upgrade the package to adopt a new version, or set `allowUnknownApiVersion` if -you have to move first and accept that risk. +The same check applies to a `Seclai-Version` set through `defaultHeaders` or the +per-request `headers` of `request()` / `requestRaw()`, in any letter case, and an +empty value is rejected. Upgrade the package to adopt a new version, or set +`allowUnknownApiVersion` if you have to move first and accept that risk. The guard only covers the header. An account pinned server-side can still be newer than this release — `getApiVersion()` reports the `effective_version` the @@ -140,39 +142,55 @@ request resolved to, and comparing it against `SeclaiApiVersion.Latest` is how you detect the gap. **What `2026-07-27` changes.** Undeclared query parameters become a 422 instead -of being ignored, and list endpoints move to the canonical `{data, pagination}` -envelope. The affected methods read both shapes, so they keep working either way -— but the metadata moves: +of being ignored, and every list endpoint that answered with a bare array or +under a per-resource key moves to the canonical `{data, pagination}` envelope. +The methods for those endpoints return their declared type on either shape, so +code written against the default still reads the result after you opt in. Four +of them return fewer rows once you do, covered below the table: -| Method | Before | From 2026-07-27 | +| Declared return | Methods | From 2026-07-27 | | --- | --- | --- | -| `listEvaluationCriteriaPage()` | bare array | `data` + `pagination` | -| `listRunEvaluationResults()` | bare array | `data` + `pagination` | -| `listAlertConfigs()` | `configs` + `total` | `data` + `pagination` | -| `listModelAlerts()` | `alerts` + `total` | `data` + `pagination` | - -Prefer `pagination` over the flat `total`/`page`/`limit` properties, and read the -last two with `res.data ?? res.configs` / `res.data ?? res.alerts`. The legacy -keys will be deprecated and then removed once the canonical envelope is the -default. - -The cloud-drive listings (`listCloudDriveProviders()`, `listCloudDrives()`, -`getAgentsUsingCloudDrive()`, `listCloudDriveRejections()`) follow the same rule -and return the items as an array on either shape. `listEmbeddingModels()` and -`listRerankerModels()` move their list from `models` to `data`; both methods -populate `models` on either shape, with the defaults and pricing beside it. - -**Later versions.** Each is cumulative, and none changes a response shape this -client decodes: +| An array | `listEvaluationCriteria()`, `getAgentCallers()`, `listInboundEmailRejections()`, `listGovernanceAiConversations()`, `listSolutionConversations()`, `listModels()`, `listMemoryBankTemplates()`, `getAgentsUsingMemoryBank()`, `listCloudDriveProviders()`, `listCloudDrives()`, `getAgentsUsingCloudDrive()`, `listCloudDriveRejections()` | Still the array of items; the page metadata is not returned | +| `data`, bare array by default | `listEvaluationCriteriaPage()`, `listRunEvaluationResults()` | `data`, plus `pagination` | +| `data` with flat `total`/`page`/`limit` | `listEvaluationResults()`, `listAgentEvaluationResults()`, `listEvaluationRuns()`, `listCompatibleRuns()` | Unchanged, plus `pagination` | +| A per-resource key | `listAgentEmailOptOuts()` and `listBlockedEmailSenders()` / `setAutoBlockMode()` (`items`), `listAlertConfigs()` (`configs`), `listOrganizationAlertPreferences()` (`preferences`), `listEmailDomains()` (`domains`), `listKnowledgeBases()` (`knowledge_bases`), `listMemoryBanks()` (`memory_banks`), `getGenerationTiers()` (`tiers`), `listModelAlerts()` (`alerts`), `listExperiments()` (`experiments`), `listEmbeddingModels()` and `listRerankerModels()` (`models`) | The same key and any flat `total`/`page`/`limit`, plus `data` and `pagination` | + +Where a type declares flat `total`, `page` or `limit`, the client fills them +from `pagination` after you opt in. `listRunEvaluationResults()` has no counters +on its default bare array, and gains them with `pagination`. Fields that sit +beside a list, such as `auto_block_mode` or the email-domain plan capabilities, +are present on both shapes. + +Opting in also turns paging on for endpoints that returned everything by +default, so the same call can return fewer rows: + +- `listEvaluationCriteria()` and `listRunEvaluationResults()` return every item + by default and one page (20 unless you pass `limit`) after you opt in. The + array from `listEvaluationCriteria()` carries no sign of that; use + `listEvaluationCriteriaPage()` to see `pagination`. +- `listAlertConfigs()` ignores `page` and `limit` by default and returns every + config; after you opt in it returns one page. +- `setAutoBlockMode()` reports the account's full `total` by default, and the + number of rows it returned after you opt in. + +**Later versions.** Each is cumulative. None changes a response shape this +client decodes, but `2026-09-30` changes what a string you may be parsing +contains: | Version | What it changes | | --- | --- | -| `2026-08-03` | `createMemoryBank()` rejects `max_age_days` with a 400, and an omitted `retention_days` resolves per bank type instead of to 30 | +| `2026-08-03` | `createMemoryBank()` and `updateMemoryBank()` reject a non-zero `max_age_days` with a 400, and a memory bank's `max_age_days` reads as `null`. On create, an omitted `retention_days` resolves per bank type instead of to 30 | | `2026-08-21` | `createSource()` rejects an embedding dimension its embedder does not support with a 400 — `listEmbeddingModels()` reports the supported ones | | `2026-09-28` | Agent-definition writes use the current file-list grammar: an omitted `attachments` keeps the stored list and `[]` means no files | -| `2026-09-30` | A run's and a step's `output`, and a step's `input`, are the text rather than a JSON manifest; files are in `attachments` on every version | +| `2026-09-30` | **Breaks code that parses `output`.** A run's and a step's `output`, and a step's `input`, are the plain text; below this version an output that has files is a JSON manifest string (`{schema, text, attachments}`). Read files from `attachments`, which is populated on every version | | `2026-10-03` | A new LLM step written without `attachments` takes its parent's files, and a new retrieval step's matched media are its files | +**`Latest` moves with the SDK.** `SeclaiApiVersion.Latest` is the newest version +the installed release knows, so upgrading the package can opt a client that +passes it into every version added since — `1.6.0` moved it from `2026-07-27` to +`2026-10-03`, across the `2026-09-30` output change. Pass a dated constant such +as `SeclaiApiVersion.V2026_07_27` to keep behaviour fixed across upgrades. + ## Resources ### Identity diff --git a/src/client.ts b/src/client.ts index 3b67f62..b95b11c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -292,18 +292,78 @@ export type PaginatedPage = } | { items: T[]; pagination?: { page: number; total_pages: number } }; -/** The items of a list that is a bare array by default and `{data, pagination}` from 2026-07-27. */ +/** + * The items of a version-gated list whose method is declared as an array: a + * bare array by default, under `data` from 2026-07-27. + */ function listItems(res: unknown): T[] { if (Array.isArray(res)) return res as T[]; - return (res as { data?: T[] | null } | null)?.data ?? []; + if (res !== null && typeof res === "object" && "data" in res) { + const data = (res as { data: unknown }).data; + if (Array.isArray(data)) return data as T[]; + if (data === null) return []; + } + throw notAList(); +} + +/** An empty list would read as "no results" for a body that is not a list at all. */ +function notAList(): SeclaiError { + return new SeclaiError( + "Expected a list response: an array, or an object carrying the items under `data` or the endpoint's own key.", + ); +} + +/** + * A version-gated list as its declared object type. The items are under `key` + * by default (or the body is a bare array) and under `data` from 2026-07-27, + * where `flat` counters arrive inside `pagination`; both are restored to where + * the type declares them, and `data`/`pagination` stay when the API sent them. + */ +function keyedList( + res: unknown, + key: keyof T & string, + flat: readonly ("total" | "page" | "limit")[] = [], +): T { + if (Array.isArray(res)) return { [key]: res } as T; + if (res === null || typeof res !== "object") throw notAList(); + const body: Record = { ...(res as Record) }; + if (Array.isArray(body.data)) body[key] = body.data; + else if ("data" in body && body.data === null) body[key] = []; + else if (!Array.isArray(body[key])) throw notAList(); + const pagination = body.pagination as Record | null | undefined; + for (const field of flat) { + if (body[field] === undefined && pagination?.[field] !== undefined) body[field] = pagination[field]; + } + return body as T; +} + +/** The flat counters of a list type that declares `total`, `page` and `limit`. */ +const PAGE_COUNTERS = ["total", "page", "limit"] as const; + +/** + * Merge header layers into one value per header. A later layer wins, and names + * compare case-insensitively, so an override replaces instead of adding a + * second spelling for `fetch` to join. + */ +function mergeHeaders(...layers: (Record | undefined)[]): Record { + const byName = new Map(); + for (const layer of layers) { + for (const [name, value] of Object.entries(layer ?? {})) { + // A nullish value from untyped JavaScript means "not set", not a header. + if (value === undefined || value === null) continue; + const lower = name.toLowerCase(); + byName.delete(lower); + byName.set(lower, [name, value]); + } + } + return Object.fromEntries(byName.values()); } -/** Restore `models` on a model listing, which arrives under `data` from 2026-07-27. */ -function withModels(res: unknown): T { - const body = res as T & { data?: T["models"] }; - return Array.isArray(body.models) || !Array.isArray(body.data) - ? body - : { ...body, models: body.data }; +/** The spelling under which `headers` carries `name` — the last one, which is the one a merge keeps. */ +function headerKey(headers: Record | undefined, name: string): string | undefined { + return Object.keys(headers ?? {}) + .filter((key) => key.toLowerCase() === name) + .pop(); } async function safeText(response: Response): Promise { @@ -450,6 +510,7 @@ function inferMimeType(fileName: string | undefined): string | undefined { export class Seclai { private readonly baseUrl: string; private readonly defaultHeaders: Record; + private readonly allowUnknownApiVersion: boolean; private readonly fetcher: FetchLike; private _authState: AuthState | null = null; private _authInitPromise: Promise | null = null; @@ -485,45 +546,16 @@ export class Seclai { this.baseUrl = opts.baseUrl ?? getEnv("SECLAI_API_URL") ?? SECLAI_API_URL; - // Merge first, then validate what the merge produced. `defaultHeaders` is - // applied last so an explicit header wins, which means it can carry its own - // Seclai-Version — and it may carry several in differing cases. Inspecting - // the options instead would have to predict which one survives: picking the - // first match while the merge lets the last win is a guard that validates a - // value the client never sends. - // - // Keys are compared case-insensitively so a caller-supplied `seclai-version` - // replaces ours rather than adding a second wire header. - const merged: Record = opts.apiVersion - ? { "Seclai-Version": opts.apiVersion } - : {}; - let versionKey = opts.apiVersion ? "Seclai-Version" : undefined; - for (const [key, value] of Object.entries(opts.defaultHeaders ?? {})) { - for (const existing of Object.keys(merged)) { - if (existing.toLowerCase() === key.toLowerCase()) delete merged[existing]; - } - merged[key] = value; - if (key.toLowerCase() === "seclai-version") versionKey = key; - } - - const effectiveVersion = versionKey ? merged[versionKey] : undefined; - if ( - effectiveVersion && - !opts.allowUnknownApiVersion && - !KNOWN_API_VERSIONS.includes(effectiveVersion) - ) { - const via = - versionKey === "Seclai-Version" && merged[versionKey] === opts.apiVersion - ? "apiVersion" - : `defaultHeaders['${versionKey}']`; - throw new SeclaiConfigurationError( - `Unknown API version '${effectiveVersion}' (via ${via}). This release was ` + - `built against ${KNOWN_API_VERSIONS.join(", ")}. A newer API version can ` + - `change response shapes, which this client would decode incorrectly rather ` + - `than reject. Upgrade the package, or set allowUnknownApiVersion to ` + - `proceed anyway.`, - ); - } + // `defaultHeaders` is applied last so an explicit header wins, which means + // it can carry its own Seclai-Version; the guard reads the merged result, + // the value the client will send. + this.allowUnknownApiVersion = opts.allowUnknownApiVersion ?? false; + const merged = mergeHeaders( + opts.apiVersion ? { "Seclai-Version": opts.apiVersion } : undefined, + opts.defaultHeaders, + ); + const defaultKey = headerKey(opts.defaultHeaders, "seclai-version"); + this.assertKnownApiVersion(merged, defaultKey ? `defaultHeaders['${defaultKey}']` : "apiVersion"); this.defaultHeaders = merged; this.fetcher = fetcher; @@ -569,6 +601,35 @@ export class Seclai { return this._authState; } + /** Throw unless the `Seclai-Version` in `headers` is one this release was built against. */ + private assertKnownApiVersion(headers: Record, via: string): void { + const key = headerKey(headers, "seclai-version"); + if (key === undefined || this.allowUnknownApiVersion) return; + const version = headers[key] ?? ""; + if (KNOWN_API_VERSIONS.includes(version)) return; + throw new SeclaiConfigurationError( + `Unknown API version '${version}' (via ${via}). This release was ` + + `built against ${KNOWN_API_VERSIONS.join(", ")}. A newer API version can ` + + `change response shapes, which this client would decode incorrectly rather ` + + `than reject. Upgrade the package, or set allowUnknownApiVersion to ` + + `proceed anyway.`, + ); + } + + /** + * The headers one request sends: `layers` merged in order, later winning, + * with the resulting `Seclai-Version` checked against the known versions. + */ + private requestHeaders( + layers: (Record | undefined)[], + perRequest?: Record, + ): Record { + const headers = mergeHeaders(...layers); + const requestKey = headerKey(perRequest, "seclai-version"); + this.assertKnownApiVersion(headers, requestKey ? `headers['${requestKey}']` : "defaultHeaders"); + return headers; + } + /** Resolve auth headers for the current request. */ private async authHeaders(): Promise> { const state = await this.ensureAuth(); @@ -604,17 +665,18 @@ export class Seclai { const url = buildURL(this.baseUrl, path, opts?.query); const authHeaders = await this.authHeaders(); - const headers: Record = { - ...this.defaultHeaders, - ...(opts?.headers ?? {}), - ...authHeaders, - }; - let body: BodyInit | undefined; - if (opts?.json !== undefined) { - headers["content-type"] = headers["content-type"] ?? "application/json"; - body = JSON.stringify(opts.json); - } + if (opts?.json !== undefined) body = JSON.stringify(opts.json); + // A caller's content type, default or per-request, replaces the JSON one. + const headers = this.requestHeaders( + [ + body === undefined ? undefined : { "content-type": "application/json" }, + this.defaultHeaders, + opts?.headers, + authHeaders, + ], + opts?.headers, + ); const init: RequestInit = { method, headers }; if (body !== undefined) { @@ -683,17 +745,18 @@ export class Seclai { const url = buildURL(this.baseUrl, path, opts?.query); const authHeaders = await this.authHeaders(); - const headers: Record = { - ...this.defaultHeaders, - ...(opts?.headers ?? {}), - ...authHeaders, - }; - let body: BodyInit | undefined; - if (opts?.json !== undefined) { - headers["content-type"] = headers["content-type"] ?? "application/json"; - body = JSON.stringify(opts.json); - } + if (opts?.json !== undefined) body = JSON.stringify(opts.json); + // A caller's content type, default or per-request, replaces the JSON one. + const headers = this.requestHeaders( + [ + body === undefined ? undefined : { "content-type": "application/json" }, + this.defaultHeaders, + opts?.headers, + authHeaders, + ], + opts?.headers, + ); const init: RequestInit = { method, headers }; if (body !== undefined) init.body = body; @@ -741,13 +804,10 @@ export class Seclai { const url = buildURL(this.baseUrl, path); const authHeaders = await this.authHeaders(); - const headers: Record = { - ...this.defaultHeaders, - ...authHeaders, - }; - // Let fetch set the correct multipart Content-Type with boundary - delete headers["content-type"]; - delete headers["Content-Type"]; + const headers = this.requestHeaders([this.defaultHeaders, authHeaders]); + // Let fetch set the multipart Content-Type with its boundary. + const contentType = headerKey(headers, "content-type"); + if (contentType !== undefined) delete headers[contentType]; const form = new FormData(); const mimeType = opts.mimeType ?? inferMimeType(opts.fileName); @@ -935,7 +995,7 @@ export class Seclai { * @returns The calling agents. */ async getAgentCallers(agentId: string): Promise { - return (await this.request("GET", `/agents/${agentId}/callers`)) as AgentCallerApiResponse[]; + return listItems(await this.request("GET", `/agents/${agentId}/callers`)); } // ═══════════════════════════════════════════════════════════════════════════ @@ -1110,12 +1170,11 @@ export class Seclai { const url = buildURL(this.baseUrl, `/agents/${agentId}/runs/stream`); const authHdrs = await this.authHeaders(); - const headers: Record = { - ...this.defaultHeaders, - ...authHdrs, - accept: "text/event-stream", - "content-type": "application/json", - }; + const headers = this.requestHeaders([ + this.defaultHeaders, + authHdrs, + { accept: "text/event-stream", "content-type": "application/json" }, + ]); const timeoutMs = opts?.timeoutMs ?? 60_000; const timeoutController = new AbortController(); @@ -1243,12 +1302,11 @@ export class Seclai { const url = buildURL(this.baseUrl, `/agents/${agentId}/runs/stream`); const authHdrs = await this.authHeaders(); - const headers: Record = { - ...this.defaultHeaders, - ...authHdrs, - accept: "text/event-stream", - "content-type": "application/json", - }; + const headers = this.requestHeaders([ + this.defaultHeaders, + authHdrs, + { accept: "text/event-stream", "content-type": "application/json" }, + ]); const timeoutMs = opts?.timeoutMs ?? 60_000; const timeoutController = new AbortController(); @@ -1419,14 +1477,13 @@ export class Seclai { runId: string, opts: ListOptions = {}, ): Promise { - // Either wire shape: the endpoint returns a bare array by default and an - // envelope once the caller opts in with apiVersion 2026-07-27 or later. - const res = (await this.request("GET", `/agents/${agentId}/runs/${runId}/evaluation-results`, { - query: { page: opts.page, limit: opts.limit }, - })) as EvaluationResultWithCriteriaListResponse | EvaluationResultWithCriteriaListResponse["data"]; - return Array.isArray(res) - ? ({ data: res } as EvaluationResultWithCriteriaListResponse) - : res; + return keyedList( + await this.request("GET", `/agents/${agentId}/runs/${runId}/evaluation-results`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + PAGE_COUNTERS, + ); } // ═══════════════════════════════════════════════════════════════════════════ @@ -1586,10 +1643,9 @@ export class Seclai { /** * List evaluation criteria for an agent, with pagination metadata. * - * Accepts either wire shape. The endpoint answered with a bare array before - * 2026-07 and with a paginated envelope after, so a client that decodes only - * one breaks the day the other ships. `total`, `page` and `limit` are absent - * when the endpoint answers with a bare array. + * The endpoint answers with a bare array by default and with `{data, + * pagination}` once `apiVersion` is 2026-07-27 or later; `pagination` is + * absent on the bare array. * * @param agentId - Agent identifier. * @param opts - Pagination options. @@ -1599,10 +1655,12 @@ export class Seclai { agentId: string, opts: ListOptions = {}, ): Promise { - const res = (await this.request("GET", `/agents/${agentId}/evaluation-criteria`, { - query: { page: opts.page, limit: opts.limit }, - })) as EvaluationCriteriaResponse[] | EvaluationCriteriaListResponse; - return Array.isArray(res) ? { data: res } : res; + return keyedList( + await this.request("GET", `/agents/${agentId}/evaluation-criteria`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + ); } /** @@ -1663,9 +1721,13 @@ export class Seclai { * @param opts - Pagination options. */ async listEvaluationResults(criteriaId: string, opts: ListOptions = {}): Promise { - return (await this.request("GET", `/agents/evaluation-criteria/${criteriaId}/results`, { - query: { page: opts.page, limit: opts.limit }, - })) as EvaluationResultListResponse; + return keyedList( + await this.request("GET", `/agents/evaluation-criteria/${criteriaId}/results`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + PAGE_COUNTERS, + ); } /** @@ -1686,9 +1748,13 @@ export class Seclai { * @param opts - Pagination options. */ async listCompatibleRuns(criteriaId: string, opts: ListOptions = {}): Promise { - return (await this.request("GET", `/agents/evaluation-criteria/${criteriaId}/compatible-runs`, { - query: { page: opts.page, limit: opts.limit }, - })) as CompatibleRunListResponse; + return keyedList( + await this.request("GET", `/agents/evaluation-criteria/${criteriaId}/compatible-runs`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + PAGE_COUNTERS, + ); } /** @@ -1709,9 +1775,13 @@ export class Seclai { * @param opts - Pagination options. */ async listAgentEvaluationResults(agentId: string, opts: ListOptions = {}): Promise { - return (await this.request("GET", `/agents/${agentId}/evaluation-results`, { - query: { page: opts.page, limit: opts.limit }, - })) as EvaluationResultWithCriteriaListResponse; + return keyedList( + await this.request("GET", `/agents/${agentId}/evaluation-results`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + PAGE_COUNTERS, + ); } /** @@ -1721,9 +1791,13 @@ export class Seclai { * @param opts - Pagination options. */ async listEvaluationRuns(agentId: string, opts: ListOptions = {}): Promise { - return (await this.request("GET", `/agents/${agentId}/evaluation-runs`, { - query: { page: opts.page, limit: opts.limit }, - })) as EvaluationRunSummaryListResponse; + return keyedList( + await this.request("GET", `/agents/${agentId}/evaluation-runs`, { + query: { page: opts.page, limit: opts.limit }, + }), + "data", + PAGE_COUNTERS, + ); } /** @@ -1780,9 +1854,13 @@ export class Seclai { async listAgentEmailOptOuts( opts: { agentId?: string; limit?: number; offset?: number } = {}, ): Promise { - return (await this.request("GET", "/agents/agent-email-optouts", { - query: { agent_id: opts.agentId, limit: opts.limit, offset: opts.offset }, - })) as AgentEmailOptOutListResponse; + return keyedList( + await this.request("GET", "/agents/agent-email-optouts", { + query: { agent_id: opts.agentId, limit: opts.limit, offset: opts.offset }, + }), + "items", + ["total"], + ); } /** @@ -1805,9 +1883,13 @@ export class Seclai { async listBlockedEmailSenders( opts: { limit?: number; offset?: number } = {}, ): Promise { - return (await this.request("GET", "/agents/blocked-email-senders", { - query: { limit: opts.limit, offset: opts.offset }, - })) as BlockedEmailSenderListResponse; + return keyedList( + await this.request("GET", "/agents/blocked-email-senders", { + query: { limit: opts.limit, offset: opts.offset }, + }), + "items", + ["total"], + ); } /** @@ -1845,9 +1927,11 @@ export class Seclai { * @returns The updated blocked-sender list. */ async setAutoBlockMode(body: SetAutoBlockModeRequest): Promise { - return (await this.request("PUT", "/agents/blocked-email-senders/mode", { - json: body, - })) as BlockedEmailSenderListResponse; + return keyedList( + await this.request("PUT", "/agents/blocked-email-senders/mode", { json: body }), + "items", + ["total"], + ); } /** @@ -1861,9 +1945,11 @@ export class Seclai { async listInboundEmailRejections( opts: { agentId?: string; limit?: number } = {}, ): Promise { - return (await this.request("GET", "/agents/inbound-email-rejections", { - query: { agent_id: opts.agentId, limit: opts.limit }, - })) as InboundEmailRejectionResponse[]; + return listItems( + await this.request("GET", "/agents/inbound-email-rejections", { + query: { agent_id: opts.agentId, limit: opts.limit }, + }), + ); } /** @@ -1910,9 +1996,13 @@ export class Seclai { * @returns Paginated list of knowledge bases. */ async listKnowledgeBases(opts: SortableListOptions = {}): Promise { - return (await this.request("GET", "/knowledge_bases", { - query: { page: opts.page, limit: opts.limit, sort: opts.sort, order: opts.order }, - })) as KnowledgeBaseListResponse; + return keyedList( + await this.request("GET", "/knowledge_bases", { + query: { page: opts.page, limit: opts.limit, sort: opts.sort, order: opts.order }, + }), + "knowledge_bases", + PAGE_COUNTERS, + ); } /** @@ -1966,9 +2056,13 @@ export class Seclai { * @returns Paginated list of memory banks. */ async listMemoryBanks(opts: SortableListOptions = {}): Promise { - return (await this.request("GET", "/memory_banks", { - query: { page: opts.page, limit: opts.limit, sort: opts.sort, order: opts.order }, - })) as MemoryBankListResponse; + return keyedList( + await this.request("GET", "/memory_banks", { + query: { page: opts.page, limit: opts.limit, sort: opts.sort, order: opts.order }, + }), + "memory_banks", + PAGE_COUNTERS, + ); } /** @@ -2020,7 +2114,7 @@ export class Seclai { * @param memoryBankId - Memory bank identifier. */ async getAgentsUsingMemoryBank(memoryBankId: string): Promise { - return await this.request("GET", `/memory_banks/${memoryBankId}/agents`); + return listItems(await this.request("GET", `/memory_banks/${memoryBankId}/agents`)); } /** @@ -2073,7 +2167,7 @@ export class Seclai { * List available memory bank templates. */ async listMemoryBankTemplates(): Promise { - return await this.request("GET", "/memory_banks/templates"); + return listItems(await this.request("GET", "/memory_banks/templates")); } // ─── Memory Bank AI Assistant ────────────────────────────────────────────── @@ -2579,7 +2673,9 @@ export class Seclai { * @returns List of conversations. */ async listSolutionConversations(solutionId: string): Promise { - return (await this.request("GET", `/solutions/${solutionId}/conversations`)) as SolutionConversationResponse[]; + return listItems( + await this.request("GET", `/solutions/${solutionId}/conversations`), + ); } /** @@ -2679,7 +2775,9 @@ export class Seclai { * List governance AI conversations. */ async listGovernanceAiConversations(): Promise { - return (await this.request("GET", "/governance/ai-assistant/conversations")) as GovernanceConversationResponse[]; + return listItems( + await this.request("GET", "/governance/ai-assistant/conversations"), + ); } /** @@ -2778,15 +2876,15 @@ export class Seclai { * @returns Paginated list of alert configs. */ /** - * The configurations arrive under `configs` alongside `total` by default. - * Once the caller opts in with `apiVersion` 2026-07-27 or later the endpoint - * returns the canonical `{data, pagination}` envelope instead, so the - * top-level key changes. + * `configs` and `total` are populated on every API version; `data` and + * `pagination` are also present once `apiVersion` is 2026-07-27 or later. */ async listAlertConfigs(opts: ListOptions = {}): Promise { - return (await this.request("GET", "/alerts/configs", { - query: { page: opts.page, limit: opts.limit }, - })) as AlertConfigListResponse; + return keyedList( + await this.request("GET", "/alerts/configs", { query: { page: opts.page, limit: opts.limit } }), + "configs", + ["total"], + ); } /** @@ -2835,7 +2933,11 @@ export class Seclai { * List organization alert preferences. */ async listOrganizationAlertPreferences(): Promise { - return (await this.request("GET", "/alerts/organization-preferences/list")) as OrganizationAlertPreferenceListResponse; + return keyedList( + await this.request("GET", "/alerts/organization-preferences/list"), + "preferences", + ["total"], + ); } /** @@ -2871,9 +2973,11 @@ export class Seclai { // every page after the first returned page 1. const limit = opts.limit ?? 50; const offset = opts.page && opts.page > 1 ? (opts.page - 1) * limit : undefined; - return (await this.request("GET", "/models/alerts", { - query: { offset, limit: opts.limit }, - })) as ModelAlertListResponse; + return keyedList( + await this.request("GET", "/models/alerts", { query: { offset, limit: opts.limit } }), + "alerts", + ["total"], + ); } /** @@ -2922,7 +3026,7 @@ export class Seclai { supportsOutputMedia?: string; } = {}, ): Promise { - return (await this.request("GET", "/models", { + return listItems(await this.request("GET", "/models", { query: { provider: opts.provider, supports_tool_use: opts.supportsToolUse, @@ -2930,7 +3034,7 @@ export class Seclai { supports_input_media: opts.supportsInputMedia, supports_output_media: opts.supportsOutputMedia, }, - })) as ProviderGroupResponse[]; + })); } /** @@ -2954,7 +3058,7 @@ export class Seclai { * Global routing/pricing (the same for every account); read-only. */ async getGenerationTiers(): Promise> { - return (await this.request("GET", "/models/generation-tiers")) as Record; + return keyedList>(await this.request("GET", "/models/generation-tiers"), "tiers"); } /** @@ -2968,10 +3072,11 @@ export class Seclai { * input modality — a coarse kind (text, image, video, audio) or a full MIME. */ async listEmbeddingModels(opts: { supportsInputMedia?: string } = {}): Promise { - return withModels( + return keyedList( await this.request("GET", "/models/embedders", { query: { supports_input_media: opts.supportsInputMedia }, }), + "models", ); } @@ -2981,7 +3086,7 @@ export class Seclai { * `models` is populated on either wire shape, as for {@link Seclai.listEmbeddingModels}. */ async listRerankerModels(): Promise { - return withModels(await this.request("GET", "/models/rerankers")); + return keyedList(await this.request("GET", "/models/rerankers"), "models"); } // ═══════════════════════════════════════════════════════════════════════════ @@ -2994,9 +3099,13 @@ export class Seclai { * @param opts - Optional filters and pagination. */ async listExperiments(opts: { days?: number; startDate?: string; endDate?: string; limit?: number; offset?: number } = {}): Promise { - return (await this.request("GET", "/models/playground/experiments", { - query: { days: opts.days, start_date: opts.startDate, end_date: opts.endDate, limit: opts.limit, offset: opts.offset }, - })) as ExperimentListResponse; + return keyedList( + await this.request("GET", "/models/playground/experiments", { + query: { days: opts.days, start_date: opts.startDate, end_date: opts.endDate, limit: opts.limit, offset: opts.offset }, + }), + "experiments", + ["total"], + ); } /** @@ -3194,7 +3303,7 @@ export class Seclai { * Requires a user-bound credential; an account-only API key is refused with 403. */ async listEmailDomains(): Promise { - return (await this.request("GET", "/email-domains")) as EmailDomainsListResponse; + return keyedList(await this.request("GET", "/email-domains"), "domains"); } /** diff --git a/src/types.ts b/src/types.ts index ad0004b..68f23ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,13 @@ export type ValidationError = components["schemas"]["ValidationError"]; /** Pagination metadata included in list responses. */ export type PaginationResponse = components["schemas"]["PaginationResponse"]; +/** + * A list response whose endpoint is version-gated. `T` is the default shape, + * which the client returns on every version; `data` and `pagination` are also + * present when `apiVersion` is `2026-07-27` or later. + */ +type VersionedList = T & { data?: T[K]; pagination?: PaginationResponse }; + // ─── Identity ──────────────────────────────────────────────────────────────── /** The authenticated user's personal account ID and the organizations they belong to. */ @@ -102,7 +109,7 @@ export type EmailTriggerConfigResponse = components["schemas"]["EmailTriggerConf export type AgentEmailOptOutResponse = components["schemas"]["AgentEmailOptOutResponse"]; /** A page of agent-email opt-outs plus the total count. */ -export type AgentEmailOptOutListResponse = components["schemas"]["AgentEmailOptOutListResponse"]; +export type AgentEmailOptOutListResponse = VersionedList; /** Request body for blocking an inbound email sender or domain. */ export type BlockEmailSenderRequest = components["schemas"]["BlockEmailSenderRequest"]; @@ -111,7 +118,7 @@ export type BlockEmailSenderRequest = components["schemas"]["BlockEmailSenderReq export type BlockedEmailSenderResponse = components["schemas"]["BlockedEmailSenderResponse"]; /** A page of blocked senders plus the account's governance auto-block mode. */ -export type BlockedEmailSenderListResponse = components["schemas"]["BlockedEmailSenderListResponse"]; +export type BlockedEmailSenderListResponse = VersionedList; /** Request body for setting the governance auto-block mode. */ export type SetAutoBlockModeRequest = components["schemas"]["SetAutoBlockModeRequest"]; @@ -223,9 +230,8 @@ export type AlertConfigResponse = components["schemas"]["AlertConfigResponse"]; /** * A page of alert configurations. * - * The top-level key is version-gated: `configs` alongside `total` by default, - * the canonical `{data, pagination}` envelope once `apiVersion` is `2026-07-27` - * or later. Both are declared optional so either shape type-checks. + * `configs` and `total` are populated on every API version; `data` and + * `pagination` are also present once `apiVersion` is `2026-07-27` or later. */ export type AlertConfigListResponse = { configs?: AlertConfigResponse[]; @@ -240,9 +246,8 @@ export type ModelAlertResponse = components["schemas"]["routers__api__model_life /** * A page of model lifecycle alerts. * - * The top-level key is version-gated: `alerts` alongside `total` by default, the - * canonical `{data, pagination}` envelope once `apiVersion` is `2026-07-27` or - * later. Both are declared optional so either shape type-checks. + * `alerts` and `total` are populated on every API version; `data` and + * `pagination` are also present once `apiVersion` is `2026-07-27` or later. */ export type ModelAlertListResponse = { alerts?: ModelAlertResponse[]; @@ -270,7 +275,7 @@ export type ModelRecommendationsResponse = components["schemas"]["routers__api__ export type ModelRecommendationResponse = components["schemas"]["routers__api__model_lifecycle__ModelRecommendationResponse"]; /** A page of model playground experiments. */ -export type ExperimentListResponse = components["schemas"]["ExperimentListResponse"]; +export type ExperimentListResponse = VersionedList; /** A model playground experiment in a listing. */ export type ExperimentSummaryResponse = components["schemas"]["ExperimentSummaryResponse"]; @@ -319,7 +324,7 @@ export type UpdateEvaluationCriteriaRequest = components["schemas"]["UpdateEvalu export type EvaluationResultResponse = components["schemas"]["EvaluationResultResponse"]; /** Paginated list of evaluation results. */ -export type EvaluationResultListResponse = components["schemas"]["EvaluationResultListResponse"]; +export type EvaluationResultListResponse = VersionedList; /** Request body for creating a manual evaluation result. */ export type CreateEvaluationResultRequest = components["schemas"]["CreateEvaluationResultRequest"]; @@ -341,8 +346,8 @@ export type EvaluationResultWithCriteriaResponse = components["schemas"]["Evalua * `page` and `limit`. * - `GET /agents/{id}/runs/{runId}/evaluation-results` is version-gated: a bare * array by default, and the canonical `{data, pagination}` envelope once - * `apiVersion` is `2026-07-27` or later — in which case the metadata is on - * `pagination` and the flat fields are absent. + * `apiVersion` is `2026-07-27` or later. The client then fills `total`, + * `page` and `limit` from `pagination`; on the bare array they are absent. */ export type EvaluationResultWithCriteriaListResponse = Omit< components["schemas"]["EvaluationResultWithCriteriaListResponse"], @@ -358,7 +363,7 @@ export type EvaluationResultWithCriteriaListResponse = Omit< export type EvaluationRunSummaryResponse = components["schemas"]["EvaluationRunSummaryResponse"]; /** Paginated list of evaluation run summaries. */ -export type EvaluationRunSummaryListResponse = components["schemas"]["EvaluationRunSummaryListResponse"]; +export type EvaluationRunSummaryListResponse = VersionedList; /** Status of an evaluation: pending, passed, failed, skipped, or error. */ export type EvaluationStatus = components["schemas"]["EvaluationStatus"]; @@ -376,7 +381,7 @@ export type TestDraftEvaluationRequest = components["schemas"]["TestDraftEvaluat export type TestDraftEvaluationResponse = components["schemas"]["TestDraftEvaluationResponse"]; /** Paginated list of runs compatible with a specific evaluation criteria. */ -export type CompatibleRunListResponse = components["schemas"]["CompatibleRunListResponse"]; +export type CompatibleRunListResponse = VersionedList; /** Individual compatible run. */ export type CompatibleRunResponse = components["schemas"]["CompatibleRunResponse"]; @@ -384,7 +389,7 @@ export type CompatibleRunResponse = components["schemas"]["CompatibleRunResponse // ─── Knowledge Bases ───────────────────────────────────────────────────────── /** Paginated list of knowledge bases. */ -export type KnowledgeBaseListResponse = components["schemas"]["KnowledgeBaseListResponseModel"]; +export type KnowledgeBaseListResponse = VersionedList; /** Full knowledge base configuration and metadata. */ export type KnowledgeBaseResponse = components["schemas"]["KnowledgeBaseResponseModel"]; @@ -398,7 +403,7 @@ export type UpdateKnowledgeBaseBody = components["schemas"]["UpdateKnowledgeBase // ─── Memory Banks ──────────────────────────────────────────────────────────── /** Paginated list of memory banks. */ -export type MemoryBankListResponse = components["schemas"]["MemoryBankListResponseModel"]; +export type MemoryBankListResponse = VersionedList; /** Full memory bank configuration and metadata. */ export type MemoryBankResponse = components["schemas"]["MemoryBankResponseModel"]; @@ -647,7 +652,7 @@ export type AddCommentRequest = components["schemas"]["routers__api__alerts__Add export type OrganizationAlertPreferenceResponse = components["schemas"]["routers__api__alerts__OrganizationAlertPreferenceResponse"]; /** Paginated list of organization alert preferences. */ -export type OrganizationAlertPreferenceListResponse = components["schemas"]["OrganizationAlertPreferenceListResponse"]; +export type OrganizationAlertPreferenceListResponse = VersionedList; /** Request to update an organization alert preference. */ export type UpdateOrganizationAlertPreferenceRequest = components["schemas"]["routers__api__alerts__UpdateOrganizationAlertPreferenceRequest"]; @@ -684,7 +689,7 @@ export type EffortOptionsResponse = components["schemas"]["EffortOptionsResponse export type EmbeddingModelResponse = components["schemas"]["EmbeddingModelResponse"]; /** The embedding models, with the defaults and pricing that apply to all of them. */ -export type EmbeddingModelListResponse = components["schemas"]["EmbeddingModelListResponse"]; +export type EmbeddingModelListResponse = VersionedList; /** Per-modality rate for an embedding model. */ export type EmbeddingModalityRateResponse = components["schemas"]["EmbeddingModalityRateResponse"]; @@ -696,7 +701,7 @@ export type EmbeddingStorageCreditsResponse = components["schemas"]["EmbeddingSt export type RerankerModelResponse = components["schemas"]["RerankerModelResponse"]; /** The reranker models, with the default and pricing that apply to all of them. */ -export type RerankerModelListResponse = components["schemas"]["RerankerModelListResponse"]; +export type RerankerModelListResponse = VersionedList; /** Variant category for model pricing tiers. */ export type VariantCategoryResponse = components["schemas"]["VariantCategoryResponse"]; @@ -735,7 +740,7 @@ export type AddEmailDomainInput = Pick export type EmailDomainResponse = components["schemas"]["EmailDomainResponse"]; /** The account's email domains plus the plan capabilities for adding more. */ -export type EmailDomainsListResponse = components["schemas"]["EmailDomainsListResponse"]; +export type EmailDomainsListResponse = VersionedList; /** Result of removing an email domain (with an optional registrar `cleanup_note`). */ export type RemoveEmailDomainResponse = components["schemas"]["RemoveEmailDomainResponse"]; diff --git a/tests/client.test.ts b/tests/client.test.ts index d117510..2b3bc6e 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -483,7 +483,7 @@ describe("Knowledge Bases", () => { test("listKnowledgeBases sends GET /knowledge_bases", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/knowledge_bases"); - return jsonResponse({ items: [] }); + return jsonResponse({ knowledge_bases: [], page: 1, limit: 20, total: 0 }); }); await client.listKnowledgeBases(); }); @@ -530,7 +530,7 @@ describe("Memory Banks", () => { test("listMemoryBanks sends GET /memory_banks", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/memory_banks"); - return jsonResponse({ items: [] }); + return jsonResponse({ memory_banks: [], page: 1, limit: 20, total: 0 }); }); await client.listMemoryBanks(); }); @@ -971,7 +971,7 @@ describe("Alerts", () => { test("listAlertConfigs sends GET /alerts/configs", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/alerts/configs"); - return jsonResponse({ items: [] }); + return jsonResponse({ configs: [], total: 0 }); }); await client.listAlertConfigs(); }); @@ -1174,7 +1174,7 @@ describe("Models", () => { test("listModelAlerts sends GET /models/alerts", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/models/alerts"); - return jsonResponse({ items: [] }); + return jsonResponse({ alerts: [], total: 0 }); }); await client.listModelAlerts(); }); @@ -1615,7 +1615,7 @@ describe("Alerts — extended", () => { test("listOrganizationAlertPreferences sends GET", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/alerts/organization-preferences/list"); - return jsonResponse({ items: [] }); + return jsonResponse({ preferences: [], total: 0 }); }); await client.listOrganizationAlertPreferences(); }); @@ -1697,7 +1697,7 @@ describe("Agent Evaluations — extended", () => { test("listEvaluationResults sends GET /agents/evaluation-criteria/:id/results", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/agents/evaluation-criteria/crit_1/results"); - return jsonResponse({ items: [] }); + return jsonResponse({ data: [], total: 0, page: 1, limit: 20 }); }); await client.listEvaluationResults("crit_1"); }); @@ -1717,7 +1717,7 @@ describe("Agent Evaluations — extended", () => { expect(u.pathname).toBe("/agents/evaluation-criteria/crit_1/results"); expect(u.searchParams.get("page")).toBe("2"); expect(u.searchParams.get("limit")).toBe("10"); - return jsonResponse({ items: [] }); + return jsonResponse({ data: [], total: 0, page: 2, limit: 10 }); }); await client.listEvaluationResults("crit_1", { page: 2, limit: 10 }); }); @@ -1725,7 +1725,7 @@ describe("Agent Evaluations — extended", () => { test("listCompatibleRuns sends GET /agents/evaluation-criteria/:id/compatible-runs", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/agents/evaluation-criteria/crit_1/compatible-runs"); - return jsonResponse({ items: [] }); + return jsonResponse({ data: [], total: 0, page: 1, limit: 20 }); }); await client.listCompatibleRuns("crit_1"); }); @@ -1733,7 +1733,7 @@ describe("Agent Evaluations — extended", () => { test("listRunEvaluationResults sends GET /agents/:agentId/runs/:runId/evaluation-results", async () => { const client = makeClient((req) => { expect(new URL(req.url).pathname).toBe("/agents/ag_1/runs/run_1/evaluation-results"); - return jsonResponse({ items: [] }); + return jsonResponse([]); }); await client.listRunEvaluationResults("ag_1", "run_1"); }); @@ -2474,10 +2474,10 @@ describe("Models — media filters & generation tiers", () => { const client = makeClient((req) => { expect(req.method).toBe("GET"); expect(new URL(req.url).pathname).toBe("/models/generation-tiers"); - return jsonResponse({ image: { fast: { model: "m_1" } } }); + return jsonResponse({ tiers: [{ modality: "image", tier: "fast", model: "m_1" }] }); }); const tiers = await client.getGenerationTiers() as Record; - expect(tiers["image"]).toEqual({ fast: { model: "m_1" } }); + expect(tiers["tiers"]).toEqual([{ modality: "image", tier: "fast", model: "m_1" }]); }); }); @@ -2611,7 +2611,7 @@ describe("Undeclared and required query params", () => { expect(q.get("offset")).toBe("50"); expect(q.get("limit")).toBe("25"); expect(q.has("page")).toBe(false); - return jsonResponse({ data: [] }); + return jsonResponse({ alerts: [], total: 0 }); }); await client.listModelAlerts({ page: 3, limit: 25 }); }); @@ -3092,3 +3092,406 @@ describe("Cloud drives, embedders/rerankers and source contents", () => { expect(await client.deleteCloudDrive("c1")).toBeUndefined(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Version-gated lists: every gated endpoint, both response shapes +// ───────────────────────────────────────────────────────────────────────────── + +// Every `METHOD path` the API serves through a version gate. Regenerate from the +// backend (seclai/backend/api/src/api/routers/api): list each call to +// `versioned_list_response`, `versioned_offset_list_response` and +// `versioned_complete_list_response`, and write down the route of the handler it +// returns from — the router's prefix without `/api`, plus the decorator's verb +// and path. One entry per call site. +const GATED_ENDPOINTS = [ + "GET /agents/{agent_id}/evaluation-criteria", + "GET /agents/evaluation-criteria/{criteria_id}/results", + "GET /agents/{agent_id}/runs/{run_id}/evaluation-results", + "GET /agents/{agent_id}/evaluation-runs", + "GET /agents/{agent_id}/evaluation-results", + "GET /agents/evaluation-criteria/{criteria_id}/compatible-runs", + "GET /agents/inbound-email-rejections", + "GET /agents/agent-email-optouts", + "GET /agents/blocked-email-senders", + "PUT /agents/blocked-email-senders/mode", + "GET /agents/{agent_id}/callers", + "GET /alerts/configs", + "GET /alerts/organization-preferences/list", + "GET /cloud-drives/providers", + "GET /cloud-drives", + "GET /cloud-drives/{connection_id}/agents", + "GET /cloud-drives/{connection_id}/rejections", + "GET /email-domains", + "GET /governance/ai-assistant/conversations", + "GET /knowledge_bases", + "GET /memory_banks/templates", + "GET /memory_banks", + "GET /memory_banks/{memory_bank_id}/agents", + "GET /models/generation-tiers", + "GET /models/alerts", + "GET /models/playground/experiments", + "GET /models", + "GET /models/embedders", + "GET /models/rerankers", + "GET /solutions/{solution_id}/conversations", +] as const; + +describe("Version-gated lists", () => { + const ITEMS = [{ id: "x1" }]; + // `versioned_list_response` / `versioned_offset_list_response`: a real page. + const PAGED = { page: 1, limit: 50, total: 1, pages: 1, has_next: false, has_prev: false }; + // `versioned_complete_list_response`: one page spanning the whole list. + const COMPLETE = { page: 1, limit: 1, total: 1, pages: 1, has_next: false, has_prev: false }; + + type Row = { + endpoint: (typeof GATED_ENDPOINTS)[number]; + name: string; + call: (c: Seclai) => Promise; + /** The body below 2026-07-27, and what the method must return for it. */ + legacy: unknown; + wantLegacy: unknown; + /** The body from 2026-07-27, and what the method must return for it. */ + canonical: unknown; + wantCanonical: unknown; + }; + + /** A method declared as an array: the items on either shape. */ + const arrayRow = ( + endpoint: Row["endpoint"], + name: string, + call: Row["call"], + pagination: typeof PAGED, + ): Row => ({ + endpoint, + name, + call, + legacy: ITEMS, + wantLegacy: ITEMS, + canonical: { data: ITEMS, pagination }, + wantCanonical: ITEMS, + }); + + /** + * A method declared as an object. `legacy` is the default body; from + * 2026-07-27 its list moves to `data`, its counters into `pagination`, and + * `extras` stay beside them — and the method must still return every legacy + * field, with `data` and `pagination` kept. + */ + const keyedRow = ( + endpoint: Row["endpoint"], + name: string, + call: Row["call"], + pagination: typeof PAGED, + legacy: Record, + extras: Record = {}, + ): Row => ({ + endpoint, + name, + call, + legacy, + wantLegacy: legacy, + canonical: { data: ITEMS, pagination, ...extras }, + wantCanonical: { ...legacy, data: ITEMS, pagination }, + }); + + const FLAT = { data: ITEMS, total: 1, page: 1, limit: 50 }; + const BLOCKED = { auto_block_mode: "disabled" }; + const DOMAIN_CAPS = { + can_add_vanity: true, + can_add_custom: false, + has_vanity: false, + has_custom: false, + vanity_plan_names: ["Pro"], + custom_plan_names: ["Enterprise"], + }; + const EMBEDDER_EXTRAS = { + storage_credits: [{ dimension: 1024, credits: 1 }], + file_processing_credits_per_mb: 2, + default_model_type: "m1", + default_dimension: 1024, + }; + const RERANKER_EXTRAS = { default_model_type: "m1", search_processing_credits: 3 }; + + const rows: Row[] = [ + arrayRow("GET /agents/{agent_id}/evaluation-criteria", "listEvaluationCriteria", (c) => c.listEvaluationCriteria("a1"), PAGED), + { + endpoint: "GET /agents/{agent_id}/evaluation-criteria", + name: "listEvaluationCriteriaPage", + call: (c) => c.listEvaluationCriteriaPage("a1"), + legacy: ITEMS, + wantLegacy: { data: ITEMS }, + canonical: { data: ITEMS, pagination: PAGED }, + wantCanonical: { data: ITEMS, pagination: PAGED }, + }, + keyedRow("GET /agents/evaluation-criteria/{criteria_id}/results", "listEvaluationResults", (c) => c.listEvaluationResults("c1"), PAGED, FLAT), + { + endpoint: "GET /agents/{agent_id}/runs/{run_id}/evaluation-results", + name: "listRunEvaluationResults", + call: (c) => c.listRunEvaluationResults("a1", "r1"), + legacy: ITEMS, + wantLegacy: { data: ITEMS }, + canonical: { data: ITEMS, pagination: PAGED }, + wantCanonical: { ...FLAT, pagination: PAGED }, + }, + keyedRow("GET /agents/{agent_id}/evaluation-runs", "listEvaluationRuns", (c) => c.listEvaluationRuns("a1"), PAGED, FLAT), + keyedRow("GET /agents/{agent_id}/evaluation-results", "listAgentEvaluationResults", (c) => c.listAgentEvaluationResults("a1"), PAGED, FLAT), + keyedRow("GET /agents/evaluation-criteria/{criteria_id}/compatible-runs", "listCompatibleRuns", (c) => c.listCompatibleRuns("c1"), PAGED, FLAT), + arrayRow("GET /agents/inbound-email-rejections", "listInboundEmailRejections", (c) => c.listInboundEmailRejections(), PAGED), + keyedRow("GET /agents/agent-email-optouts", "listAgentEmailOptOuts", (c) => c.listAgentEmailOptOuts(), PAGED, { items: ITEMS, total: 1 }), + keyedRow("GET /agents/blocked-email-senders", "listBlockedEmailSenders", (c) => c.listBlockedEmailSenders(), PAGED, { items: ITEMS, total: 1, ...BLOCKED }, BLOCKED), + keyedRow("PUT /agents/blocked-email-senders/mode", "setAutoBlockMode", (c) => c.setAutoBlockMode({ mode: "disabled" }), COMPLETE, { items: ITEMS, total: 1, ...BLOCKED }, BLOCKED), + arrayRow("GET /agents/{agent_id}/callers", "getAgentCallers", (c) => c.getAgentCallers("a1"), COMPLETE), + keyedRow("GET /alerts/configs", "listAlertConfigs", (c) => c.listAlertConfigs(), PAGED, { configs: ITEMS, total: 1 }), + keyedRow("GET /alerts/organization-preferences/list", "listOrganizationAlertPreferences", (c) => c.listOrganizationAlertPreferences(), COMPLETE, { preferences: ITEMS, total: 1 }), + arrayRow("GET /cloud-drives/providers", "listCloudDriveProviders", (c) => c.listCloudDriveProviders(), COMPLETE), + arrayRow("GET /cloud-drives", "listCloudDrives", (c) => c.listCloudDrives(), COMPLETE), + arrayRow("GET /cloud-drives/{connection_id}/agents", "getAgentsUsingCloudDrive", (c) => c.getAgentsUsingCloudDrive("d1"), COMPLETE), + arrayRow("GET /cloud-drives/{connection_id}/rejections", "listCloudDriveRejections", (c) => c.listCloudDriveRejections("d1"), PAGED), + keyedRow("GET /email-domains", "listEmailDomains", (c) => c.listEmailDomains(), COMPLETE, { domains: ITEMS, ...DOMAIN_CAPS }, DOMAIN_CAPS), + arrayRow("GET /governance/ai-assistant/conversations", "listGovernanceAiConversations", (c) => c.listGovernanceAiConversations(), PAGED), + keyedRow("GET /knowledge_bases", "listKnowledgeBases", (c) => c.listKnowledgeBases(), PAGED, { knowledge_bases: ITEMS, page: 1, limit: 50, total: 1 }), + arrayRow("GET /memory_banks/templates", "listMemoryBankTemplates", (c) => c.listMemoryBankTemplates(), COMPLETE), + keyedRow("GET /memory_banks", "listMemoryBanks", (c) => c.listMemoryBanks(), PAGED, { memory_banks: ITEMS, page: 1, limit: 50, total: 1 }), + arrayRow("GET /memory_banks/{memory_bank_id}/agents", "getAgentsUsingMemoryBank", (c) => c.getAgentsUsingMemoryBank("m1"), COMPLETE), + keyedRow("GET /models/generation-tiers", "getGenerationTiers", (c) => c.getGenerationTiers(), COMPLETE, { tiers: ITEMS }), + keyedRow("GET /models/alerts", "listModelAlerts", (c) => c.listModelAlerts(), PAGED, { alerts: ITEMS, total: 1 }), + keyedRow("GET /models/playground/experiments", "listExperiments", (c) => c.listExperiments(), PAGED, { experiments: ITEMS, total: 1 }), + arrayRow("GET /models", "listModels", (c) => c.listModels(), COMPLETE), + keyedRow("GET /models/embedders", "listEmbeddingModels", (c) => c.listEmbeddingModels(), COMPLETE, { models: ITEMS, ...EMBEDDER_EXTRAS }, EMBEDDER_EXTRAS), + keyedRow("GET /models/rerankers", "listRerankerModels", (c) => c.listRerankerModels(), COMPLETE, { models: ITEMS, ...RERANKER_EXTRAS }, RERANKER_EXTRAS), + arrayRow("GET /solutions/{solution_id}/conversations", "listSolutionConversations", (c) => c.listSolutionConversations("s1"), COMPLETE), + ]; + + /** Serve `body`, failing unless the request is the row's `METHOD path`. */ + function clientFor(endpoint: string, body: unknown): Seclai { + const [verb, template] = endpoint.split(" "); + const path = new RegExp(`^${template.replace(/\{[^}]+\}/g, "[^/]+")}$`); + return makeClient((req) => { + expect(req.method).toBe(verb); + expect(new URL(req.url).pathname).toMatch(path); + return jsonResponse(body); + }); + } + + test("every gated endpoint has a row", () => { + expect(GATED_ENDPOINTS).toHaveLength(30); + expect([...new Set(rows.map((row) => row.endpoint))].sort()).toEqual([...GATED_ENDPOINTS].sort()); + }); + + test.each(rows)("$name returns its declared shape for the default body", async (row) => { + expect(await row.call(clientFor(row.endpoint, row.legacy))).toEqual(row.wantLegacy); + }); + + test.each(rows)("$name returns its declared shape for the 2026-07-27 body", async (row) => { + expect(await row.call(clientFor(row.endpoint, row.canonical))).toEqual(row.wantCanonical); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Headers: one value per header, and a guarded Seclai-Version, on every path +// ───────────────────────────────────────────────────────────────────────────── + +describe("Request headers", () => { + // `makeFetch` reads headers through `Headers`, which joins two spellings of a + // name into one value. These tests need the object exactly as it was sent. + function recordingClient( + extra: Partial[0]>, + respond: () => Response = () => jsonResponse({ data: [] }), + ) { + const sent: { headers: Record; body: unknown }[] = []; + const client = new Seclai({ + baseUrl: "https://test.invalid", + fetch: async (_input, init) => { + sent.push({ headers: { ...(init?.headers as Record) }, body: init?.body }); + return respond(); + }, + ...extra, + }); + return { client, sent }; + } + + const done = () => + makeSseResponse([`event: done\ndata: ${JSON.stringify({ run_id: "r1", status: "completed" })}\n\n`]); + + const CREDENTIAL_DEFAULTS = { "X-API-KEY": "other", "X-Trace": "default" }; + + test("request() sends one value per header whatever the case", async () => { + const { client, sent } = recordingClient({ apiKey: "real", defaultHeaders: CREDENTIAL_DEFAULTS }); + await client.request("POST", "/agents", { + json: { name: "a" }, + headers: { "x-trace": "request", "Content-Type": "application/x-ndjson" }, + }); + expect(sent[0].headers).toEqual({ + "Content-Type": "application/x-ndjson", + "x-trace": "request", + "x-api-key": "real", + }); + }); + + test("request() sends the JSON content type when the caller sets none", async () => { + const { client, sent } = recordingClient({ apiKey: "real" }); + await client.request("POST", "/agents", { json: { name: "a" } }); + expect(sent[0].headers).toEqual({ "content-type": "application/json", "x-api-key": "real" }); + }); + + test("request() sends the bearer token and account id once", async () => { + const { client, sent } = recordingClient({ + accessToken: "tok", + accountId: "acct", + defaultHeaders: { Authorization: "Bearer other", "X-Account-Id": "other" }, + }); + await client.request("GET", "/agents", { headers: { AUTHORIZATION: "Bearer third" } }); + expect(sent[0].headers).toEqual({ authorization: "Bearer tok", "x-account-id": "acct" }); + }); + + test("requestRaw() sends one value per header whatever the case", async () => { + const { client, sent } = recordingClient({ apiKey: "real", defaultHeaders: CREDENTIAL_DEFAULTS }); + await client.requestRaw("POST", "/agents", { + json: { name: "a" }, + headers: { "x-trace": "request", "CONTENT-TYPE": "application/x-ndjson" }, + }); + expect(sent[0].headers).toEqual({ + "CONTENT-TYPE": "application/x-ndjson", + "x-trace": "request", + "x-api-key": "real", + }); + }); + + test("a download sends one value per header", async () => { + const { client, sent } = recordingClient({ apiKey: "real", defaultHeaders: CREDENTIAL_DEFAULTS }); + await client.downloadSourceExport("s1", "e1"); + expect(sent[0].headers).toEqual({ "X-Trace": "default", "x-api-key": "real" }); + }); + + test("an upload leaves the content type to the multipart body", async () => { + const { client, sent } = recordingClient({ + apiKey: "real", + defaultHeaders: { ...CREDENTIAL_DEFAULTS, "Content-type": "application/json" }, + }); + await client.uploadFileToSource("s1", { file: new Uint8Array([1]), fileName: "a.txt" }); + expect(sent[0].headers).toEqual({ "X-Trace": "default", "x-api-key": "real" }); + expect(sent[0].body).toBeInstanceOf(FormData); + }); + + const STREAM_DEFAULTS = { ...CREDENTIAL_DEFAULTS, Accept: "application/json", "Content-Type": "text/plain" }; + const STREAM_HEADERS = { + "X-Trace": "default", + "x-api-key": "real", + accept: "text/event-stream", + "content-type": "application/json", + }; + + test("runStreamingAgentAndWait() sends one value per header", async () => { + const { client, sent } = recordingClient({ apiKey: "real", defaultHeaders: STREAM_DEFAULTS }, done); + await client.runStreamingAgentAndWait("a1", { input: "x" } as never); + expect(sent[0].headers).toEqual(STREAM_HEADERS); + }); + + test("runStreamingAgent() sends one value per header", async () => { + const { client, sent } = recordingClient({ apiKey: "real", defaultHeaders: STREAM_DEFAULTS }, done); + for await (const _ of client.runStreamingAgent("a1", { input: "x" } as never)) void _; + expect(sent[0].headers).toEqual(STREAM_HEADERS); + }); + + test("a per-request Seclai-Version replaces the client's in any case", async () => { + const { client, sent } = recordingClient({ apiKey: "real", apiVersion: SeclaiApiVersion.V2026_07_01 }); + await client.request("GET", "/agents", { headers: { "seclai-version": SeclaiApiVersion.V2026_07_27 } }); + await client.requestRaw("GET", "/agents", { headers: { "SECLAI-VERSION": SeclaiApiVersion.V2026_07_27 } }); + expect(sent.map((s) => s.headers)).toEqual([ + { "seclai-version": "2026-07-27", "x-api-key": "real" }, + { "SECLAI-VERSION": "2026-07-27", "x-api-key": "real" }, + ]); + }); + + test.each(["Seclai-Version", "seclai-version"])( + "an unknown per-request %s is rejected before anything is sent", + async (name) => { + const { client, sent } = recordingClient({ apiKey: "real", apiVersion: SeclaiApiVersion.V2026_07_01 }); + const headers = { [name]: "2099-01-01" }; + await expect(client.request("GET", "/agents", { headers })).rejects.toThrow(SeclaiConfigurationError); + await expect(client.requestRaw("GET", "/agents", { headers })).rejects.toThrow( + `via headers['${name}']`, + ); + expect(sent).toEqual([]); + }, + ); + + test("an empty Seclai-Version is rejected, not treated as absent", async () => { + expect( + () => + new Seclai({ + apiKey: "real", + apiVersion: SeclaiApiVersion.Latest, + defaultHeaders: { "Seclai-Version": "" }, + }), + ).toThrow(/via defaultHeaders\['Seclai-Version'\]/); + + const { client, sent } = recordingClient({ apiKey: "real", apiVersion: SeclaiApiVersion.Latest }); + await expect(client.request("GET", "/agents", { headers: { "seclai-version": "" } })).rejects.toThrow( + SeclaiConfigurationError, + ); + expect(sent).toEqual([]); + }); + + test("allowUnknownApiVersion lets an unknown per-request version through", async () => { + const { client, sent } = recordingClient({ apiKey: "real", allowUnknownApiVersion: true }); + await client.request("GET", "/agents", { headers: { "Seclai-Version": "2099-01-01" } }); + expect(sent[0].headers).toEqual({ "Seclai-Version": "2099-01-01", "x-api-key": "real" }); + }); +}); + +describe("A list response that is not a list", () => { + const clientAnswering = (body: unknown) => makeClient(() => jsonResponse(body)); + + test.each([ + ["an error-shaped object", { error: "boom" }], + ["a string", "login"], + ["null", null], + ["a key holding a non-array", { knowledge_bases: { a: 1 }, models: { a: 1 } }], + ])("throws SeclaiError for %s rather than reading as no results", async (_name, body) => { + const client = clientAnswering(body); + await expect(client.listKnowledgeBases()).rejects.toThrow(SeclaiError); + await expect(client.listModels()).rejects.toThrow(SeclaiError); + await expect(client.listCloudDrives()).rejects.toThrow(SeclaiError); + await expect(client.listMemoryBankTemplates()).rejects.toThrow(SeclaiError); + }); + + test("an explicit null list is still an empty list", async () => { + const client = clientAnswering({ data: null, pagination: null }); + expect(await client.listModels()).toEqual([]); + expect((await client.listKnowledgeBases()).knowledge_bases).toEqual([]); + }); +}); + +describe("Header edge cases from untyped callers", () => { + function recording(extra: Partial[0]> = {}) { + const sent: Record[] = []; + const client = new Seclai({ + apiKey: "real", + baseUrl: "https://test.invalid", + fetch: async (_input, init) => { + sent.push({ ...(init?.headers as Record) }); + return jsonResponse({ data: [] }); + }, + ...extra, + }); + return { client, sent }; + } + + test.each([undefined, null])("a %s content-type does not displace the JSON one", async (value) => { + const { client, sent } = recording(); + const headers = { "content-type": value } as unknown as Record; + await client.request("POST", "/agents", { json: { a: 1 }, headers }); + expect(sent[0]).toEqual({ "content-type": "application/json", "x-api-key": "real" }); + }); + + test("the guard names the spelling that carried the rejected version", () => { + expect( + () => + new Seclai({ + apiKey: "real", + defaultHeaders: { "Seclai-Version": "2026-07-27", "seclai-version": "2099-01-01" }, + }), + ).toThrow("defaultHeaders['seclai-version']"); + }); +});