nexus-ai-pro 2.12.0 → 2.13.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.
Files changed (95) hide show
  1. package/API_STABILITY.md +25 -0
  2. package/CHANGELOG.md +69 -1
  3. package/README.md +10 -5
  4. package/dist/core/lifecycle.d.ts +3 -0
  5. package/dist/core/lifecycle.js +108 -5
  6. package/dist/core/nexus.d.ts +11 -1
  7. package/dist/core/nexus.js +54 -8
  8. package/dist/credentials/index.d.ts +179 -0
  9. package/dist/credentials/index.js +206 -0
  10. package/dist/embeddings/adapters.d.ts +3 -3
  11. package/dist/embeddings/adapters.js +16 -10
  12. package/dist/embeddings/engine.js +7 -2
  13. package/dist/embeddings/errors.d.ts +5 -1
  14. package/dist/embeddings/errors.js +3 -1
  15. package/dist/images/google.js +6 -1
  16. package/dist/images/manager.js +3 -0
  17. package/dist/images/openai.js +4 -1
  18. package/dist/images/operation.js +1 -0
  19. package/dist/providers/anthropic.js +11 -5
  20. package/dist/providers/azure-openai.js +1 -0
  21. package/dist/providers/cohere.d.ts +3 -2
  22. package/dist/providers/cohere.js +6 -4
  23. package/dist/providers/google.d.ts +3 -2
  24. package/dist/providers/google.js +9 -7
  25. package/dist/providers/openai.js +17 -7
  26. package/dist/residency/errors.d.ts +18 -0
  27. package/dist/residency/errors.js +18 -0
  28. package/dist/residency/index.d.ts +93 -0
  29. package/dist/residency/index.js +225 -0
  30. package/dist/router/failover.d.ts +13 -0
  31. package/dist/router/failover.js +26 -4
  32. package/dist/server/http.js +3 -0
  33. package/dist/server/regions.d.ts +88 -0
  34. package/dist/server/regions.js +167 -0
  35. package/dist/server/runs.d.ts +18 -0
  36. package/dist/server/runs.js +21 -2
  37. package/dist/server/server.js +3 -2
  38. package/dist/types/config.d.ts +5 -0
  39. package/dist/types/embeddings.d.ts +5 -0
  40. package/dist/types/images.d.ts +5 -0
  41. package/dist/types/lifecycle.d.ts +71 -0
  42. package/dist/types/residency.d.ts +66 -0
  43. package/dist/types/residency.js +1 -0
  44. package/dist/types/server.d.ts +6 -0
  45. package/dist/utils/scrub.d.ts +12 -0
  46. package/dist/utils/scrub.js +49 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/dist-cjs/core/lifecycle.d.ts +3 -0
  50. package/dist-cjs/core/lifecycle.js +109 -5
  51. package/dist-cjs/core/nexus.d.ts +11 -1
  52. package/dist-cjs/core/nexus.js +53 -7
  53. package/dist-cjs/credentials/index.d.ts +179 -0
  54. package/dist-cjs/credentials/index.js +219 -0
  55. package/dist-cjs/embeddings/adapters.d.ts +3 -3
  56. package/dist-cjs/embeddings/adapters.js +16 -10
  57. package/dist-cjs/embeddings/engine.js +7 -2
  58. package/dist-cjs/embeddings/errors.d.ts +5 -1
  59. package/dist-cjs/embeddings/errors.js +3 -1
  60. package/dist-cjs/images/google.js +6 -1
  61. package/dist-cjs/images/manager.js +3 -0
  62. package/dist-cjs/images/openai.js +4 -1
  63. package/dist-cjs/images/operation.js +1 -0
  64. package/dist-cjs/providers/anthropic.js +11 -5
  65. package/dist-cjs/providers/azure-openai.js +1 -0
  66. package/dist-cjs/providers/cohere.d.ts +3 -2
  67. package/dist-cjs/providers/cohere.js +6 -4
  68. package/dist-cjs/providers/google.d.ts +3 -2
  69. package/dist-cjs/providers/google.js +9 -7
  70. package/dist-cjs/providers/openai.js +17 -7
  71. package/dist-cjs/residency/errors.d.ts +18 -0
  72. package/dist-cjs/residency/errors.js +22 -0
  73. package/dist-cjs/residency/index.d.ts +93 -0
  74. package/dist-cjs/residency/index.js +232 -0
  75. package/dist-cjs/router/failover.d.ts +13 -0
  76. package/dist-cjs/router/failover.js +26 -4
  77. package/dist-cjs/server/http.js +3 -0
  78. package/dist-cjs/server/regions.d.ts +88 -0
  79. package/dist-cjs/server/regions.js +170 -0
  80. package/dist-cjs/server/runs.d.ts +18 -0
  81. package/dist-cjs/server/runs.js +21 -2
  82. package/dist-cjs/server/server.js +3 -2
  83. package/dist-cjs/types/config.d.ts +5 -0
  84. package/dist-cjs/types/embeddings.d.ts +5 -0
  85. package/dist-cjs/types/images.d.ts +5 -0
  86. package/dist-cjs/types/lifecycle.d.ts +71 -0
  87. package/dist-cjs/types/residency.d.ts +66 -0
  88. package/dist-cjs/types/residency.js +2 -0
  89. package/dist-cjs/types/server.d.ts +6 -0
  90. package/dist-cjs/utils/scrub.d.ts +12 -0
  91. package/dist-cjs/utils/scrub.js +53 -0
  92. package/dist-cjs/version.d.ts +1 -1
  93. package/dist-cjs/version.js +1 -1
  94. package/llms.txt +2 -1
  95. package/package.json +40 -1
