@omnicross/core 0.1.2 → 0.1.3

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 (131) hide show
  1. package/dist/auth/GeminiCodeAssistProjectResolver.cjs +6 -1
  2. package/dist/auth/GeminiCodeAssistProjectResolver.js +6 -1
  3. package/dist/{chunk-SN3YWBX7.cjs → chunk-2FMI2B6V.cjs} +2329 -533
  4. package/dist/{chunk-XBSYYZIY.cjs → chunk-3QOSDDNI.cjs} +39 -3
  5. package/dist/{chunk-2FEVTJWG.js → chunk-4DECIFU7.js} +2162 -366
  6. package/dist/chunk-4IH4EL7M.cjs +9 -0
  7. package/dist/chunk-5ENKBSWO.js +311 -0
  8. package/dist/chunk-6RPZADX3.cjs +92 -0
  9. package/dist/chunk-AEOZIDEB.cjs +32 -0
  10. package/dist/chunk-BO4RFWBR.js +89 -0
  11. package/dist/{chunk-MNYKI4CI.js → chunk-CWM2ZDU5.js} +36 -0
  12. package/dist/chunk-CZHZGRU7.js +383 -0
  13. package/dist/{chunk-4NBS6KPV.cjs → chunk-DQ5VAYZ6.cjs} +7 -8
  14. package/dist/chunk-HUZZZ3JL.js +32 -0
  15. package/dist/{chunk-QOCNX236.js → chunk-IAXWICZU.js} +24 -1
  16. package/dist/{chunk-V5KPWNYX.cjs → chunk-JMJBSACD.cjs} +3 -3
  17. package/dist/chunk-L74VDN4A.cjs +32 -0
  18. package/dist/{chunk-74TMJA7Z.js → chunk-N5QFKSDO.js} +7 -4
  19. package/dist/{chunk-LKZJEL6E.cjs → chunk-Q27JY5RG.cjs} +6 -4
  20. package/dist/{chunk-SPVWWUHX.cjs → chunk-QWBWGODS.cjs} +24 -1
  21. package/dist/chunk-RPII3E6Z.cjs +145 -0
  22. package/dist/chunk-S6JGLF3G.cjs +89 -0
  23. package/dist/chunk-SVXC36MU.cjs +383 -0
  24. package/dist/chunk-TKJMZBAH.js +32 -0
  25. package/dist/{chunk-ZSVQT3PW.js → chunk-UBWEBKRC.js} +5 -6
  26. package/dist/chunk-UNEFIWXI.cjs +32 -0
  27. package/dist/chunk-VQY5W4YW.cjs +311 -0
  28. package/dist/chunk-WNY4WMRF.js +32 -0
  29. package/dist/chunk-XEKD23J5.js +9 -0
  30. package/dist/chunk-XTEI64OU.js +145 -0
  31. package/dist/chunk-XX6NQJMA.js +92 -0
  32. package/dist/{chunk-N3V2J5ZO.cjs → chunk-Y6OM7EV7.cjs} +7 -4
  33. package/dist/{chunk-745DV5FL.js → chunk-Y7FO65VT.js} +3 -3
  34. package/dist/{chunk-PXUJF5HS.js → chunk-ZY6KU6P3.js} +4 -2
  35. package/dist/completion/CompletionService.cjs +22 -15
  36. package/dist/completion/CompletionService.js +21 -14
  37. package/dist/completion/NativeSearchInjector.cjs +3 -3
  38. package/dist/completion/NativeSearchInjector.js +2 -2
  39. package/dist/completion/openrouter-headers.cjs +3 -6
  40. package/dist/completion/openrouter-headers.d.cts +9 -12
  41. package/dist/completion/openrouter-headers.d.ts +9 -12
  42. package/dist/completion/openrouter-headers.js +2 -5
  43. package/dist/completion/openrouter-models.cjs +4 -3
  44. package/dist/completion/openrouter-models.js +2 -1
  45. package/dist/completion.cjs +22 -15
  46. package/dist/completion.js +21 -14
  47. package/dist/index.cjs +139 -17
  48. package/dist/index.d.cts +40 -10
  49. package/dist/index.d.ts +40 -10
  50. package/dist/index.js +146 -24
  51. package/dist/outbound-api/routeResolver.cjs +5 -2
  52. package/dist/outbound-api/routeResolver.d.cts +8 -2
  53. package/dist/outbound-api/routeResolver.d.ts +8 -2
  54. package/dist/outbound-api/routeResolver.js +4 -1
  55. package/dist/outbound-api/types.cjs +6 -1
  56. package/dist/outbound-api/types.d.cts +11 -144
  57. package/dist/outbound-api/types.d.ts +11 -144
  58. package/dist/outbound-api/types.js +6 -0
  59. package/dist/outbound-api.cjs +133 -15
  60. package/dist/outbound-api.d.cts +786 -25
  61. package/dist/outbound-api.d.ts +786 -25
  62. package/dist/outbound-api.js +137 -19
  63. package/dist/pipeline/AuthSource.d.cts +23 -1
  64. package/dist/pipeline/AuthSource.d.ts +23 -1
  65. package/dist/pipeline/LlmConfigProviderAuth.cjs +3 -3
  66. package/dist/pipeline/LlmConfigProviderAuth.js +2 -2
  67. package/dist/pipeline/SubscriptionAccountHealth.cjs +22 -0
  68. package/dist/pipeline/SubscriptionAccountHealth.d.cts +235 -0
  69. package/dist/pipeline/SubscriptionAccountHealth.d.ts +235 -0
  70. package/dist/pipeline/SubscriptionAccountHealth.js +22 -0
  71. package/dist/pipeline/SubscriptionAuthSource.cjs +2 -2
  72. package/dist/pipeline/SubscriptionAuthSource.d.cts +3 -2
  73. package/dist/pipeline/SubscriptionAuthSource.d.ts +3 -2
  74. package/dist/pipeline/SubscriptionAuthSource.js +1 -1
  75. package/dist/pipeline/SubscriptionAuthStrategy.d.cts +24 -1
  76. package/dist/pipeline/SubscriptionAuthStrategy.d.ts +24 -1
  77. package/dist/pipeline/auditSink.cjs +14 -0
  78. package/dist/pipeline/auditSink.d.cts +48 -0
  79. package/dist/pipeline/auditSink.d.ts +48 -0
  80. package/dist/pipeline/auditSink.js +14 -0
  81. package/dist/pipeline/billingEmit.cjs +14 -0
  82. package/dist/pipeline/billingEmit.d.cts +49 -0
  83. package/dist/pipeline/billingEmit.d.ts +49 -0
  84. package/dist/pipeline/billingEmit.js +14 -0
  85. package/dist/ports/usage-event-store.d.cts +26 -1
  86. package/dist/ports/usage-event-store.d.ts +26 -1
  87. package/dist/ports.d.cts +7 -1
  88. package/dist/ports.d.ts +7 -1
  89. package/dist/provider-proxy/ProviderProxy.cjs +22 -15
  90. package/dist/provider-proxy/ProviderProxy.js +21 -14
  91. package/dist/provider-proxy/identity/SubscriptionIdentityStore.cjs +15 -0
  92. package/dist/provider-proxy/identity/SubscriptionIdentityStore.d.cts +121 -0
  93. package/dist/provider-proxy/identity/SubscriptionIdentityStore.d.ts +121 -0
  94. package/dist/provider-proxy/identity/SubscriptionIdentityStore.js +15 -0
  95. package/dist/provider-proxy/identity/fingerprintHeaders.cjs +20 -0
  96. package/dist/provider-proxy/identity/fingerprintHeaders.d.cts +89 -0
  97. package/dist/provider-proxy/identity/fingerprintHeaders.d.ts +89 -0
  98. package/dist/provider-proxy/identity/fingerprintHeaders.js +20 -0
  99. package/dist/provider-proxy/ingress/providerProxyShared.cjs +22 -15
  100. package/dist/provider-proxy/ingress/providerProxyShared.d.cts +11 -2
  101. package/dist/provider-proxy/ingress/providerProxyShared.d.ts +11 -2
  102. package/dist/provider-proxy/ingress/providerProxyShared.js +21 -14
  103. package/dist/provider-proxy/matchText.cjs +4 -2
  104. package/dist/provider-proxy/matchText.d.cts +14 -1
  105. package/dist/provider-proxy/matchText.d.ts +14 -1
  106. package/dist/provider-proxy/matchText.js +3 -1
  107. package/dist/provider-proxy/types.d.cts +23 -0
  108. package/dist/provider-proxy/types.d.ts +23 -0
  109. package/dist/provider-proxy.cjs +22 -15
  110. package/dist/provider-proxy.js +21 -14
  111. package/dist/{routeResolver-HE-ZO0fO.d.ts → routeResolver-B1NglTfe.d.ts} +36 -10
  112. package/dist/{routeResolver-BrbK6ja9.d.cts → routeResolver-CbG5uBgm.d.cts} +36 -10
  113. package/dist/transformer/transformers.cjs +7 -7
  114. package/dist/transformer/transformers.js +7 -7
  115. package/dist/transformer.cjs +5 -5
  116. package/dist/transformer.js +4 -4
  117. package/dist/types-6OUY_xfA.d.ts +830 -0
  118. package/dist/types-aBFFXoBz.d.cts +830 -0
  119. package/dist/usage/usage-recorder.cjs +2 -2
  120. package/dist/usage/usage-recorder.d.cts +19 -1
  121. package/dist/usage/usage-recorder.d.ts +19 -1
  122. package/dist/usage/usage-recorder.js +1 -1
  123. package/dist/usage.cjs +2 -2
  124. package/dist/usage.js +1 -1
  125. package/package.json +4 -2
  126. package/dist/chunk-3MEACFK3.js +0 -193
  127. package/dist/chunk-6VIXXLMX.cjs +0 -14
  128. package/dist/chunk-E3WHL7CO.js +0 -14
  129. package/dist/chunk-G2FUJNA2.cjs +0 -193
  130. package/dist/{chunk-UYPEN5XE.cjs → chunk-FFALUUZZ.cjs} +5 -5
  131. package/dist/{chunk-O466Y272.js → chunk-MQNDWYPN.js} +6 -6
