@goable-io/sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -7,6 +7,8 @@ import { GoableNetworkError, toApiError } from "./errors.ts"
7
7
  import type {
8
8
  AdaptationReportRequest,
9
9
  AdaptationReportResponse,
10
+ AuditExportQuery,
11
+ AuditExportResponse,
10
12
  BindPolicyRequest,
11
13
  BindPolicyResponse,
12
14
  BriefingRequest,
@@ -24,10 +26,15 @@ import type {
24
26
  EvaluatePolicyResponse,
25
27
  ExplainRequest,
26
28
  ExplainResponse,
29
+ HealthReadyResponse,
27
30
  HealthResponse,
31
+ IdempotencyOptions,
32
+ LegalDocumentKind,
33
+ LegalDocumentResponse,
28
34
  ListPoliciesQuery,
29
35
  ListPoliciesResponse,
30
36
  ListStationsResponse,
37
+ LlmKeyStatus,
31
38
  PolicyResponse,
32
39
  ProjectionsPortfolioRequest,
33
40
  ProjectionsPortfolioResponse,
@@ -56,10 +63,13 @@ import type {
56
63
  ScoreResponse,
57
64
  ScoreSeriesRequest,
58
65
  ScoreSeriesResponse,
66
+ SetLlmKeyRequest,
59
67
  SettlePolicyRequest,
60
68
  SettlePolicyResponse,
61
69
  SubmitObservationsRequest,
62
70
  SubmitObservationsResponse,
71
+ SubmitOutcomeRequest,
72
+ SubmitOutcomeResponse,
63
73
  SustainabilityIndexQuery,
64
74
  SustainabilityIndexResponse,
65
75
  UpdateStationRequest,
@@ -119,6 +129,12 @@ export class GoableClient {
119
129
  return this.request<HealthResponse>("GET", "/v1/health")
120
130
  }
121
131
 
132
+ /** Readiness probe (DB + skill lookup + LLM config). Note: a degraded/critical
133
+ * deployment answers `503`, which surfaces here as a {@link GoableApiError}. */
134
+ healthReady(): Promise<HealthReadyResponse> {
135
+ return this.request<HealthReadyResponse>("GET", "/v1/health/ready")
136
+ }
137
+
122
138
  score(input: ScoreRequest): Promise<ScoreResponse> {
123
139
  return this.request<ScoreResponse>("POST", "/v1/score", input)
124
140
  }
@@ -181,15 +197,29 @@ export class GoableClient {
181
197
  }
182
198
 
183
199
  /** Close the calibration loop: report the observed outcome of a scored
184
- * session. Requires the `outcomes:write` scope. */
185
- reportOutcome(sessionId: string, input: ReportOutcomeRequest): Promise<ReportOutcomeResponse> {
200
+ * session. Requires the `outcomes:write` scope. Pass `idempotencyKey` so a
201
+ * retry after a network timeout can't record the same outcome twice. */
202
+ reportOutcome(
203
+ sessionId: string,
204
+ input: ReportOutcomeRequest,
205
+ options?: IdempotencyOptions,
206
+ ): Promise<ReportOutcomeResponse> {
186
207
  return this.request<ReportOutcomeResponse>(
187
208
  "POST",
188
209
  `/v1/score/${encodeURIComponent(sessionId)}/outcome`,
189
210
  input,
211
+ idempotencyHeader(options),
190
212
  )
191
213
  }
192
214
 
215
+ /** Report a standalone activity outcome not tied to a scored session — the
216
+ * operator-reported behavioural signal behind the calibration + research
217
+ * datasets. Responds 202. Requires the `outcomes:write` scope. For an
218
+ * outcome linked to a specific score, use {@link reportOutcome} instead. */
219
+ submitOutcome(input: SubmitOutcomeRequest): Promise<SubmitOutcomeResponse> {
220
+ return this.request<SubmitOutcomeResponse>("POST", "/v1/outcomes", input)
221
+ }
222
+
193
223
  /** LLM edge-case narrative for a marginal score. */
194
224
  edgeCase(input: EdgeCaseRequest): Promise<EdgeCaseResponse> {
195
225
  return this.request<EdgeCaseResponse>("POST", "/v1/intelligence/edge-case", input)
@@ -217,8 +247,13 @@ export class GoableClient {
217
247
  * warning/critical event refuses the bind with `422 DRIFT_ACTIVE`, thrown as
218
248
  * a {@link DriftActiveError}.
219
249
  */
220
- bindPolicy(input: BindPolicyRequest): Promise<BindPolicyResponse> {
221
- return this.request<BindPolicyResponse>("POST", "/v1/underwriting/policy/bind", input)
250
+ bindPolicy(input: BindPolicyRequest, options?: IdempotencyOptions): Promise<BindPolicyResponse> {
251
+ return this.request<BindPolicyResponse>(
252
+ "POST",
253
+ "/v1/underwriting/policy/bind",
254
+ input,
255
+ idempotencyHeader(options),
256
+ )
222
257
  }
223
258
 
224
259
  /** List the calling tenant's bound policies (paginated, boundAt DESC). */
@@ -321,6 +356,47 @@ export class GoableClient {
321
356
  return this.request<CatalogStatsResponse>("GET", "/v1/public/catalog-stats")
322
357
  }
323
358
 
359
+ /** Fetch the current published legal document of a kind (no auth). */
360
+ legalDocument(kind: LegalDocumentKind): Promise<LegalDocumentResponse> {
361
+ return this.request<LegalDocumentResponse>(
362
+ "GET",
363
+ `/v1/legal/${encodeURIComponent(kind)}/current`,
364
+ )
365
+ }
366
+
367
+ // ── audit / compliance ────────────────────────────────────────────────────
368
+ /**
369
+ * Export the calling tenant's own score + outcome audit history for a date
370
+ * range. `format: "csv"` returns the raw CSV as a `string`; the default
371
+ * (`"json"`) returns the parsed {@link AuditExportResponse}. Offset-paginated
372
+ * via `limit` / `offset`.
373
+ */
374
+ auditExport(query: AuditExportQuery & { format: "csv" }): Promise<string>
375
+ auditExport(query: AuditExportQuery & { format?: "json" }): Promise<AuditExportResponse>
376
+ auditExport(query: AuditExportQuery): Promise<AuditExportResponse | string> {
377
+ const path = `/v1/audit/export${toQuery(query)}`
378
+ if (query.format === "csv") return this.requestText("GET", path)
379
+ return this.request<AuditExportResponse>("GET", path)
380
+ }
381
+
382
+ // ── LLM BYOK (bring-your-own Anthropic key) ───────────────────────────────
383
+ /** Set/rotate the tenant's Anthropic API key. The server validates it with
384
+ * one cheap Anthropic call, encrypts it at rest, and never echoes it back.
385
+ * Resolves `void` on the 204. */
386
+ async setLlmKey(input: SetLlmKeyRequest): Promise<void> {
387
+ await this.request<void>("PUT", "/v1/tenant/llm-key", input)
388
+ }
389
+
390
+ /** Get the tenant's Anthropic key status (masked — never the key itself). */
391
+ getLlmKey(): Promise<LlmKeyStatus> {
392
+ return this.request<LlmKeyStatus>("GET", "/v1/tenant/llm-key")
393
+ }
394
+
395
+ /** Remove the tenant's Anthropic key. Resolves `void` on the 204. */
396
+ async deleteLlmKey(): Promise<void> {
397
+ await this.request<void>("DELETE", "/v1/tenant/llm-key")
398
+ }
399
+
324
400
  /** GDPR Art. 17 erasure. Surfaces the receipt headers from a 204. */
325
401
  async deleteUserData(pseudonym: string): Promise<DeleteUserDataResult> {
326
402
  const res = await this.rawRequest("DELETE", `/v1/decision/user-data/${encodeURIComponent(pseudonym)}`)
@@ -342,15 +418,21 @@ export class GoableClient {
342
418
  }
343
419
 
344
420
  // ── transport ─────────────────────────────────────────────────────────────
345
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
346
- const res = await this.rawRequest(method, path, body)
421
+ private async request<T>(
422
+ method: string,
423
+ path: string,
424
+ body?: unknown,
425
+ extraHeaders?: Record<string, string>,
426
+ ): Promise<T> {
427
+ const res = await this.rawRequest(method, path, body, extraHeaders)
347
428
  const parsed = await safeJson(res)
348
- if (!res.ok) throw toApiError(res.status, parsed)
429
+ if (!res.ok) throw toApiError(res.status, parsed, res.headers)
349
430
  return parsed as T
350
431
  }
351
432
 
352
433
  /** Like {@link request} but returns the raw response body as text — used for
353
- * the NDJSON research streams, which are not a single JSON document. */
434
+ * the NDJSON research streams and the `format=csv` audit export, which are
435
+ * not a single JSON document. */
354
436
  private async requestText(method: string, path: string): Promise<string> {
355
437
  const res = await this.rawRequest(method, path)
356
438
  let text: string
@@ -366,12 +448,17 @@ export class GoableClient {
366
448
  } catch {
367
449
  // non-JSON error body — pass the raw text through to toApiError
368
450
  }
369
- throw toApiError(res.status, parsed)
451
+ throw toApiError(res.status, parsed, res.headers)
370
452
  }
371
453
  return text
372
454
  }
373
455
 
374
- private async rawRequest(method: string, path: string, body?: unknown): ReturnType<FetchLike> {
456
+ private async rawRequest(
457
+ method: string,
458
+ path: string,
459
+ body?: unknown,
460
+ extraHeaders?: Record<string, string>,
461
+ ): ReturnType<FetchLike> {
375
462
  // `X-Goable-Key` rather than `Authorization: Bearer` — the API
376
463
  // sits behind CloudFront with OAC, which hijacks the standard
377
464
  // Authorization header for its own SigV4 signature. Custom header
@@ -383,6 +470,11 @@ export class GoableClient {
383
470
  Accept: "application/json",
384
471
  }
385
472
  if (body !== undefined) headers["Content-Type"] = "application/json"
473
+ if (extraHeaders) {
474
+ for (const [k, v] of Object.entries(extraHeaders)) {
475
+ if (v !== undefined) headers[k] = v
476
+ }
477
+ }
386
478
 
387
479
  const controller = this.timeoutMs > 0 ? new AbortController() : undefined
388
480
  const timer = controller && this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined
@@ -419,6 +511,11 @@ function toQuery(params?: Record<string, unknown>): string {
419
511
  return s ? `?${s}` : ""
420
512
  }
421
513
 
514
+ /** Build the optional `Idempotency-Key` header bag from per-call options. */
515
+ function idempotencyHeader(options?: IdempotencyOptions): Record<string, string> | undefined {
516
+ return options?.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined
517
+ }
518
+
422
519
  async function safeJson(res: { text(): Promise<string> }): Promise<unknown> {
423
520
  let text: string
424
521
  try {
package/src/errors.ts CHANGED
@@ -14,6 +14,26 @@ export interface ZodIssueLike {
14
14
  [k: string]: unknown
15
15
  }
16
16
 
17
+ /** Rate-limit snapshot parsed from `X-RateLimit-*` response headers. Present
18
+ * when the server sent them (currently the `score` + `recommend-spot`
19
+ * endpoints; omitted on unlimited Scale plans). */
20
+ export interface RateLimit {
21
+ /** `X-RateLimit-Limit` — daily safety cap for this endpoint + plan. */
22
+ limit: number
23
+ /** `X-RateLimit-Remaining` — requests left in the current window. */
24
+ remaining: number
25
+ /** `X-RateLimit-Reset` — Unix timestamp (seconds) when the window resets. */
26
+ reset: number
27
+ }
28
+
29
+ /** Structured extras attached to a {@link GoableApiError}. */
30
+ export interface ApiErrorExtra {
31
+ issues?: ZodIssueLike[]
32
+ detail?: Record<string, unknown>
33
+ retryAfterSeconds?: number | null
34
+ rateLimit?: RateLimit
35
+ }
36
+
17
37
  export class GoableApiError extends Error {
18
38
  // Typed `string` (not the literal) so subclasses like DriftActiveError can
19
39
  // override it with their own name.
@@ -26,18 +46,21 @@ export class GoableApiError extends Error {
26
46
  readonly issues?: ZodIssueLike[]
27
47
  /** Free-form extra context (e.g. plan info, quote id). */
28
48
  readonly detail?: Record<string, unknown>
49
+ /** Seconds to wait before retrying, from the `Retry-After` header. Set on a
50
+ * `429`; `null` when the header is absent or unparseable. Lets a caller
51
+ * implement a compliant back-off (`if (err.status === 429) sleep(err.retryAfterSeconds)`). */
52
+ readonly retryAfterSeconds: number | null
53
+ /** Rate-limit snapshot from `X-RateLimit-*` headers, when the response carried them. */
54
+ readonly rateLimit?: RateLimit
29
55
 
30
- constructor(
31
- status: number,
32
- code: string,
33
- message?: string,
34
- extra?: { issues?: ZodIssueLike[]; detail?: Record<string, unknown> },
35
- ) {
56
+ constructor(status: number, code: string, message?: string, extra?: ApiErrorExtra) {
36
57
  super(message ?? code)
37
58
  this.status = status
38
59
  this.code = code
39
60
  if (extra?.issues) this.issues = extra.issues
40
61
  if (extra?.detail) this.detail = extra.detail
62
+ this.retryAfterSeconds = extra?.retryAfterSeconds ?? null
63
+ if (extra?.rateLimit) this.rateLimit = extra.rateLimit
41
64
  // Restore prototype chain for `instanceof` across transpilation targets.
42
65
  Object.setPrototypeOf(this, GoableApiError.prototype)
43
66
  }
@@ -56,12 +79,7 @@ export class DriftActiveError extends GoableApiError {
56
79
  * per-cell and best-effort (documented, not part of the typed contract). */
57
80
  readonly openDriftEvents: Array<Record<string, unknown>>
58
81
 
59
- constructor(
60
- status: number,
61
- code: string,
62
- message?: string,
63
- extra?: { issues?: ZodIssueLike[]; detail?: Record<string, unknown> },
64
- ) {
82
+ constructor(status: number, code: string, message?: string, extra?: ApiErrorExtra) {
65
83
  super(status, code, message, extra)
66
84
  const raw = extra?.detail?.openDriftEvents
67
85
  this.openDriftEvents = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : []
@@ -90,15 +108,37 @@ interface RawErrorBody {
90
108
  detail?: unknown
91
109
  }
92
110
 
111
+ /** A minimal read-only header bag (the `headers` of a fetch Response). */
112
+ export interface HeaderBag {
113
+ get(name: string): string | null
114
+ }
115
+
116
+ /** Parse an integer header, returning null when absent or non-numeric. */
117
+ function intHeader(headers: HeaderBag | undefined, name: string): number | null {
118
+ const v = headers?.get(name)
119
+ if (v == null) return null
120
+ const n = Number(v)
121
+ return Number.isFinite(n) ? n : null
122
+ }
123
+
93
124
  /** Map a parsed error body + status into a GoableApiError. Tolerant of a
94
- * non-conforming body (falls back to the status code as the error code). */
95
- export function toApiError(status: number, body: unknown): GoableApiError {
125
+ * non-conforming body (falls back to the status code as the error code).
126
+ * When `headers` are supplied, surfaces `Retry-After` (as `retryAfterSeconds`)
127
+ * and the `X-RateLimit-*` snapshot on the resulting error. */
128
+ export function toApiError(status: number, body: unknown, headers?: HeaderBag): GoableApiError {
96
129
  const b = (body ?? {}) as RawErrorBody
97
130
  const code = typeof b.error === "string" ? b.error : `HTTP_${status}`
98
131
  const message = typeof b.message === "string" ? b.message : undefined
99
- const extra: { issues?: ZodIssueLike[]; detail?: Record<string, unknown> } = {}
132
+ const extra: ApiErrorExtra = {}
100
133
  if (Array.isArray(b.issues)) extra.issues = b.issues as ZodIssueLike[]
101
134
  if (b.detail && typeof b.detail === "object") extra.detail = b.detail as Record<string, unknown>
135
+ extra.retryAfterSeconds = intHeader(headers, "Retry-After")
136
+ const limit = intHeader(headers, "X-RateLimit-Limit")
137
+ const remaining = intHeader(headers, "X-RateLimit-Remaining")
138
+ const reset = intHeader(headers, "X-RateLimit-Reset")
139
+ if (limit !== null && remaining !== null && reset !== null) {
140
+ extra.rateLimit = { limit, remaining, reset }
141
+ }
102
142
  // Specialise the one error the SDK models with its own class. Stays a
103
143
  // GoableApiError subclass, so generic catch sites are unaffected.
104
144
  if (code === "DRIFT_ACTIVE") return new DriftActiveError(status, code, message, extra)