package/API_STABILITY.md CHANGED
@@ -41,6 +41,31 @@ Both are covered by this policy:
41
41
  Deep imports into `dist`, `dist-cjs`, or `src` are not supported. Import only from `nexus-ai-pro` or
42
42
  one of its explicit subpaths.
43
43
 
44
+ ## 2.13 stage (2.13.0)
45
+
46
+ Everything 2.13 adds is stable from the start and additive, unless this section says otherwise.
47
+
48
+ New and stable:
49
+ - `nexus-ai-pro/server/regions`: `regionalGateway()`, `RegionalGateway`, `RegionalGatewayOptions`,
50
+ `RegionTarget`, `RegionHealth`, and `RegionHealthOptions`;
51
+ - `RunManagerOptions.region` and `residency`, as `AgentServerOptions` carries them; `ThreadRecord.region`,
52
+ `RunRecord.region`, and `ReplicaReport.region`; a regional server's ids start with its region;
53
+ - `nexus-ai-pro/residency`: `residency()`, `Residency`, `ResidencyOptions`, `RegionRule`,
54
+ `RegionDefinition`, `ResidencyPolicy`, `ResidencyDecision`, `ResidencyTarget`, `ResidencyPlacement`,
55
+ `CallResidency`, `ResidencyError`, `regionalTraceStore()`, `RegionalTraceStore`,
56
+ `RegionalTraceStoreOptions`, `regionalAssetStore()`, and `RegionalAssetStoreOptions`;
57
+ - `LifecycleConfig.residency`, `ProviderCallContext.residency`, and `ImageRequestBase.tenantId`;
58
+ - `nexus-ai-pro/credentials`: `tenantCredentials()`, `TenantCredentials`, `TenantCredentialsOptions`,
59
+ `TenantCredentialStats`, `CredentialError`, `parseSecret()`, `credentialRequest()`, `SecretSource`,
60
+ `awsSecrets()`, `gcpSecrets()`, `azureSecrets()`, `vaultSecrets()`, `envSecrets()`,
61
+ `memorySecrets()`, `MemorySecrets`, `AwsSecretsClient`, `GcpSecretsClient`, `AzureSecretsClient`,
62
+ and `VaultSecretsOptions`;
63
+ - `LifecycleConfig.credentials`, `CredentialResolver`, `CredentialRequest`, and `ProviderCredential`;
64
+ `ProviderCallContext.tenantId` and `credential`; `OperationTicket.refreshCredential()` and
65
+ `scrub()`;
66
+ - `OpenAIProviderConfig.credentialHeader`, `EmbeddingRequest.tenantId`, and
67
+ `EmbeddingProviderError.attempts`.
68
+
44
69
  ## 2.12 stage (2.12.0)
45
70
 
46
71
  Everything 2.12 adds is stable from the start and additive, unless this section says otherwise.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,73 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.13.0] - 2026-10-19
