@omnicross/core 0.1.2 → 0.1.4

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