@@ -1,24 +1,94 @@
1
- import { EndpointRoutingConfig, OutboundApiDeps, OutboundApiServerStatus, OutboundFormatUrls, OutboundApiServerConfig, OutboundKeyDb, OutboundApiKeyCreated, RequestRole } from './outbound-api/types.cjs';
2
- export { OutboundApiKeyInfo, OutboundEndpoint, OutboundKeyDbRow } from './outbound-api/types.cjs';
1
+ import { VoucherConfig } from '@omnicross/contracts/voucher-types';
2
+ import { i as KindMappedEndpoint, j as ModelKind, q as OutboundEndpoint, b as EndpointRoutingConfig, o as OutboundApiServerConfig, U as UserMessageQueueConfig, a as ConcurrencyQueueConfig, l as OutboundApiDeps, p as OutboundApiServerStatus, r as OutboundFormatUrls, A as AccountProbeConfig, F as FingerprintConfig, k as ModelPrefixTargets, v as OutboundProxyConfig, P as ModelRef, K as KeyCostLimits, S as ModelRestriction, O as OutboundKeyDb, m as OutboundApiKeyCreated, R as RequestRole } from './types-aBFFXoBz.cjs';
3
+ export { C as ChatDispatchMode, E as ENDPOINT_MODEL_KINDS, c as KeyExpiryInput, d as KeyExpiryResult, e as KeySpend, f as KeySpendReader, g as KeySpendSeeder, h as KeySpendTracker, M as MessagesModelKind, n as OutboundApiKeyInfo, s as OutboundKeyActivationMode, t as OutboundKeyDbRow, u as OutboundKeyPolicy, Q as QuotaDecision, w as ResponsesModelKind, V as VoucherCreateInput, x as VoucherDb, y as checkKeyQuota, z as computeKeyExpiry, B as computeVoucherGrant, D as generateVoucherCode, G as hashVoucherCode, H as newVoucherId, I as startOfLocalDay, J as startOfLocalWeek, L as toVoucherInfo, N as voucherCodePrefix } from './types-aBFFXoBz.cjs';
4
+ import { ProxyConfig } from '@omnicross/contracts/account-tokens-types';
5
+ import { AuditConfig } from '@omnicross/contracts/audit-types';
6
+ import { BillingConfig } from '@omnicross/contracts/billing-types';
7
+ import { WebhookDestination, WebhookConfig } from '@omnicross/contracts/webhook-types';
8
+ import http from 'node:http';
3
9
  import { IngressFormat } from './provider-proxy/types.cjs';
4
- export { S as SUBSCRIPTION_PROVIDER_IDS, e as endpointSupportsSubscription, i as isSubscriptionProviderId, r as resolveRoute } from './routeResolver-BrbK6ja9.cjs';
10
+ export { S as SUBSCRIPTION_PROVIDER_IDS, e as endpointSupportsSubscription, i as isSubscriptionProviderId, p as parseModelRef, a as pickModelRefFromList, r as resolveRoute } from './routeResolver-CbG5uBgm.cjs';
11
+ import '@omnicross/contracts/health-logging-types';
12
+ import './logger-4GvQNzhE.cjs';
5
13
  import './ports/provider-config-source.cjs';
6
14
  import '@omnicross/contracts/llm-config';
7
15
  import './transformer/types.cjs';
8
16
  import './transformer/TransformerService.cjs';
9
17
  import './ProviderProxy-CnMQYN59.cjs';
10
- import 'node:http';
11
18
  import '@omnicross/contracts/completion-types';
12
19
  import '@omnicross/contracts/subscription-types';
13
20
  import '@omnicross/contracts/usage-types';
14
21
  import './completion/ApiKeyPoolService.cjs';
15
- import './logger-4GvQNzhE.cjs';
16
22
  import './pipeline/AuthSource.cjs';
17
23
  import './pipeline/SubscriptionAuthSource.cjs';
18
24
  import './pipeline/SubscriptionAuthStrategy.cjs';
19
25
  import './ports/web-search-backend.cjs';
20
26
  import '@omnicross/contracts/websearch-types';
21
27
 
28
+ /**
29
+ * kindDetection — classify an outbound request's model KIND for the kind-mapped
30
+ * endpoints (`messages`/`responses`) and validate their config completeness
31
+ * (`outbound-api-server`, model-kind-mapping contract).
32
+ *
33
+ * The kind-mapped endpoints route by a version-INDEPENDENT model KIND rather
34
+ * than by role: the user configures one upstream ref per kind
35
+ * ({@link ENDPOINT_MODEL_KINDS}) and an incoming versioned client id
36
+ * (`claude-opus-4-8-2026xxxx`) is classified to its kind (`opus`) so CLI
37
+ * upgrades need no reconfig. `chat`/`gemini` stay on `roleDetection`.
38
+ *
39
+ * Pure module — no I/O. The router decides, per endpoint, whether to call
40
+ * `detectModelKind` (messages/responses) or `detectRequestRole` (chat/gemini);
41
+ * the routing RESOLUTION and the startup-gate ENFORCEMENT live downstream.
42
+ *
43
+ * @module outbound-api/kindDetection
44
+ */
45
+
46
+ /**
47
+ * Narrow an endpoint to the kind-mapped set (`messages`/`responses`). `chat`
48
+ * and `gemini` are role-based and return false.
49
+ */
50
+ declare function isKindMappedEndpoint(endpoint: OutboundEndpoint): endpoint is KindMappedEndpoint;
51
+ /** The canonical kinds for a kind-mapped endpoint (in declaration order). */
52
+ declare function modelKindsForEndpoint(endpoint: KindMappedEndpoint): readonly ModelKind[];
53
+ /**
54
+ * Extract the version-INDEPENDENT model KIND for a kind-mapped endpoint.
55
+ * - `messages`: the FIRST id token that is one of {fable,opus,sonnet,haiku};
56
+ * `undefined` when no token matches (the unmatched-kind fallback is SERVING's
57
+ * decision, not core's).
58
+ * - `responses`: `mini` when {@link isBackgroundTierModel}; else `codex`
59
+ * (codex is the else-branch — a present id always resolves).
60
+ * - Any empty/blank id ⇒ `undefined`.
61
+ *
62
+ * Reuses `normalizeModelId` + the shared `/[-._:/\s]+/` tokenizer
63
+ * (`modelTokens`) + the small-tier token set (`isBackgroundTierModel`) so the
64
+ * kind and role detectors stay token-boundary-consistent.
65
+ */
66
+ declare function detectModelKind(endpoint: KindMappedEndpoint, requestedModelId: string | undefined): ModelKind | undefined;
67
+ /**
68
+ * The kinds THIS endpoint declares but leaves unconfigured (blank/absent ref).
69
+ * Role-based endpoints (`chat`/`gemini`) have no declared kinds → `[]`.
70
+ * Absent and blank refs are treated identically; a malformed-but-non-blank ref
71
+ * is a serving-time concern, not a startup-gate one.
72
+ */
73
+ declare function validateEndpointModelConfig(config: EndpointRoutingConfig): ModelKind[];
74
+ /** One incomplete kind-mapped endpoint and the kinds it is missing. */
75
+ interface EndpointModelConfigError {
76
+ endpoint: KindMappedEndpoint;
77
+ missingKinds: ModelKind[];
78
+ }
79
+ /**
80
+ * Server-level completeness, PER-ENDPOINT: a kind-mapped endpoint whose
81
+ * declared kinds are ALL blank/absent counts as UNCONFIGURED — the operator
82
+ * simply doesn't use that endpoint, so it does NOT block startup (its requests
83
+ * 503 per-request instead). Only a PARTIALLY configured endpoint (some kinds
84
+ * set, some blank — a real config mistake that would silently misroute) is
85
+ * returned as an error. Empty result ⇒ the config satisfies the startup gate.
86
+ *
87
+ * Serving consumes this for the gate ENFORCEMENT; it MAY tighten to an
88
+ * all-endpoints-strict policy by composing {@link validateEndpointModelConfig}.
89
+ */
90
+ declare function validateServerModelConfig(config: OutboundApiServerConfig): EndpointModelConfigError[];
91
+
22
92
  /**
23
93
  * OutboundApiServer — the external-facing HTTP listener for the outbound API
24
94
  * server (`outbound-api-server`, design D1/D4/D5).
@@ -51,6 +121,24 @@ interface ApplyConfigInput {
51
121
  networkBinding: boolean;
52
122
  endpoints: EndpointRoutingConfig[];
53
123
  port?: number;
124
+ /** User-message serial-queue segment (normalized/defaulted by core). */
125
+ userMessageQueue?: UserMessageQueueConfig;
126
+ /** Per-key concurrency-queue segment (normalized/defaulted by core). */
127
+ concurrencyQueue?: ConcurrencyQueueConfig;
128
+ /** Voucher segment (voucher-redemption #9). Read live per request; absent ⇒
129
+ * disabled ⇒ the `/redeem` endpoint is inert (zero regression). */
130
+ voucher?: VoucherConfig;
131
+ }
132
+ /**
133
+ * Thrown by {@link OutboundApiServer.applyConfig} when an ENABLED server is asked
134
+ * to bind with an INCOMPLETE model-kind map (the "未配置 → 无法启动接口服务" gate,
135
+ * design D6). Carries the per-endpoint missing kinds so the daemon/UI can render
136
+ * an actionable message; the server does NOT bind. Exported from the barrel so
137
+ * the daemon (surface) can `instanceof`-narrow it.
138
+ */
139
+ declare class OutboundApiConfigError extends Error {
140
+ readonly missing: EndpointModelConfigError[];
141
+ constructor(missing: EndpointModelConfigError[]);
54
142
  }