8
+
9
+ Regions and residency, and credentials per tenant.
10
+
11
+ ### Added
12
+
13
+ - **Agent servers across regions.** `regionalGateway()`, on the new `nexus-ai-pro/server/regions`, sits
14
+ in front of one agent server per region. A thread or run goes to the region its id names and waits
15
+ there while that region is down, since its data is there and nowhere else; new work goes to the
16
+ tenant's home region and fails over only to another region the tenant may reach; a tenant that may
17
+ reach no region served is refused with a 403 naming the rule. Regions are checked through their
18
+ `/health`, marked down by failed requests, and taken out by an operator with `markDown()`. An agent
19
+ server given `region` stamps it on its threads, runs, ids, replica report, and health, and, given
20
+ `residency`, refuses tenants that may not reach it.
21
+ - **Residency: each tenant's requests and data kept in its regions.** `residency()`, on the new
22
+ `nexus-ai-pro/residency`, says which regions each tenant may reach (a map, or a function over the
23
+ tenant's record), which providers run in each region, and each provider's twin inside a region.
24
+ `LifecycleConfig.residency` applies it to every family: a completion or stream, its fallbacks and
25
+ its context summary included, is sent to the provider routing chose when that runs inside the
26
+ tenant's regions and to its twin there when not; an embedding never falls back outside them; and a
27
+ provider anything would reach outside them is refused with a `ResidencyError` naming the rule,
28
+ before anything is sent. `regionalTraceStore()` and `regionalAssetStore()` write each tenant's
29
+ traces, every run of a trace together, and assets in its home region, and read them only from its
30
+ regions.
31
+ - **Credentials per tenant.** `LifecycleConfig.credentials` resolves each provider's key per tenant,
32
+ when a call is made, in every family that runs through the lifecycle: completions, streams,
33
+ embeddings, and images send it in place of the configured key. `tenantCredentials()`, on the new
34
+ `nexus-ai-pro/credentials`, reads it from a secret source, cached for `cacheMs`, shared by
35
+ concurrent calls, carried by the last key while the source is down for up to `staleMs`, and
36
+ refusing a tenant with no key unless told to fall back. Sources for AWS Secrets Manager, Google
37
+ Secret Manager, Azure Key Vault, HashiCorp Vault, the environment, and memory, each over the
38
+ vendor's own client with no dependency.
39
+ - A completion or stream whose provider refuses its credential is resolved again and made once more
40
+ on the new key; in any other family, the next operation reads the key again. Every key and header
41
+ value an operation resolves is removed from the errors it raises, before an audit event, a log,
42
+ a trace, or the health report sees them.
43
+ - `ProviderCallContext.residency`, the operation's `CallResidency`; `ImageRequestBase.tenantId`; and
44
+ an agent server's trace root records its run's tenant as `metadata.nexus.tenant`.
45
+ - `ProviderCallContext.tenantId` and `credential()`; `OperationTicket.refreshCredential()` and
46
+ `scrub()`; `OpenAIProviderConfig.credentialHeader`; `EmbeddingRequest.tenantId`; and
47
+ `EmbeddingProviderError.attempts`, each attempt's error.
48
+
49
+ ### Proof
50
+
51
+ - `tests/regions.test.ts`: the proof that closes 2.13. Three regions, each an agent server with its own
52
+ state, traces, and provider, behind a gateway, with every tenant's key from one secret manager. A run
53
+ of four model calls has its key rotated and revoked after the second; the third and fourth are
54
+ answered on the new key. Then the EU tenant's home region goes down: its new work fails over to the
55
+ other EU region, its thread waits for its own, a tenant held to that region waits too, and nothing
56
+ of either reaches the US: no thread, run, trace, or provider call. Without region ids, the server's
57
+ check, a thread's region kept, new work held to the tenant's regions, failover, checks, the
58
+ operator's mark, or the gateway's own refusal, a test fails.
59
+ - `tests/residency.test.ts`: against a provider server per region, a tenant pinned to the EU is
60
+ answered by the EU twin of the provider routing chose, completions and streams alike, and nothing
61
+ of it reaches the US; a provider with no twin there is refused with the rule, nothing sent; an
62
+ embedding whose EU provider fails never falls back to the US; an image provider outside the regions
63
+ is refused; and each tenant's traces and assets land in its home region and are read only from its
64
+ regions. Without placement, twins, the decision, the routed check, the fallback filter, the image
65
+ check, traces kept together, the tenant view, the asset home, or the rule checks, a test fails.
66
+ - `tests/tenant-credentials.test.ts`: against a provider of the test's own that takes only live keys,
67
+ each tenant's calls carry its own key through completions, streams, and embeddings; in a run of five
68
+ calls whose key is rotated and revoked after the second, the third is answered on the new key and
69
+ every call after uses it; a key the provider quotes in its refusal reaches no error, stack, cause,
70
+ audit event, log, trace, or health report. Without the call's credential, the refresh, its limit,
71
+ either scrub, an adapter's use of the key, the cache's expiry, its single read, its stale key, or
72
+ its refusal, a test fails.
73
+
7
74
  ## [2.12.0] - 2026-10-18
8
75
 
9
76
  Observability that loses nothing, and media you can evaluate.
@@ -2879,7 +2946,8 @@ shared policy vocabulary is in place for a future release that revisits this.
2879
2946
  - URL fetching rejects unsafe private-network targets and limits response reads.
2880
2947
  - CLI and audit findings no longer disclose detected secret values.
2881
2948
 
2882
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.12.0...HEAD
2949
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.13.0...HEAD
2950
+ [2.13.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.12.0...v2.13.0
2883
2951
  [2.12.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.11.0...v2.12.0
2884
2952
  [2.11.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.10.0...v2.11.0
2885
2953
  [2.10.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.9.0...v2.10.0
package/README.md CHANGED
@@ -167,6 +167,7 @@ from the doc comments. The guides live in the repository, so these links go to G
167
167
  | [Agent server](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/server.md) | Self-hosted HTTP server for assistants: threads, durable runs, resumable streams, cron, a worker queue, and metrics |
168
168
  | [Deployments](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/deployments.md) | Revisions and canaries with automatic rollback, autoscaling on Kubernetes and Helm, and per-tenant limits |
169
169
  | [Tenancy](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/tenancy.md) | One tenant model: a tenant's view of every store, tenant graph runs, and the isolation suite |
170
+ | [Regions and credentials](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/regions.md) | Agent servers across regions behind a gateway, residency rules that keep each tenant's requests, traces, and assets in its regions, and each tenant's provider keys from a secret manager |
170
171
  | [Workflows](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/workflows.md) | Ready-made chains and domain workflows |
171
172
  | [Command line](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/cli.md) | The `nexus` command |
172
173
  | [Packaging and install weight](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/packaging.md) | Every entry point and what it costs |
@@ -307,14 +308,18 @@ Shipped so far:
307
308
  - 2.12 observability that loses nothing, and media you can evaluate: spans spooled through a collector
308
309
  outage, OTLP over gRPC, deployment evaluation that reads each run's whole trace, evaluators that
309
310
  read and judge images, documents, audio, and video, a catalog of every stored asset, and automation
310
- rules that roll a canary back when a score falls.
311
+ rules that roll a canary back when a score falls;
312
+ - 2.13 regions and residency, and credentials per tenant: agent servers across regions behind a
313
+ gateway that fails over only within a tenant's regions, residency rules that keep each tenant's
314
+ provider calls, traces, and assets in its regions, and each tenant's provider keys from a secret
315
+ manager, rotated without a restart and kept out of every error.
311
316
 
312
317
  Upgrading from 1.x: `npx nexus migrate src --write` moves your imports, and
313
318
  [MIGRATING.md](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/MIGRATING.md) covers the rest.
314
- Next, 2.13 runs agents across regions, keeps each tenant's requests and data in the region they
315
- require, and resolves provider keys per tenant from a secret manager. Pinecone, images, the Gemini
316
- embedding batch tier, media understanding, video, Gemini Live, SIP, and both phone providers graduate
317
- once the live workflow passes against each. The
319
+ Next, 2.14 makes policy a reviewed file, checked by the command line before it ships, brings
320
+ per-tenant keys to every family, and lets an evaluation run an agent over emulated tools with nothing
321
+ sent. Pinecone, images, the Gemini embedding batch tier, media understanding, video, Gemini Live, SIP,
322
+ and both phone providers graduate once the live workflow passes against each. The
318
323
  [roadmap](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/ROADMAP.md) lists what comes after, in the order
319
324
  applications ask for it.
320
325
 
@@ -3,6 +3,7 @@ import type { ProviderHealthOutcome, LifecycleConfig, LifecycleStage, OperationD
3
3
  import type { AuditLogger } from '../ops/audit-logger.js';
4
4
  import type { MetricsCollector } from '../ops/metrics.js';
5
5
  import { type RateLimiter } from '../ops/rate-limiter.js';
6
+ import type { CallResidency, ResidencyPolicy } from '../types/residency.js';
6
7
  /** Every lifecycle stage, in the order an operation passes through them. */
7
8
  export declare const LIFECYCLE_STAGES: readonly LifecycleStage[];
8
9
  /** Raised when `lifecycle.authorize` refuses an operation, before any provider is called. */
@@ -114,3 +115,5 @@ export declare class OperationLifecycle implements OperationLifecycleLike {
114
115
  private checkRateLimit;
115
116
  private authorize;
116
117
  }
118
+ /** An operation's residency, as the families that call providers check it. */
119
+ export declare function callResidency(policy: ResidencyPolicy, decision: CallResidency['decision']): CallResidency;
@@ -1,5 +1,6 @@
1
1
  import { NexusRateLimitError } from '../ops/rate-limiter.js';
2
2
  import { generateRequestId } from '../utils/ids.js';
3
+ import { scrubSecrets } from '../utils/scrub.js';
3
4
  import { linkSignals } from '../utils/signals.js';
4
5
  export const LIFECYCLE_STAGES = [
5
6
  'validate',
@@ -51,12 +52,24 @@ export class OperationLifecycle {
51
52
  ...(deadline !== undefined ? { deadline } : {}),
52
53
  ...(options.traceContext ? { traceContext: options.traceContext } : {}),
53
54
  ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
55
+ ...(descriptor.tenantId !== undefined ? { tenantId: descriptor.tenantId } : {}),
54
56
  };
55
- const ticket = new Ticket(this.runtime, descriptor, context, startedAt, call.dispose);
57
+ const credentials = this.runtime.config?.credentials
58
+ ? new CallCredentials(this.runtime.config.credentials, descriptor)
59
+ : undefined;
60
+ if (credentials)
61
+ context.credential = (provider) => credentials.resolve(provider);
62
+ const ticket = new Ticket(this.runtime, descriptor, context, startedAt, call.dispose, credentials);
56
63
  try {
57
64
  await this.runtime.metrics?.recordRequest(labelsOf(descriptor));
58
65
  await this.checkRateLimit(descriptor);
59
66
  await this.authorize(descriptor);
67
+ const policy = this.runtime.config?.residency;
68
+ if (policy) {
69
+ context.residency = callResidency(policy, await policy.regionsFor(descriptor.tenantId));
70
+ if (descriptor.provider)
71
+ context.residency.check(descriptor.provider);
72
+ }
60
73
  }
61
74
  catch (error) {
62
75
  await ticket.fail(error);
@@ -73,8 +86,10 @@ export class OperationLifecycle {
73
86
  await plan.inputPolicy?.(ticket.context);
74
87
  await plan.resolveAssets?.(ticket.context);
75
88
  const routed = await plan.route?.();
76
- if (routed?.provider)
89
+ if (routed?.provider) {
90
+ ticket.context.residency?.check(routed.provider);
77
91
  ticket.descriptor.provider = routed.provider;
92
+ }
78
93
  if (routed?.model)
79
94
  ticket.descriptor.model = routed.model;
80
95
  if (this.runtime.config?.budget)
@@ -107,7 +122,9 @@ export class OperationLifecycle {
107
122
  !this.runtime.rateLimit?.enabled &&
108
123
  !config?.authorize &&
109
124
  !config?.budget &&
110
- !config?.hooks);
125
+ !config?.hooks &&
126
+ !config?.credentials &&
127
+ !config?.residency);
111
128
  }
112
129
  async checkRateLimit(descriptor) {
113
130
  const { rateLimiter, rateLimit } = this.runtime;
@@ -142,20 +159,98 @@ export class OperationLifecycle {
142
159
  throw new OperationDeniedError(descriptor);
143
160
  }
144
161
  }
162
+ export function callResidency(policy, decision) {
163
+ return {
164
+ decision,
165
+ allows: (provider) => policy.allows(policy.regionOf(provider), decision),
166
+ place: (provider) => policy.place(provider, decision),
167
+ check: (provider) => {
168
+ const region = policy.regionOf(provider);
169
+ policy.check({ kind: 'provider', name: provider, ...(region ? { region } : {}) }, decision);
170
+ },
171
+ };
172
+ }
173
+ class CallCredentials {
174
+ resolver;
175
+ descriptor;
176
+ resolved = new Map();
177
+ secrets = new Set();
178
+ constructor(resolver, descriptor) {
179
+ this.resolver = resolver;
180
+ this.descriptor = descriptor;
181
+ }
182
+ resolve(provider) {
183
+ let found = this.resolved.get(provider);
184
+ if (found)
185
+ return found;
186
+ found = Promise.resolve(this.resolver.resolve({
187
+ provider,
188
+ ...(this.descriptor.tenantId !== undefined ? { tenantId: this.descriptor.tenantId } : {}),
189
+ family: this.descriptor.family,
190
+ operation: this.descriptor.operation,
191
+ })).then((credential) => {
192
+ if (credential?.apiKey)
193
+ this.secrets.add(credential.apiKey);
194
+ for (const value of Object.values(credential?.headers ?? {}))
195
+ this.secrets.add(value);
196
+ return credential;
197
+ });
198
+ this.resolved.set(provider, found);
199
+ found.catch(() => {
200
+ if (this.resolved.get(provider) === found)
201
+ this.resolved.delete(provider);
202
+ });
203
+ return found;
204
+ }
205
+ async refresh(provider) {
206
+ this.resolved.delete(provider);
207
+ await this.resolver.invalidate?.({
208
+ provider,
209
+ ...(this.descriptor.tenantId !== undefined ? { tenantId: this.descriptor.tenantId } : {}),
210
+ });
211
+ }
212
+ async refreshAll() {
213
+ for (const provider of [...this.resolved.keys()])
214
+ await this.refresh(provider);
215
+ }
216
+ }
217
+ function refusedCredential(error, depth = 0) {
218
+ if (!error || typeof error !== 'object' || depth > 4)
219
+ return false;
220
+ const failed = error;
221
+ if (failed.category === 'auth' || failed.status === 401 || failed.statusCode === 401)
222
+ return true;
223
+ for (const list of [failed.attempts, failed.errors]) {
224
+ if (Array.isArray(list) && list.some((item) => refusedCredential(item, depth + 1)))
225
+ return true;
226
+ }
227
+ return refusedCredential(failed.cause, depth + 1);
228
+ }
145
229
  class Ticket {
146
230
  runtime;
147
231
  descriptor;
148
232
  context;
149
233
  startedAt;
150
234
  release;
235
+ credentials;
151
236
  reservation;
152
237
  finished = false;
153
- constructor(runtime, descriptor, context, startedAt, release = () => undefined) {
238
+ constructor(runtime, descriptor, context, startedAt, release = () => undefined, credentials) {
154
239
  this.runtime = runtime;
155
240
  this.descriptor = descriptor;
156
241
  this.context = context;
157
242
  this.startedAt = startedAt;
158
243
  this.release = release;
244
+ this.credentials = credentials;
245
+ }
246
+ async refreshCredential(provider) {
247
+ if (!this.credentials)
248
+ return false;
249
+ await this.credentials.refresh(provider);
250
+ return true;
251
+ }
252
+ scrub(error) {
253
+ return this.credentials ? scrubSecrets(error, this.credentials.secrets) : error;
159
254
  }
160
255
  async reserve(estimate) {
161
256
  const budget = this.runtime.config?.budget;
@@ -193,9 +288,13 @@ class Ticket {
193
288
  });
194
289
  }
195
290
  async fail(error) {
291
+ this.scrub(error);
196
292
  if (this.finished)
197
293
  return;
198
294
  this.finished = true;
295
+ if (this.credentials && refusedCredential(error)) {
296
+ await this.credentials.refreshAll().catch(() => undefined);
297
+ }
199
298
  const budget = this.runtime.config?.budget;
200
299
  if (budget && this.reservation)
201
300
  await budget.release(this.reservation);
@@ -239,7 +338,11 @@ function outcomeOf(error, signal) {
239
338
  error instanceof BudgetExceededError ||
240
339
  error instanceof NexusRateLimitError ||
241
340
  (error instanceof Error &&
242
- (error.name === 'CostBudgetError' || error.name === 'TenantLimitError' || error.name === 'NexusSecurityError'))) {
341
+ (error.name === 'CostBudgetError' ||
342
+ error.name === 'TenantLimitError' ||
343
+ error.name === 'NexusSecurityError' ||
344
+ error.name === 'ResidencyError' ||
345
+ error.name === 'CredentialError'))) {
243
346
  return 'denied';
244
347
  }
245
348
  if (signal.aborted ||
@@ -289,7 +289,17 @@ export declare class NexusAI {
289
289
  /** A completion as the lifecycle sees it. */
290
290
  private describe;
291
291
  private startOptions;
292
- /** How failover runs the provider calls of one operation, with the context each attempt hands on. */
292
+ /**
293
+ * A route kept inside the regions the operation may reach: each attempt sent to its provider when
294
+ * that runs inside them, to the provider's twin inside them when there is one, and dropped when
295
+ * neither. With nothing left, the operation is refused with a `ResidencyError` naming the rule.
296
+ */
297
+ private placeRoute;
298
+ /**
299
+ * How failover runs the provider calls of one operation, with the context each attempt hands on,
300
+ * and, given the operation's ticket, how a refused credential is resolved again and every error
301
+ * cleaned of the credentials it resolved.
302
+ */
293
303
  private executionOptions;
294
304
  /**
295
305
  * Times a lifecycle stage into the trace when the lifecycle has work to do, so a client with
@@ -1,4 +1,5 @@
1
1
  import { configuredProviders } from '../providers/lazy.js';
2
+ import { ResidencyError } from '../residency/errors.js';
2
3
  import { Router, FailoverExecutor } from '../router/index.js';
3
4
  import { Logger } from '../utils/logger.js';
4
5
  import { SecurityPipeline } from '../security/index.js';
@@ -23,7 +24,7 @@ import { ProviderHealthMonitor } from '../ops/health.js';
23
24
  import { CircuitBreaker } from '../ops/circuit-breaker.js';
24
25
  import { runBatch } from '../jobs/batch.js';
25
26
  import { JobQueue } from '../jobs/queue.js';
26
- import { OperationLifecycle } from './lifecycle.js';
27
+ import { callResidency, OperationLifecycle } from './lifecycle.js';
27
28
  export class NexusAI {
28
29
  images;
29
30
  lifecycle;
@@ -137,7 +138,7 @@ export class NexusAI {
137
138
  }
138
139
  context = await this.pipeline.runHook('afterSecurity', context);
139
140
  const decision = await this.pipeline.trace(context, 'routing', () => {
140
- return this.router.route(context.request, this.config, this.providers, this.health.snapshot(), this.circuitBreaker.openProviders());
141
+ return this.placeRoute(this.router.route(context.request, this.config, this.providers, this.health.snapshot(), this.circuitBreaker.openProviders()), ticket.context.residency);
141
142
  });
142
143
  context.route = decision;
143
144
  this.logger.info('route decision', decision);
@@ -197,7 +198,7 @@ export class NexusAI {
197
198
  }
198
199
  context = await this.pipeline.runHook('beforeProvider', context);
199
200
  const response = await this.pipeline.trace(context, 'providerCall', () => {
200
- return this.failover.complete(context.request, decision, this.providers, this.executionOptions(ticket.context));
201
+ return this.failover.complete(context.request, decision, this.providers, this.executionOptions(ticket.context, ticket));
201
202
  });
202
203
  context.response = response;
203
204
  context = await this.pipeline.runHook('afterProvider', context);
@@ -638,8 +639,43 @@ export class NexusAI {
638
639
  ...(this.config.auditLog?.includeInput ? { audit: { request } } : {}),
639
640
  };
640
641
  }
641
- executionOptions(context) {
642
- const { requestId, traceContext, idempotencyKey } = context;
642
+ placeRoute(decision, residency) {
643
+ if (!residency)
644
+ return decision;
645
+ const attempts = [
646
+ { providerName: decision.providerName, model: decision.model, ...decision.primary },
647
+ ...decision.fallbacks,
648
+ ];
649
+ const placed = [];
650
+ const seen = new Set();
651
+ for (const attempt of attempts) {
652
+ const where = residency.place(attempt.providerName);
653
+ if (!where || !this.providers.has(where.provider))
654
+ continue;
655
+ const key = `${where.provider}\u0000${attempt.model}`;
656
+ if (seen.has(key))
657
+ continue;
658
+ seen.add(key);
659
+ placed.push({ ...attempt, providerName: where.provider });
660
+ }
661
+ const [first, ...rest] = placed;
662
+ if (!first) {
663
+ residency.check(decision.providerName);
664
+ throw new ResidencyError({ kind: 'provider', name: decision.providerName }, residency.decision, 'its twin is not registered');
665
+ }
666
+ const { providerName, model, ...limits } = first;
667
+ return {
668
+ providerName,
669
+ model,
670
+ reason: providerName === decision.providerName
671
+ ? decision.reason
672
+ : `${decision.reason}; sent to ${providerName} inside ${residency.decision.rule}`,
673
+ fallbacks: rest,
674
+ ...(Object.keys(limits).length > 0 ? { primary: limits } : {}),
675
+ };
676
+ }
677
+ executionOptions(context, ticket) {
678
+ const { requestId, traceContext, idempotencyKey, tenantId, credential } = context;
643
679
  return {
644
680
  timeoutMs: this.config.timeout,
645
681
  retry: this.config.retry,
@@ -648,7 +684,13 @@ export class NexusAI {
648
684
  requestId,
649
685
  ...(traceContext ? { traceContext } : {}),
650
686
  ...(idempotencyKey ? { idempotencyKey } : {}),
687
+ ...(tenantId !== undefined ? { tenantId } : {}),
688
+ ...(credential ? { credential } : {}),
651
689
  },
690
+ ...(ticket?.refreshCredential && credential
691
+ ? { refreshCredential: (provider) => ticket.refreshCredential?.(provider) ?? Promise.resolve(false) }
692
+ : {}),
693
+ ...(ticket?.scrub && credential ? { scrub: (error) => ticket.scrub?.(error) ?? error } : {}),
652
694
  };
653
695
  }
654
696
  lifecycleStep(context, name, fn) {
@@ -721,7 +763,7 @@ export class NexusAI {
721
763
  : { ok: true, value: optimizationResult.value, findings: [], guardrailsApplied: [] };
722
764
  if (this.isSecurityEnabled())
723
765
  this.security.assertSafe(securityResult);
724
- const decision = this.router.route(securityResult.value, this.config, this.providers, this.health.snapshot(), this.circuitBreaker.openProviders());
766
+ const decision = this.placeRoute(this.router.route(securityResult.value, this.config, this.providers, this.health.snapshot(), this.circuitBreaker.openProviders()), ticket.context.residency);
725
767
  this.logger.info('stream route decision', {
726
768
  ...decision,
727
769
  tokensSaved: optimizationResult.usage.savedTokens,
@@ -740,7 +782,7 @@ export class NexusAI {
740
782
  if (shared)
741
783
  await ticket.reserve(estimate.totalCost);
742
784
  }
743
- const stream = this.failover.stream(routedRequest, decision, this.providers, this.executionOptions(ticket.context));
785
+ const stream = this.failover.stream(routedRequest, decision, this.providers, this.executionOptions(ticket.context, ticket));
744
786
  return {
745
787
  stream: this.isSecurityEnabled() ? protectStreamOutput(stream, this.security, routedRequest.signal) : stream,
746
788
  request: routedRequest,
@@ -785,7 +827,11 @@ export class NexusAI {
785
827
  contextWindowSummary: true,
786
828
  },
787
829
  };
788
- const decision = this.router.route(summaryRequest, this.config, this.providers, this.health.snapshot());
830
+ const policy = this.config.lifecycle?.residency;
831
+ const routed = this.router.route(summaryRequest, this.config, this.providers, this.health.snapshot());
832
+ const decision = policy
833
+ ? this.placeRoute(routed, callResidency(policy, await policy.regionsFor(summaryRequest.tenantId)))
834
+ : routed;
789
835
  const routedRequest = { ...summaryRequest, model: resolveModel(decision.model, this.config).model };
790
836
  const response = await this.lifecycle.run({ ...this.describe(routedRequest, 'summarize'), provider: decision.providerName }, {
791
837
  execute: (callContext) => this.failover.complete(routedRequest, decision, this.providers, this.executionOptions(callContext)),