55
143
  declare class OutboundApiServer {
56
144
  private readonly deps;
@@ -60,7 +148,25 @@ declare class OutboundApiServer {
60
148
  private boundPort;
61
149
  private boundAddr;
62
150
  private endpoints;
151
+ private userMessageQueue;
152
+ private concurrencyQueue;
153
+ private voucherConfig;
63
154
  private readonly rateLimiter;
155
+ /**
156
+ * Redeem-attempt limiter (voucher-redemption #9, design D6) — a SEPARATE bucket
157
+ * from the traffic `rateLimiter`, keyed by the authenticating key id, so
158
+ * brute-forcing `CC_` codes is throttled (a handful/min) without touching the
159
+ * per-key request rate. Conservative fixed defaults (10 / 60s).
160
+ */
161
+ private readonly redeemLimiter;
162
+ /**
163
+ * Per-key redeem mutex (voucher-redemption #9, MJ1 fix). One instance for the
164
+ * server's lifetime so concurrent redeem REQUESTS for the same key serialize
165
+ * (relative grant increments accumulate instead of clobbering a snapshot).
166
+ */
167
+ private readonly redeemMutex;
168
+ private readonly serialQueue;
169
+ private readonly concurrencyGate;
64
170
  constructor(deps: OutboundApiDeps,
65
171
  /** Called when the actual bound port differs from the requested one. */
66
172
  onPortChange?: ((port: number) => void) | undefined);
@@ -76,10 +182,37 @@ declare class OutboundApiServer {
76
182
  private listen;
77
183
  /** Per-request handler. Auth is enforced on EVERY request (incl. loopback). */
78
184
  private onRequest;
185
+ /**
186
+ * Serve `GET|HEAD /health` (+ `/healthz`) from the injected provider, returning
187
+ * true when it handled the request. 200 when `ok`, else 503; secret-free body.
188
+ */
189
+ private tryServeHealth;
190
+ /** Route an info lifecycle line through the injected logger, else `console.log`
191
+ * (byte-identical legacy fallback when no logger is wired). */
192
+ private logInfo;
193
+ /** Route an error lifecycle line through the injected logger, else `console.error`. */
194
+ private logError;
79
195
  /** Stop the listener and release the port. */
80
196
  stop(): Promise<void>;
81
197
  /** A live status snapshot for the Settings tab. */
82
198
  getStatus(): OutboundApiServerStatus;
199
+ /**
200
+ * Live queue-occupancy snapshot (only active entries). This getter's name +
201
+ * shape are FROZEN — `omnicross-uqc-daemon` spreads it into its `/status`
202
+ * response; the existing {@link getStatus} shape is deliberately NOT changed.
203
+ */
204
+ getQueueStatus(): {
205
+ serial: Array<{
206
+ providerId: string;
207
+ holding: boolean;
208
+ waiting: number;
209
+ }>;
210
+ concurrency: Array<{
211
+ apiKeyId: string;
212
+ active: number;
213
+ waiting: number;
214
+ }>;
215
+ };
83
216
  }
84
217
  /** Build the four format endpoint URLs for a base URL. */
85
218
  declare function formatUrls(base: string): OutboundFormatUrls;
@@ -90,20 +223,137 @@ declare function formatUrls(base: string): OutboundFormatUrls;
90
223
  *
91
224
  * The config (`{ enabled, networkBinding, endpoints, port }`) is persisted via
92
225
  * a small key/value store (the app SettingsService) under a single key, so it
93
- * survives restart. Defaults: disabled, loopback, four endpoints with empty
94
- * models + `useSubscription` OFF, default port. Shared by the router and the
95
- * bootstrap wiring so both read/write the same shape.
226
+ * survives restart. Defaults: disabled, loopback, four blank endpoints +
227
+ * `useSubscription` OFF, default port. The per-endpoint shape is heterogeneous:
228
+ * kind-mapped endpoints (`messages`/`responses`) carry a blank `modelMap` (one
229
+ * key per declared kind); role-based endpoints (`chat`/`gemini`) carry blank
230
+ * `defaultModel`/`backgroundModel`. NO legacy migration — `normalizeServerConfig`
231
+ * drops unknown/legacy fields (incl. `visionModel`) and fills blanks. Shared by
232
+ * the router and the bootstrap wiring so both read/write the same shape.
96
233
  *
97
234
  * @module outbound-api/apiServerConfig
98
235
  */
99
236
 
100
237
  /** The settings key the config persists under. */
101
238
  declare const OUTBOUND_API_SERVER_CONFIG_KEY = "outboundApiServer.config";
239
+ /**
240
+ * Frozen defaults for the user-message serial queue segment (SSOT). Note the
241
+ * `waitTimeoutMs` default is **60000** — the office-hours draft's 30000 is
242
+ * superseded by the user's拍板 / planning-context §COMMITTED.
243
+ */
244
+ declare const DEFAULT_USER_MESSAGE_QUEUE: UserMessageQueueConfig;
245
+ /** Frozen defaults for the per-key concurrency queue segment (SSOT). */
246
+ declare const DEFAULT_CONCURRENCY_QUEUE: ConcurrencyQueueConfig;
247
+ /**
248
+ * Frozen defaults for the scheduled account-probe segment (SSOT,
249
+ * subscription-account-probe #8). Default OFF (zero regression); a 15-min cadence,
250
+ * multi-account-only, short timeout, small rolling history, staggered — every knob
251
+ * a load-safety valve (see `AccountProbeConfig`).
252
+ */
253
+ declare const DEFAULT_ACCOUNT_PROBE: AccountProbeConfig;
254
+ /** Fill + range-CLAMP the account-probe segment to the frozen defaults. */
255
+ declare function normalizeAccountProbe(raw: Partial<OutboundApiServerConfig> | undefined | null): AccountProbeConfig;
256
+ /**
257
+ * Fill + range-CLAMP the request-audit segment to the frozen defaults
258
+ * (request-audit-log, design D2). Lenient like the other segment normalizers:
259
+ * `enabled`/`captureBodies`/`trustForwardedFor` coerce to booleans (default
260
+ * false), `maxBodyBytes` clamps to `[256, 1_048_576]`, `retentionDays` clamps to
261
+ * `[1, 365]`. Default (all-off) ⇒ no capture ⇒ zero regression.
262
+ */
263
+ declare function normalizeAudit(raw: Partial<OutboundApiServerConfig> | undefined | null): AuditConfig;
264
+ /**
265
+ * Fill + range-CLAMP the billing segment to the frozen defaults
266
+ * (billing-event-stream, design D6). Lenient like `normalizeAudit`: `enabled`
267
+ * coerces to a boolean (default false); `endpoint`/`secret` are carried only when
268
+ * non-empty strings (a blank/absent `endpoint` ⇒ ledger-only mode); `maxRetryAgeMs`
269
+ * clamps to `[60_000, 30 days]`. Default (off) ⇒ no publish ⇒ zero regression.
270
+ */
271
+ declare function normalizeBilling(raw: Partial<OutboundApiServerConfig> | undefined | null): BillingConfig;
272
+ /**
273
+ * Frozen defaults for the client-fingerprint segment (SSOT,
274
+ * subscription-client-fingerprint #7). Default OFF ⇒ no capture/replay ⇒
275
+ * byte-identical outbound headers.
276
+ */
277
+ declare const DEFAULT_FINGERPRINT: FingerprintConfig;
278
+ /**
279
+ * Fill the client-fingerprint segment to the frozen defaults. Lenient like the
280
+ * other segment normalizers: `enabled` coerces to a boolean (default false); `ua`
281
+ * is carried only when a non-empty trimmed string (a blank/absent baseline stays
282
+ * absent). Default (off) ⇒ zero regression. Carries NO secret.
283
+ */
284
+ declare function normalizeFingerprint(raw: Partial<OutboundApiServerConfig> | undefined | null): FingerprintConfig;
285
+ /**
286
+ * Fill the voucher segment to the frozen defaults (voucher-redemption #9). Lenient
287
+ * like the other segment normalizers: `enabled` coerces to a boolean (default
288
+ * false). Default (off) ⇒ the redeem endpoint is inert ⇒ zero regression. Carries
289
+ * NO secret (codes are hashed at rest in the separate voucher store).
290
+ */
291
+ declare function normalizeVoucher(raw: Partial<OutboundApiServerConfig> | undefined | null): VoucherConfig;
292
+ /**
293
+ * Validate ONE `ProxyConfig` (upstream-proxy). Returns the cleaned descriptor or
294
+ * `undefined` (drop) when malformed. Lenient like the other segment normalizers:
295
+ * - `{ url }` — a non-empty string URL (trimmed).
296
+ * - structured — `type` ∈ {http,https,socks5} + non-empty `host` + a
297
+ * finite integer `port` in `1..65535`. `username`/`password`
298
+ * are non-empty-string-or-omit (may be `enc:`/`$ENV` at load
299
+ * — the secret box decrypts afterwards).
300
+ * Never throws.
301
+ */
302
+ declare function normalizeProxyConfig(raw: unknown): ProxyConfig | undefined;
303
+ /**
304
+ * Validate the optional `proxy` segment (upstream-proxy). Drops malformed entries
305
+ * (a bad `global` or a bad `byProvider[*]` value/key is dropped, never thrown).
306
+ * Returns `undefined` when nothing valid remains — a missing/empty proxy segment
307
+ * stays ABSENT (zero-config = direct fetch; unlike `accountHealth`, no default is
308
+ * synthesized).
309
+ */
310
+ declare function normalizeProxySegment(raw: unknown): OutboundProxyConfig | undefined;
311
+ /**
312
+ * Validate ONE webhook destination (webhook-notifications). Returns the cleaned
313
+ * descriptor or `undefined` (drop) when malformed — lenient like the proxy
314
+ * normalizer, never throws:
315
+ * - `id` — a non-empty trimmed string.
316
+ * - `type` — ∈ {custom, feishu}.
317
+ * - `url` — a non-empty trimmed string.
318
+ * - `secret` — non-empty-string-or-omit (may be `enc:`/`$ENV` at load — the
319
+ * settings-store secret box decrypts afterwards).
320
+ * - `events` — kept only when a non-empty array of known kinds (unknown kinds
321
+ * dropped); an absent/empty filter means "all kinds".
322
+ * - `enabled` — coerced boolean (default true — a destination in the list is on
323
+ * unless explicitly disabled).
324
+ */
325
+ declare function normalizeWebhookDestination(raw: unknown): WebhookDestination | undefined;
326
+ /**
327
+ * Validate the optional `webhook` segment (webhook-notifications). Drops
328
+ * malformed destinations; `enabled` defaults false. Returns `undefined` when the
329
+ * segment is absent/non-object — a missing webhook segment stays ABSENT (no sink
330
+ * wired ⇒ zero regression), unlike `accountHealth` no default is synthesized. A
331
+ * present segment with `enabled` present OR any valid destination is kept.
332
+ */
333
+ declare function normalizeWebhookSegment(raw: unknown): WebhookConfig | undefined;
334
+ /**
335
+ * Fill + range-CLAMP the two queue segments to the frozen defaults. Lenient:
336
+ * out-of-range persisted numerics are clamped to the nearest bound, never
337
+ * thrown — strict validation is the daemon admin PUT's job. `enabled` coerces
338
+ * to a boolean (default false).
339
+ */
340
+ declare function normalizeQueueSegments(raw: Partial<OutboundApiServerConfig> | undefined | null): {
341
+ userMessageQueue: UserMessageQueueConfig;
342
+ concurrencyQueue: ConcurrencyQueueConfig;
343
+ };
102
344
  /** Structural subset of the settings store the config loader needs. */
103
345
  interface ApiServerSettingsStore {
104
346
  get<T = unknown>(key: string): Promise<T | undefined>;
105
347
  set<T = unknown>(key: string, value: T): Promise<void>;
106
348
  }
349
+ /**
350
+ * Validate the optional `chat` prefix-target map (openai-chat-bridge #11). Keeps
351
+ * ONLY the three known prefixes (`claude`/`gpt`/`gemini`) whose value is a
352
+ * non-empty trimmed `"providerId,modelId"` string; drops everything else. Returns
353
+ * `undefined` when nothing valid remains (a prefix-mode chat endpoint with no
354
+ * targets simply routes nothing until configured). Never throws.
355
+ */
356
+ declare function normalizePrefixTargets(raw: unknown): ModelPrefixTargets | undefined;
107
357
  /** The default server config: disabled, loopback, four blank endpoints. */
108
358
  declare function defaultServerConfig(): OutboundApiServerConfig;
109
359
  /**
@@ -119,6 +369,391 @@ declare function saveServerConfig(store: ApiServerSettingsStore, config: Outboun
119
369
  /** Apply a partial patch to a config, returning the merged whole. */
120
370
  declare function mergeServerConfig(current: OutboundApiServerConfig, patch: Partial<OutboundApiServerConfig>): OutboundApiServerConfig;
121
371
 
372
+ /**
373
+ * auditRedact — the pure secret-redaction pass for captured audit bodies
374
+ * (request-audit-log, design D3/D7).
375
+ *
376
+ * Body capture is opt-in and inherently sensitive: a prompt or tool argument can
377
+ * embed a secret. {@link redactAuditText} is run over EVERY captured body (and
378
+ * any other free text stored in a record) BEFORE it is persisted — defense in
379
+ * depth on top of "request headers are NEVER captured" (so an Authorization /
380
+ * x-api-key HEADER value never enters a record in the first place).
381
+ *
382
+ * It masks the obvious, high-confidence secret shapes ONLY (frozen list) — never
383
+ * a broad heuristic that would gut a legitimate prompt:
384
+ * - `sk-…` API keys (covers `sk-ant-…`, `sk-omnicross-…`, `sk-proj-…`, OpenAI).
385
+ * - `Bearer <token>` authorization values.
386
+ * - `x-api-key` / `api-key` / `authorization` values written inline as text.
387
+ * - Google `AIza…` API keys.
388
+ *
389
+ * Pure + allocation-light + never throws.
390
+ *
391
+ * @module @omnicross/core/outbound-api/auditRedact
392
+ */
393
+ /** The sentinel a masked secret is replaced with. */
394
+ declare const AUDIT_REDACTED = "***REDACTED***";
395
+ /**
396
+ * Mask obvious secrets in `text`. Pure; returns the input unchanged when it holds
397
+ * no recognized secret shape. Never throws (a bad input returns `''`).
398
+ */
399
+ declare function redactAuditText(text: string): string;
400
+
401
+ /**
402
+ * auditCapture — the outbound server's post-response audit capture hook
403
+ * (request-audit-log, design D1/D3/D5).
404
+ *
405
+ * {@link beginAuditCapture} is called at the TOP of `handleOutboundRequest`. It
406
+ * reads the active capture config from the core slot; when audit is DISABLED it
407
+ * returns `null` immediately (one slot read — no assembly, no listener, zero
408
+ * regression). When enabled it:
409
+ * - registers a one-shot `res.on('close')` listener that, at response end,
410
+ * assembles the {@link AuditRecord} from the http `req` (IP via the socket, or
411
+ * a trusted `X-Forwarded-For` only when configured — LEAD OQ1 anti-spoof; UA;
412
+ * method; path with the query string DROPPED), the response `statusCode`, the
413
+ * elapsed ms, and the fields the handler fills in (keyId / model / provider /
414
+ * error / bodies) — then hands it to the fire-and-forget `recordAudit`;
415
+ * - when `captureBodies` is ALSO on, wraps `res.write`/`res.end` to accumulate a
416
+ * BOUNDED response body (metadata-only for a streaming `text/event-stream`
417
+ * response — a full stream is unbounded), and truncates+redacts both bodies.
418
+ *
419
+ * Request HEADERS are NEVER read into a record (Authorization / x-api-key live
420
+ * there). Every stored body passes through {@link redactAuditText}. The assembly
421
+ * is wrapped so a capture failure can never surface into the relay path.
422
+ *
423
+ * @module @omnicross/core/outbound-api/auditCapture
424
+ */
425
+
426
+ /**
427
+ * The mutable context the request handler enriches as it progresses. `null` is
428
+ * returned by {@link beginAuditCapture} when audit is disabled — the handler
429
+ * guards every write with `if (audit)`.
430
+ */
431
+ interface AuditCaptureContext {
432
+ /** Outbound key id (set after auth). NEVER the key material. */
433
+ keyId?: string | null;
434
+ /** Resolved upstream model (set after route resolution). */
435
+ model?: string;
436
+ /** Upstream provider id (set after route resolution). */
437
+ provider?: string;
438
+ /** Sanitized error message (set on a relay/dispatch failure). */
439
+ error?: string;
440
+ /** Stash the raw request body for capture (a no-op unless `captureBodies`). */
441
+ setRequestBody(raw: string): void;
442
+ }
443
+ /**
444
+ * Begin audit capture for one request. Returns a mutable context the handler
445
+ * enriches, or `null` when audit is disabled (zero-cost path). The record is
446
+ * emitted on response `close` via the fire-and-forget sink.
447
+ */
448
+ declare function beginAuditCapture(req: http.IncomingMessage, res: http.ServerResponse, now: number): AuditCaptureContext | null;
449
+
450
+ /**
451
+ * billingCapture — the outbound server's post-response billing capture hook
452
+ * (billing-event-stream, design D1/D4).
453
+ *
454
+ * {@link beginBillingCapture} is called at the TOP of `handleOutboundRequest`. It
455
+ * reads the active billing config from the core slot; when billing is DISABLED it
456
+ * returns `null` immediately (one slot read — no assembly, no listener, zero
457
+ * regression). When enabled it registers a one-shot `res.close` listener that
458
+ * assembles a {@link BillingEvent} from the handler-filled context (keyId / model
459
+ * / provider / authMode), the response `statusCode`, and this request's METERED
460
+ * FACTS (token counts + the ALREADY-COMPUTED cost) — then hands it to the
461
+ * no-throw {@link publishBillingEvent}.
462
+ *
463
+ * COST REUSE (design D3 — no double pricing): the metered facts are read from the
464
+ * per-request usage stash the usage tap populates ({@link readAuditUsage} — the
465
+ * SAME stash `usage-events.jsonl` correlation uses, populated UNCONDITIONALLY by
466
+ * the taps, independent of whether request-audit is enabled). Token counts are
467
+ * stashed SYNCHRONOUSLY at `record()`; the cost lands on the recorder's deferred
468
+ * pricing tick (scheduled BEFORE the response closes). So the assembly is deferred
469
+ * one macrotask past `close` — by then the earlier-scheduled pricing tick has run
470
+ * and stashed the computed cost we REUSE (we never re-price).
471
+ *
472
+ * BILLABLE-ONLY: an event is emitted ONLY when the request produced usage (an
473
+ * upstream call was billed). Auth failures / rate-limit / model-restriction
474
+ * rejections return before dispatch, stash no usage, and emit NO billing event.
475
+ *
476
+ * @module @omnicross/core/outbound-api/billingCapture
477
+ */
478
+
479
+ /**
480
+ * The mutable context the request handler enriches as it progresses. `null` is
481
+ * returned by {@link beginBillingCapture} when billing is disabled — the handler
482
+ * guards every write with `if (billing)`.
483
+ */
484
+ interface BillingCaptureContext {
485
+ /** Outbound key id (set after auth). NEVER the key material. */
486
+ keyId?: string | null;
487
+ /** Resolved upstream model (set after route resolution). */
488
+ model?: string;
489
+ /** Upstream provider id (set after route resolution). */
490
+ provider?: string;
491
+ /** Re-auth mode the request billed under (set after route resolution). */
492
+ authMode?: 'byo' | 'subscription';
493
+ }
494
+ /**
495
+ * Deferral used to schedule the event assembly one macrotask past `close`, so the
496
+ * usage tap's earlier-scheduled pricing tick has stashed the cost we reuse.
497
+ * Injectable ONLY for deterministic tests; production uses a zero-delay timer.
498
+ */
499
+ type BillingDefer = (fn: () => void) => void;
500
+ /**
501
+ * Begin billing capture for one request. Returns a mutable context the handler
502
+ * enriches, or `null` when billing is disabled (zero-cost path). The event is
503
+ * emitted on response `close` via the no-throw sink — but ONLY when the request
504
+ * produced usage (a billable request).
505
+ */
506
+ declare function beginBillingCapture(_req: http.IncomingMessage, res: http.ServerResponse, now: number, defer?: BillingDefer): BillingCaptureContext | null;
507
+
508
+ /**
509
+ * modelPrefixDispatch — pure model-name PREFIX classification for the `chat`
510
+ * endpoint's opt-in `dispatchMode: 'prefix'` (openai-chat-bridge #11, design D2).
511
+ *
512
+ * In prefix mode the requested model's leading vendor token selects a configured
513
+ * target from {@link ModelPrefixTargets} (`claude-*` → `claude`, `gpt-*` → `gpt`,
514
+ * `gemini-*` → `gemini`), so an operator can serve many upstreams from a single
515
+ * `/v1/chat/completions` without an explicit per-model list entry. The match is
516
+ * case-insensitive and anchored at the START of the id (a token prefix, not a
517
+ * substring — `my-gpt-thing` does NOT classify as `gpt`). This is a ROUTING
518
+ * convenience layered on top of the existing conversion machinery; it changes
519
+ * only which upstream a model resolves to, never how the body is translated.
520
+ *
521
+ * @module outbound-api/modelPrefixDispatch
522
+ */
523
+
524
+ /** The three core prefixes this dispatch vocabulary recognizes. */
525
+ type ModelPrefixKind = 'claude' | 'gpt' | 'gemini';
526
+ /**
527
+ * Classify a requested model id by its leading vendor token. Case-insensitive;
528
+ * anchored at the start (matches `<prefix>` exactly or `<prefix>-…`). Returns the
529
+ * matched {@link ModelPrefixKind}, or `null` when no known prefix applies (an
530
+ * empty/blank id also yields `null`).
531
+ *
532
+ * `gpt` also matches the OpenAI `o`-series reasoning ids (`o1`, `o3-mini`, …),
533
+ * which carry no `gpt` token but are the same OpenAI Chat-Completions family the
534
+ * `gpt` target serves.
535
+ */
536
+ declare function classifyModelPrefix(model: string | undefined): ModelPrefixKind | null;
537
+ /**
538
+ * Resolve the configured target ref for a requested model under prefix dispatch.
539
+ * Returns the `ModelRef` for the matched prefix, or `null` when the model has no
540
+ * known prefix OR the matched prefix has no configured target (both are
541
+ * "unroutable" — the caller surfaces a clear per-request error).
542
+ */
543
+ declare function resolvePrefixTarget(targets: ModelPrefixTargets | undefined, model: string | undefined): {
544
+ kind: ModelPrefixKind;
545
+ ref: ModelRef;
546
+ } | null;
547
+
548
+ /**
549
+ * outboundConcurrencyGate — per-`apiKeyId` concurrency queue for the outbound
550
+ * API server (queue/concurrency, design D-CORE-2).
551
+ *
552
+ * An outbound key over its concurrency ceiling should WAIT its turn rather than
553
+ * get a hard 429. This primitive is a per-key counting semaphore (`limit` =
554
+ * the key's `maxConcurrency`) fronting a bounded FIFO wait queue: within limit
555
+ * → grant; over limit but under `max(limit*factor, minQueueSize)` → enqueue;
556
+ * beyond that → reject queue-full. Waiters resolve in strict FIFO order and a
557
+ * waiter can be CANCELLED (the wire layer binds this to `res.close` so a client
558
+ * that disconnects mid-queue frees its spot). Release + cancel are idempotent
559
+ * (guarded by a per-acquisition `settled` flag) so the wire's `finally` +
560
+ * `res.once('close')` double-fire is safe — this directly avoids the CRS #1130
561
+ * slot leak.
562
+ *
563
+ * Memory-only, injected clock, resets on restart. `limit <= 0` means unlimited
564
+ * (the wire layer bypasses the gate entirely for such keys; a defensive call
565
+ * here still grants immediately without bound).
566
+ *
567
+ * NOTE ON THE ACQUIRE SHAPE: the design task sketched `acquire → Promise<{
568
+ * release, cancel }>`, but the wire layer must be able to CANCEL a still-pending
569
+ * wait (bind `res.once('close', cancel)` BEFORE the grant resolves). A bare
570
+ * promise only hands back its value on grant, so `acquire` instead returns a
571
+ * synchronous {@link GateAcquisition} handle exposing both `cancel()` and a
572
+ * `granted` promise; the granted {@link GateSlot} also carries `release` AND
573
+ * `cancel` (the same idempotent fns) so the sketched shape is still satisfied
574
+ * post-grant. (Deviation recorded in the change return notes.)
575
+ *
576
+ * @module outbound-api/outboundConcurrencyGate
577
+ */
578
+ /** A granted concurrency slot. `release` frees it; `cancel` is a post-grant no-op. */
579
+ interface GateSlot {
580
+ release(): void;
581
+ cancel(): void;
582
+ }
583
+ /** The synchronous handle `acquire` returns (so a pending wait can be cancelled). */
584
+ interface GateAcquisition {
585
+ /** Resolves with a {@link GateSlot} on grant; rejects on queue-full/timeout/cancel. */
586
+ granted: Promise<GateSlot>;
587
+ /** Cancel a still-pending wait (idempotent; no-op once granted). */
588
+ cancel(): void;
589
+ }
590
+ /** Options for one `acquire`. */
591
+ interface GateAcquireOptions {
592
+ /** Per-key max queued = `max(limit*factor, minQueueSize)`. */
593
+ maxQueueSizeFactor: number;
594
+ /** Floor of the per-key max queued. */
595
+ minQueueSize: number;
596
+ /** Reject a queued waiter after this many ms (wire → 429). */
597
+ waitTimeoutMs: number;
598
+ }
599
+ /** A per-key snapshot entry (only keys with active slots or waiters). */
600
+ interface GateStatusEntry {
601
+ apiKeyId: string;
602
+ active: number;
603
+ waiting: number;
604
+ }
605
+ /** Rejection thrown when a key's wait queue is full at acquire time. */
606
+ declare class ConcurrencyQueueFullError extends Error {
607
+ readonly apiKeyId: string;
608
+ readonly maxQueueSize: number;
609
+ readonly code = "concurrency_queue_full";
610
+ constructor(apiKeyId: string, maxQueueSize: number);
611
+ }
612
+ /** Rejection thrown when a queued waiter exceeds `waitTimeoutMs`. */
613
+ declare class ConcurrencyWaitTimeoutError extends Error {
614
+ readonly apiKeyId: string;
615
+ readonly waitTimeoutMs: number;
616
+ readonly code = "concurrency_wait_timeout";
617
+ constructor(apiKeyId: string, waitTimeoutMs: number);
618
+ }
619
+ /** Rejection thrown when a still-pending wait is cancelled (client disconnect). */
620
+ declare class ConcurrencyWaitCancelledError extends Error {
621
+ readonly apiKeyId: string;
622
+ readonly code = "concurrency_wait_cancelled";
623
+ constructor(apiKeyId: string);
624
+ }
625
+ /** True for any rejection the gate produces on an acquire that never granted. */
626
+ declare function isConcurrencyRejection(err: unknown): err is ConcurrencyQueueFullError | ConcurrencyWaitTimeoutError | ConcurrencyWaitCancelledError;
627
+ /**
628
+ * Per-`apiKeyId` counting semaphore + bounded FIFO wait queue.
629
+ */
630
+ declare class OutboundConcurrencyGate {
631
+ private readonly states;
632
+ private ensure;
633
+ /** Delete a key's state once it is fully idle (no active, no waiters). */
634
+ private gc;
635
+ /**
636
+ * Acquire a concurrency slot for `apiKeyId` under `limit`. Grants immediately
637
+ * when `active < limit`; else enqueues FIFO while under the per-key queue cap;
638
+ * else rejects queue-full. `limit <= 0` = unlimited (always granted).
639
+ */
640
+ acquire(apiKeyId: string, limit: number, options: GateAcquireOptions): GateAcquisition;
641
+ /** Build an idempotent granted slot (`release` decrements; `cancel` is a no-op). */
642
+ private makeSlot;
643
+ /** After a release freed a slot, grant the FIFO head if it fits under its cap. */
644
+ private dispatchNext;
645
+ /** Snapshot for observability — only keys with active slots or waiters. */
646
+ getStatus(): GateStatusEntry[];
647
+ /** Drop all state + pending timers (tests / teardown). */
648
+ reset(): void;
649
+ }
650
+
651
+ /**
652
+ * userMessageDetection — decide whether a parsed outbound request body is a
653
+ * REAL user-message turn (serialize it) vs a tool-loop continuation (bypass the
654
+ * serial queue) (queue/concurrency, design D-CORE-4).
655
+ *
656
+ * The serial queue protects a shared upstream account from looking like many
657
+ * concurrent humans. Only human-INITIATED turns should be serialized; a
658
+ * tool-loop turn (the client feeding a tool result back) must NOT be throttled
659
+ * or it stalls the agent. This module classifies the LAST turn per ingress
660
+ * format, aligned to claude-relay-service's `isUserMessageRequest` semantics but
661
+ * covering all four omnicross ingress formats.
662
+ *
663
+ * SAFETY BIAS: default `false` (bypass) on any shape it cannot POSITIVELY
664
+ * classify as a human turn. Under-serializing only costs the (opt-in, default-
665
+ * off) protection; over-serializing would stall real tool-loops.
666
+ *
667
+ * @module outbound-api/userMessageDetection
668
+ */
669
+
670
+ /**
671
+ * Decide whether `parsedBody` for `endpoint` is a real user-message turn (→
672
+ * serialize) vs a tool-loop / non-user turn (→ bypass). Defaults `false` on any
673
+ * unclassifiable shape.
674
+ */
675
+ declare function isUserMessageRequest(endpoint: OutboundEndpoint, parsedBody: unknown): boolean;
676
+
677
+ /**
678
+ * userMessageSerialQueue — per-`providerId` user-message serial queue for the
679
+ * outbound API server (queue/concurrency, design D-CORE-1).
680
+ *
681
+ * A shared subscription account hit by concurrent user messages looks unlike a
682
+ * single human and trips upstream risk-control. This primitive serializes the
683
+ * REAL user-message turns for one upstream account (`providerId`): exactly one
684
+ * in-flight at a time, plus a `delayMs` minimum gap between one account's
685
+ * requests. Waiters resolve in strict FIFO order (correcting claude-relay-
686
+ * service's non-fair Redis polling); no jitter, no busy-poll — the residual
687
+ * `delayMs` gap is honored by a scheduled dispatch.
688
+ *
689
+ * Memory-only, resets on app restart (acceptable — the queue is a live
690
+ * throttle, not persisted state). The caller RELEASES on response start (not
691
+ * completion) so the next account request can begin while the prior response
692
+ * streams; the release timestamp seeds the `delayMs` gap for the next waiter.
693
+ * The wire layer (`omnicross-uqc-wire`) owns the `res` lifecycle; this module
694
+ * never touches HTTP.
695
+ *
696
+ * @module outbound-api/userMessageSerialQueue
697
+ */
698
+ /** A held serial slot; `release()` frees it (idempotent). */
699
+ interface SerialSlot {
700
+ release(): void;
701
+ }
702
+ /** Options for one `acquire`. */
703
+ interface SerialAcquireOptions {
704
+ /** Reject the waiter after this many ms (wire → 503). */
705
+ waitTimeoutMs: number;
706
+ /** Minimum gap (ms) since this key's last release before the next grant. */
707
+ delayMs: number;
708
+ /** Clock reference for the immediate-grant decision (default `Date.now()`). */
709
+ now?: number;
710
+ }
711
+ /** A per-key snapshot entry (only keys with a held slot or non-empty queue). */
712
+ interface SerialQueueStatusEntry {
713
+ providerId: string;
714
+ holding: boolean;
715
+ waiting: number;
716
+ }
717
+ /** Rejection thrown when a serial waiter exceeds `waitTimeoutMs`. */
718
+ declare class SerialQueueTimeoutError extends Error {
719
+ readonly providerId: string;
720
+ readonly waitTimeoutMs: number;
721
+ readonly code = "serial_queue_timeout";
722
+ constructor(providerId: string, waitTimeoutMs: number);
723
+ }
724
+ /** True for a rejection produced by the serial queue wait-timeout. */
725
+ declare function isSerialQueueTimeout(err: unknown): err is SerialQueueTimeoutError;
726
+ /**
727
+ * Per-`providerId` single-slot mutex + `delayMs` spacing gate with a FIFO wait
728
+ * queue. `acquire` resolves when the key is free AND at least `delayMs` has
729
+ * elapsed since that key's last release.
730
+ */
731
+ declare class UserMessageSerialQueue {
732
+ private readonly states;
733
+ private ensure;
734
+ /**
735
+ * Acquire the serial slot for `providerId`. Resolves immediately when the slot
736
+ * is free, no one is queued, and the `delayMs` gap since the last release has
737
+ * elapsed; otherwise enqueues FIFO and resolves in order (or rejects with a
738
+ * {@link SerialQueueTimeoutError} after `waitTimeoutMs`).
739
+ */
740
+ acquire(providerId: string, options: SerialAcquireOptions): Promise<SerialSlot>;
741
+ /** Build an idempotent release for a granted slot. */
742
+ private makeSlot;
743
+ /**
744
+ * Grant the head waiter when the slot is free and the head's `delayMs` gap has
745
+ * elapsed; otherwise schedule a dispatch for the residual gap. No-op while the
746
+ * slot is held (release re-invokes this) or the queue is empty.
747
+ */
748
+ private maybeDispatch;
749
+ /** Hand the slot to the FIFO head (if free and someone is waiting). */
750
+ private grantHead;
751
+ /** Snapshot for observability — only keys holding a slot or with waiters. */
752
+ getStatus(): SerialQueueStatusEntry[];
753
+ /** Drop all state + pending timers (tests / teardown). */
754
+ reset(): void;
755
+ }
756
+
122
757
  /**
123
758
  * outboundApiKeyAuth — named-key generation, hashing, and verification for the
124
759
  * outbound API server (`outbound-api-server`).
@@ -131,6 +766,13 @@ declare function mergeServerConfig(current: OutboundApiServerConfig, patch: Part
131
766
  * @module outbound-api/outboundApiKeyAuth
132
767
  */
133
768
 
769
+ /**
770
+ * Produce `count` UNBIASED base62 chars (t2). Uses rejection sampling — bytes in
771
+ * `[248, 256)` are discarded so the remaining `[0, 248)` map uniformly onto the
772
+ * 62 alphabet (each char equally likely), eliminating the `byte % 62` modulo
773
+ * bias. Draws fresh random bytes in batches until enough chars are accepted.
774
+ */
775
+ declare function randomBase62(count: number): string;
134
776
  /** Hash a presented/generated secret (sha256 hex). */
135
777
  declare function hashKey(secret: string): string;
136
778
  /**
@@ -138,18 +780,90 @@ declare function hashKey(secret: string): string;
138
780
  * prefix are stored.
139
781
  */
140
782
  declare function createNamedKey(db: OutboundKeyDb, name: string): Promise<OutboundApiKeyCreated>;
141
- /** The id of a verified key (for rate-limiting + last-used bookkeeping). */
783
+ /**
784
+ * A verified key + the enforcement inputs carried from the row so the wire layer
785
+ * runs its policy checks WITHOUT a second DB read (mirrors `maxConcurrency`).
786
+ */
142
787
  interface VerifiedKey {
143
788
  id: string;
789
+ /**
790
+ * The key's per-key concurrency ceiling, carried from the row so the wire
791
+ * layer keys the concurrency gate without a second DB read. Absent/`0` =
792
+ * unlimited (gate bypassed).
793
+ */
794
+ maxConcurrency?: number;
795
+ /**
796
+ * Per-key USD cost limits (outbound-key-policy). Absent when the key has no
797
+ * cost cap → the wire layer skips the cost-quota check entirely.
798
+ */
799
+ costLimits?: KeyCostLimits;
800
+ /**
801
+ * Per-key rate-limit override (outbound-key-policy). Absent when the key has no
802
+ * rate config → the limiter uses its default 60/60s window (byte-identical).
803
+ */
804
+ rateLimit?: {
805
+ maxRequests?: number;
806
+ windowMs?: number;
807
+ };
808
+ /**
809
+ * Per-key model restriction (outbound-key-policy #6). Populated ONLY when the
810
+ * row has `enableModelRestriction === true` → the wire layer's presence check
811
+ * is the zero-regression gate. Absent ⇒ NO model check runs for this key.
812
+ */
813
+ modelRestriction?: ModelRestriction;
144
814
  }
815
+ /** The reason-bearing verify outcome (design D2). */
816
+ type KeyVerification = {
817
+ status: 'ok';
818
+ key: VerifiedKey;
819
+ } | {
820
+ status: 'invalid';
821
+ } | {
822
+ status: 'expired';
823
+ };
824
+ /**
825
+ * Verify a presented key against the DB, returning a REASON (design D2) so the
826
+ * wire layer can emit the right status + a clear body. Matches by hash where the
827
+ * stored row is enabled AND not revoked. On a valid, non-expired key: bumps
828
+ * `lastUsedAt` (best-effort) and, for an activation-mode key on its FIRST use,
829
+ * stamps `activatedAt` once (best-effort). A policy-less enabled key resolves to
830
+ * `{ status:'ok', key:{ id } }` — byte-identical to the pre-policy result.
831
+ */
832
+ declare function verifyKey(db: OutboundKeyDb, presentedKey: string | undefined, now?: number): Promise<KeyVerification>;
145
833
  /**
146
- * Verify a presented key against the DB. Matches by hash where the stored row
147
- * is enabled AND not revoked (the DB query enforces this). On success bumps
148
- * `lastUsedAt` (best-effort, fire-and-forget) and returns the key id; returns
149
- * `null` on any miss / disabled / revoked key.
834
+ * Thin id-or-null wrapper over {@link verifyKey} for callers not ready for the
835
+ * reason-bearing form: returns the `VerifiedKey` on success, `null` on any
836
+ * invalid/expired key (the exact pre-policy contract).
150
837
  */
151
838
  declare function verifyPresentedKey(db: OutboundKeyDb, presentedKey: string | undefined): Promise<VerifiedKey | null>;
152
839
 
840
+ /**
841
+ * keyedMutex — a tiny per-key async mutex (voucher-redemption #9, MJ1 fix).
842
+ *
843
+ * Serializes async critical sections that share a key so they run
844
+ * one-after-another (each observing the previous one's committed effects),
845
+ * mirroring the subscription `RefreshMutex` "one in-flight op per key" pattern.
846
+ * Different keys never block each other.
847
+ *
848
+ * Used by the voucher redeem path to serialize a key's redemptions: two redeems
849
+ * for the SAME key run sequentially, so each reads the other's applied result and
850
+ * a RELATIVE grant increment accumulates instead of clobbering a shared snapshot.
851
+ *
852
+ * @module outbound-api/keyedMutex
853
+ */
854
+ /** A per-key FIFO async mutex. In-memory; process-local. */
855
+ declare class KeyedMutex {
856
+ /** key → the tail of the pending-op chain (resolves when the last op frees). */
857
+ private readonly tails;
858
+ /**
859
+ * Run `fn` exclusively for `key`: it starts only after every previously
860
+ * enqueued op for the SAME key has settled, and the next waiter starts only
861
+ * after `fn` settles. Returns `fn`'s result (or rejection). Never lets one op's
862
+ * failure wedge the queue (waiters proceed regardless).
863
+ */
864
+ runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T>;
865
+ }
866
+
153
867
  /**
154
868
  * outboundRateLimiter — per-API-key in-memory sliding-window rate limiter for
155
869
  * the outbound API server (`outbound-api-server`, design D6).
@@ -184,29 +898,77 @@ declare class OutboundRateLimiter {
184
898
  * Record a request for `apiKeyId` and decide whether it is allowed. Prunes
185
899
  * timestamps older than the window first; when allowed, the request's
186
900
  * timestamp is appended.
901
+ *
902
+ * `override` (outbound-key-policy) supplies a PER-KEY window/max for this
903
+ * bucket, superseding the instance defaults for THAT key. Absent ⇒ the
904
+ * instance default 60/60s (byte-identical to before this change). An effective
905
+ * `maxRequests` of `0` means UNLIMITED — the request is allowed and NOT
906
+ * recorded (no bucket growth).
187
907
  */
188
- check(apiKeyId: string, now?: number): RateLimitDecision;
908
+ check(apiKeyId: string, now?: number, override?: RateLimiterOptions): RateLimitDecision;
189
909
  /** Drop all recorded state (tests / teardown). */
190
910
  reset(): void;
191
911
  }
192
912
 
193
913
  /**
194
- * roleDetectionclassify an outbound request's ROLE (vision / background /
195
- * default) so the route resolver can pick the endpoint's model for that role
914
+ * voucherRedeemthe key-authenticated `POST <base>/redeem` handler
915
+ * (voucher-redemption #9, design D1/D4/D5/D6; MJ1/M2/MJ2/M3 fix).
916
+ *
917
+ * A key presents its own outbound secret (already verified by the caller) plus a
918
+ * card `{ code }`; the card's value applies to THAT key. The critical section is
919
+ * SERIALIZED PER KEY (an async `KeyedMutex`) so two redemptions for the same key
920
+ * run one-after-another — the fix for concurrent value loss (MJ1). Each redeem:
921
+ * 1. RECONCILE any stranded (`redeemed && grantApplied !== true`) card for this
922
+ * key FIRST — re-applying its recorded ABSOLUTE target and marking it — so a
923
+ * prior card's grant settles BEFORE this redeem computes anything (M2: the
924
+ * recorded absolute is therefore never stale).
925
+ * 2. hash the code → look up → require `unredeemed`.
926
+ * 3. compute the grant from the CURRENT policy read inside the mutex — this is
927
+ * the intended final ABSOLUTE key value (`min(current + credit, cap)` /
928
+ * `min(current + days, now + capDays)`). Because the mutex serialized step 1,
929
+ * "current" already includes every earlier card, so concurrent DIFFERENT
930
+ * cards accumulate correctly.
931
+ * 4. ATOMIC CAS flip `unredeemed → redeemed` recording that absolute +
932
+ * `grantApplied = false` — the single-use guard.
933
+ * 5. apply `setPolicy(key, <recorded absolute>)`; on success set
934
+ * `grantApplied = true`.
935
+ *
936
+ * Crash-safety WITHOUT double-credit (MJ2): the apply target is the recorded
937
+ * ABSOLUTE, applied idempotently on BOTH the first pass AND the reconcile — so a
938
+ * crash between the `setPolicy` and the `grantApplied` mark just re-applies the
939
+ * SAME absolute (a no-op), never a second credit. If the apply FAILS on the first
940
+ * pass (key revoked mid-redeem, M3) the flip is REVERTED and the holder gets an
941
+ * error. Redeem attempts are rate-limited (D6). The response reveals ONLY this
942
+ * key's own balance.
943
+ *
944
+ * @module outbound-api/voucherRedeem
945
+ */
946
+
947
+ /** True for `POST <base>/redeem` (or `/v1/redeem`) — the redeem endpoint. */
948
+ declare function isRedeemRequest(method: string | undefined, url: string | undefined): boolean;
949
+ /**
950
+ * Handle a redeem request. `deps` carries the voucher store + the key DB; the key
951
+ * is ALREADY verified by the router (`verifiedKeyId` / `presentedKey`). Gated on
952
+ * `voucherEnabled` (disabled ⇒ inert). `redeemLimiter` throttles attempts (D6);
953
+ * `redeemMutex` serializes a key's redemptions (MJ1 fix).
954
+ */
955
+ declare function handleVoucherRedeem(req: http.IncomingMessage, res: http.ServerResponse, deps: OutboundApiDeps, voucherEnabled: boolean, redeemLimiter: OutboundRateLimiter, verifiedKeyId: string, presentedKey: string, now: number, redeemMutex?: KeyedMutex): Promise<void>;
956
+
957
+ /**
958
+ * roleDetection — classify an outbound request's ROLE (background / default) so
959
+ * the route resolver can pick the endpoint's model for that role
196
960
  * (`outbound-api-server`, design D2).
197
961
  *
198
- * Precedence: vision > background > default.
199
- * - vision — the body carries image/vision content parts (per-format
200
- * detection). The route resolver applies the vision→default
201
- * fallback when the endpoint has no vision model.
962
+ * Applies to the role-based endpoints (`chat`/`gemini`) only; the kind-mapped
963
+ * endpoints (`messages`/`responses`) classify by model KIND in `kindDetection`.
964
+ *
965
+ * Precedence: background > default.
202
966
  * - background — the requested model id is in the endpoint's optional
203
967
  * background-model-id override list (human decision after the
204
968
  * proposal), OR the registry small/haiku-class name signal
205
969
  * matches (Claude Code's haiku probe sends exactly this).
206
970
  * - default — everything else.
207
971
  *
208
- * Each ingress's body shape is handled here in ONE place.
209
- *
210
972
  * @module outbound-api/roleDetection
211
973
  */
212
974
 
@@ -214,8 +976,7 @@ declare class OutboundRateLimiter {
214
976
  * Detect the request's role. `backgroundModelIds` is the endpoint's optional
215
977
  * override list: when an incoming requested model id matches an entry there, the
216
978
  * request is BACKGROUND regardless of the name signal; otherwise the registry
217
- * small/haiku-class name signal is the baseline. Precedence vision > background
218
- * > default.
979
+ * small/haiku-class name signal is the baseline. Precedence background > default.
219
980
  */
220
981
  declare function detectRequestRole(ingressFormat: IngressFormat, body: Record<string, unknown>, options?: {
221
982
  backgroundModelIds?: string[];
@@ -245,4 +1006,4 @@ declare function getOutboundApiServer(deps?: OutboundApiDeps, onPortChange?: (po
245
1006
  /** Reset the singleton (tests / teardown only). */
246
1007
  declare function __resetOutboundApiServerForTests(): void;
247
1008
 
248
- export { type ApiServerSettingsStore, type ApplyConfigInput, DEFAULT_OUTBOUND_PORT, EndpointRoutingConfig, OUTBOUND_API_SERVER_CONFIG_KEY, OutboundApiDeps, OutboundApiKeyCreated, OutboundApiServer, OutboundApiServerConfig, OutboundApiServerStatus, OutboundFormatUrls, OutboundKeyDb, OutboundRateLimiter, RequestRole, __resetOutboundApiServerForTests, createNamedKey, defaultServerConfig, detectRequestRole, endpointToIngressFormat, formatUrls, getOutboundApiServer, hashKey, loadServerConfig, mergeServerConfig, normalizeServerConfig, saveServerConfig, verifyPresentedKey };
1009
+ export { AUDIT_REDACTED, AccountProbeConfig, type ApiServerSettingsStore, type ApplyConfigInput, type AuditCaptureContext, type BillingCaptureContext, ConcurrencyQueueConfig, ConcurrencyQueueFullError, ConcurrencyWaitCancelledError, ConcurrencyWaitTimeoutError, DEFAULT_ACCOUNT_PROBE, DEFAULT_CONCURRENCY_QUEUE, DEFAULT_FINGERPRINT, DEFAULT_OUTBOUND_PORT, DEFAULT_USER_MESSAGE_QUEUE, type EndpointModelConfigError, EndpointRoutingConfig, FingerprintConfig, type GateAcquireOptions, type GateAcquisition, type GateSlot, type GateStatusEntry, KeyCostLimits, type KeyVerification, KeyedMutex, KindMappedEndpoint, ModelKind, type ModelPrefixKind, ModelPrefixTargets, OUTBOUND_API_SERVER_CONFIG_KEY, OutboundApiConfigError, OutboundApiDeps, OutboundApiKeyCreated, OutboundApiServer, OutboundApiServerConfig, OutboundApiServerStatus, OutboundConcurrencyGate, OutboundEndpoint, OutboundFormatUrls, OutboundKeyDb, OutboundProxyConfig, OutboundRateLimiter, RequestRole, type SerialAcquireOptions, type SerialQueueStatusEntry, SerialQueueTimeoutError, type SerialSlot, UserMessageQueueConfig, UserMessageSerialQueue, type VerifiedKey, __resetOutboundApiServerForTests, beginAuditCapture, beginBillingCapture, classifyModelPrefix, createNamedKey, defaultServerConfig, detectModelKind, detectRequestRole, endpointToIngressFormat, formatUrls, getOutboundApiServer, handleVoucherRedeem, hashKey, isConcurrencyRejection, isKindMappedEndpoint, isRedeemRequest, isSerialQueueTimeout, isUserMessageRequest, loadServerConfig, mergeServerConfig, modelKindsForEndpoint, normalizeAccountProbe, normalizeAudit, normalizeBilling, normalizeFingerprint, normalizePrefixTargets, normalizeProxyConfig, normalizeProxySegment, normalizeQueueSegments, normalizeServerConfig, normalizeVoucher, normalizeWebhookDestination, normalizeWebhookSegment, randomBase62, redactAuditText, resolvePrefixTarget, saveServerConfig, validateEndpointModelConfig, validateServerModelConfig, verifyKey, verifyPresentedKey };