condukt 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/runtimes/claude/bridge-cache.d.ts +58 -0
  2. package/dist/runtimes/claude/bridge-cache.d.ts.map +1 -0
  3. package/dist/runtimes/claude/bridge-cache.js +257 -0
  4. package/dist/runtimes/claude/bridge-cache.js.map +1 -0
  5. package/dist/runtimes/claude/claude-runtime.d.ts +30 -0
  6. package/dist/runtimes/claude/claude-runtime.d.ts.map +1 -0
  7. package/dist/runtimes/claude/claude-runtime.js +88 -0
  8. package/dist/runtimes/claude/claude-runtime.js.map +1 -0
  9. package/dist/runtimes/claude/claude-session.d.ts +154 -0
  10. package/dist/runtimes/claude/claude-session.d.ts.map +1 -0
  11. package/dist/runtimes/claude/claude-session.js +851 -0
  12. package/dist/runtimes/claude/claude-session.js.map +1 -0
  13. package/dist/runtimes/claude/config-mapper.d.ts +69 -0
  14. package/dist/runtimes/claude/config-mapper.d.ts.map +1 -0
  15. package/dist/runtimes/claude/config-mapper.js +458 -0
  16. package/dist/runtimes/claude/config-mapper.js.map +1 -0
  17. package/dist/runtimes/claude/epoch-store.d.ts +28 -0
  18. package/dist/runtimes/claude/epoch-store.d.ts.map +1 -0
  19. package/dist/runtimes/claude/epoch-store.js +199 -0
  20. package/dist/runtimes/claude/epoch-store.js.map +1 -0
  21. package/dist/runtimes/claude/fallback-runtime.d.ts +14 -0
  22. package/dist/runtimes/claude/fallback-runtime.d.ts.map +1 -0
  23. package/dist/runtimes/claude/fallback-runtime.js +64 -0
  24. package/dist/runtimes/claude/fallback-runtime.js.map +1 -0
  25. package/dist/runtimes/claude/index.d.ts +24 -0
  26. package/dist/runtimes/claude/index.d.ts.map +1 -0
  27. package/dist/runtimes/claude/index.js +61 -0
  28. package/dist/runtimes/claude/index.js.map +1 -0
  29. package/dist/runtimes/claude/message-mapper.d.ts +135 -0
  30. package/dist/runtimes/claude/message-mapper.d.ts.map +1 -0
  31. package/dist/runtimes/claude/message-mapper.js +504 -0
  32. package/dist/runtimes/claude/message-mapper.js.map +1 -0
  33. package/dist/runtimes/claude/one-shot.d.ts +48 -0
  34. package/dist/runtimes/claude/one-shot.d.ts.map +1 -0
  35. package/dist/runtimes/claude/one-shot.js +186 -0
  36. package/dist/runtimes/claude/one-shot.js.map +1 -0
  37. package/dist/runtimes/claude/protocol-bridge.d.ts +138 -0
  38. package/dist/runtimes/claude/protocol-bridge.d.ts.map +1 -0
  39. package/dist/runtimes/claude/protocol-bridge.js +1170 -0
  40. package/dist/runtimes/claude/protocol-bridge.js.map +1 -0
  41. package/dist/runtimes/claude/protocol-router.d.ts +113 -0
  42. package/dist/runtimes/claude/protocol-router.d.ts.map +1 -0
  43. package/dist/runtimes/claude/protocol-router.js +514 -0
  44. package/dist/runtimes/claude/protocol-router.js.map +1 -0
  45. package/dist/runtimes/claude/session-epoch.d.ts +156 -0
  46. package/dist/runtimes/claude/session-epoch.d.ts.map +1 -0
  47. package/dist/runtimes/claude/session-epoch.js +365 -0
  48. package/dist/runtimes/claude/session-epoch.js.map +1 -0
  49. package/dist/runtimes/claude/session-recovery.d.ts +78 -0
  50. package/dist/runtimes/claude/session-recovery.d.ts.map +1 -0
  51. package/dist/runtimes/claude/session-recovery.js +259 -0
  52. package/dist/runtimes/claude/session-recovery.js.map +1 -0
  53. package/dist/runtimes/claude/sse.d.ts +16 -0
  54. package/dist/runtimes/claude/sse.d.ts.map +1 -0
  55. package/dist/runtimes/claude/sse.js +46 -0
  56. package/dist/runtimes/claude/sse.js.map +1 -0
  57. package/dist/runtimes/claude/translate-chat.d.ts +106 -0
  58. package/dist/runtimes/claude/translate-chat.d.ts.map +1 -0
  59. package/dist/runtimes/claude/translate-chat.js +857 -0
  60. package/dist/runtimes/claude/translate-chat.js.map +1 -0
  61. package/dist/runtimes/claude/translate-responses.d.ts +76 -0
  62. package/dist/runtimes/claude/translate-responses.d.ts.map +1 -0
  63. package/dist/runtimes/claude/translate-responses.js +625 -0
  64. package/dist/runtimes/claude/translate-responses.js.map +1 -0
  65. package/dist/runtimes/claude/usage-ledger.d.ts +108 -0
  66. package/dist/runtimes/claude/usage-ledger.d.ts.map +1 -0
  67. package/dist/runtimes/claude/usage-ledger.js +189 -0
  68. package/dist/runtimes/claude/usage-ledger.js.map +1 -0
  69. package/dist/runtimes/copilot/copilot-backend.d.ts +8 -0
  70. package/dist/runtimes/copilot/copilot-backend.d.ts.map +1 -1
  71. package/dist/runtimes/copilot/sdk-backend.d.ts.map +1 -1
  72. package/dist/runtimes/copilot/sdk-backend.js +314 -135
  73. package/dist/runtimes/copilot/sdk-backend.js.map +1 -1
  74. package/dist/runtimes/mcp-env.d.ts +24 -0
  75. package/dist/runtimes/mcp-env.d.ts.map +1 -0
  76. package/dist/runtimes/mcp-env.js +81 -0
  77. package/dist/runtimes/mcp-env.js.map +1 -0
  78. package/dist/runtimes/mock/mock-runtime.d.ts +2 -2
  79. package/dist/runtimes/mock/mock-runtime.d.ts.map +1 -1
  80. package/dist/runtimes/mock/mock-runtime.js +38 -1
  81. package/dist/runtimes/mock/mock-runtime.js.map +1 -1
  82. package/dist/src/agent.d.ts.map +1 -1
  83. package/dist/src/agent.js +171 -3
  84. package/dist/src/agent.js.map +1 -1
  85. package/dist/src/index.d.ts +1 -1
  86. package/dist/src/index.d.ts.map +1 -1
  87. package/dist/src/index.js.map +1 -1
  88. package/dist/src/session-recovery-policy.d.ts +19 -0
  89. package/dist/src/session-recovery-policy.d.ts.map +1 -0
  90. package/dist/src/session-recovery-policy.js +43 -0
  91. package/dist/src/session-recovery-policy.js.map +1 -0
  92. package/dist/src/types.d.ts +118 -1
  93. package/dist/src/types.d.ts.map +1 -1
  94. package/dist/src/types.js +11 -1
  95. package/dist/src/types.js.map +1 -1
  96. package/package.json +20 -4
@@ -0,0 +1,1170 @@
1
+ "use strict";
2
+ /**
3
+ * Self-contained in-process protocol bridge.
4
+ *
5
+ * Exposes an Anthropic-compatible HTTP surface (for Claude SDK via
6
+ * ANTHROPIC_BASE_URL) and translates to any of the three OpenCode Go /
7
+ * Zen upstream protocols, applying cache instrumentation:
8
+ *
9
+ * - anthropic → raw pass-through (cache_control preserved)
10
+ * - openai → chat/completions translation (request + SSE)
11
+ * - responses → OpenAI Responses translation (request + SSE)
12
+ *
13
+ * Routing is model-driven (see protocol-router.ts), session-stickiness
14
+ * uses the `x-opencode-session` header, and prompt-cache keys are stamped
15
+ * on chat/completions with a 400-downgrade fallback. Requests to OpenCode
16
+ * hosts present the OpenCode client identity the Console expects (client
17
+ * User-Agent, `x-opencode-client`, client-shaped session id). Binds 127.0.0.1.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.OPENCODE_CLIENT_USER_AGENT = void 0;
24
+ exports.buildBridgeEnv = buildBridgeEnv;
25
+ exports.timeoutSignal = timeoutSignal;
26
+ exports.anthropicErrorTypeForStatus = anthropicErrorTypeForStatus;
27
+ exports.resolveEpochStateFile = resolveEpochStateFile;
28
+ exports.createBridge = createBridge;
29
+ const node_http_1 = __importDefault(require("node:http"));
30
+ const node_fs_1 = __importDefault(require("node:fs"));
31
+ const node_os_1 = __importDefault(require("node:os"));
32
+ const node_path_1 = __importDefault(require("node:path"));
33
+ const bridge_cache_1 = require("./bridge-cache");
34
+ const session_epoch_1 = require("./session-epoch");
35
+ const epoch_store_1 = require("./epoch-store");
36
+ const protocol_router_1 = require("./protocol-router");
37
+ const translate_chat_1 = require("./translate-chat");
38
+ const translate_responses_1 = require("./translate-responses");
39
+ const sse_1 = require("./sse");
40
+ /**
41
+ * Environment for pointing the Claude CLI at the in-process bridge.
42
+ * The reference deployments use a fake key on both variables; the bridge
43
+ * itself authenticates upstream with `upstreamApiKey`.
44
+ */
45
+ function buildBridgeEnv(handle, opts = {}) {
46
+ const key = opts.apiKey ?? 'condukt-bridge-key';
47
+ const env = {
48
+ ANTHROPIC_BASE_URL: handle.baseUrl,
49
+ ANTHROPIC_API_KEY: key,
50
+ ANTHROPIC_AUTH_TOKEN: key,
51
+ };
52
+ if (opts.bridgeSecret) {
53
+ env['ANTHROPIC_CUSTOM_HEADERS'] = `x-condukt-bridge-secret: ${opts.bridgeSecret}`;
54
+ }
55
+ return env;
56
+ }
57
+ const DEFAULT_USER_AGENT = 'condukt-claude-bridge/0.1';
58
+ /**
59
+ * OpenCode 2.x client User-Agent shape (`opencode/<channel>/<version>/<name>`,
60
+ * packages/core/src/app.ts). The Console reads it to recognise the client, so
61
+ * bridge requests to OpenCode hosts present it by default; `config.userAgent`
62
+ * still overrides.
63
+ */
64
+ exports.OPENCODE_CLIENT_USER_AGENT = 'opencode/latest/2.0.32/cli';
65
+ const DEFAULT_MAX_ATTEMPTS = 3;
66
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
67
+ const DEFAULT_KEEP_ALIVE_MS = 45_000;
68
+ const DEFAULT_IDLE_TIMEOUT_MS = 300_000;
69
+ const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
70
+ function sleep(ms) {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+ /**
74
+ * Abort signal that fires after `ms`, optionally chained to a parent
75
+ * signal. `clear()` releases the timer so long fetches do not leak.
76
+ */
77
+ function timeoutSignal(ms, parent) {
78
+ const ac = new AbortController();
79
+ const timer = setTimeout(() => ac.abort(new Error(`request timed out after ${ms}ms`)), ms);
80
+ const onParentAbort = () => ac.abort(parent?.reason);
81
+ if (parent) {
82
+ if (parent.aborted)
83
+ ac.abort(parent.reason);
84
+ else
85
+ parent.addEventListener('abort', onParentAbort, { once: true });
86
+ }
87
+ return {
88
+ signal: ac.signal,
89
+ clear: () => {
90
+ clearTimeout(timer);
91
+ parent?.removeEventListener('abort', onParentAbort);
92
+ },
93
+ };
94
+ }
95
+ function isRetryableStatus(status) {
96
+ return status === 429 || status >= 500;
97
+ }
98
+ /**
99
+ * The Anthropic client branches on `error.type`, not on the HTTP status.
100
+ * Collapsing every 4xx into `invalid_request_error` makes a 401 look like a
101
+ * malformed request, and a client that would re-authenticate instead keeps
102
+ * resending the same rejected credential.
103
+ */
104
+ function anthropicErrorTypeForStatus(status) {
105
+ switch (status) {
106
+ case 400:
107
+ return 'invalid_request_error';
108
+ case 401:
109
+ return 'authentication_error';
110
+ case 403:
111
+ return 'permission_error';
112
+ case 404:
113
+ return 'not_found_error';
114
+ case 413:
115
+ return 'request_too_large';
116
+ case 429:
117
+ return 'rate_limit_error';
118
+ // Timeout / "too early" are transport faults, not malformed requests.
119
+ case 408:
120
+ case 425:
121
+ return 'api_error';
122
+ case 529:
123
+ return 'overloaded_error';
124
+ default:
125
+ return status >= 500 ? 'api_error' : 'invalid_request_error';
126
+ }
127
+ }
128
+ function anthropicErrorPayload(type, message) {
129
+ return JSON.stringify({ type: 'error', error: { type, message } });
130
+ }
131
+ const EPOCH_SAVE_EVERY = 16;
132
+ /** Parse a boolean-ish env flag; null when unset/unrecognized. */
133
+ function envFlag(name) {
134
+ const v = (process.env[name] ?? '').trim().toLowerCase();
135
+ if (!v)
136
+ return null;
137
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no')
138
+ return false;
139
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes')
140
+ return true;
141
+ return null;
142
+ }
143
+ /** Opt-in stderr diagnostics (`CONDUKT_EPOCH_DEBUG=1`), parity with the reference logs. */
144
+ function epochDebug(message) {
145
+ const flag = process.env['CONDUKT_EPOCH_DEBUG'];
146
+ if (flag === '1' || flag === 'true') {
147
+ try {
148
+ console.error(`[condukt-bridge] ${message}`);
149
+ }
150
+ catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Epoch sidecar resolution: explicit config → `CONDUKT_EPOCH_PATH` →
157
+ * sibling of `stateFile` → default user path. `false` disables
158
+ * persistence. The home default is skipped under test runners
159
+ * (NODE_ENV=test / VITEST) so unit tests never write to a developer's
160
+ * home directory.
161
+ */
162
+ function resolveEpochStateFile(config) {
163
+ if (config.epochStateFile === false)
164
+ return null;
165
+ if (typeof config.epochStateFile === 'string' && config.epochStateFile.trim()) {
166
+ return config.epochStateFile;
167
+ }
168
+ const envPath = (process.env['CONDUKT_EPOCH_PATH'] ?? '').trim();
169
+ if (envPath)
170
+ return envPath;
171
+ if (config.stateFile)
172
+ return `${config.stateFile}.epochs.json`;
173
+ if (process.env['NODE_ENV'] === 'test' || process.env['VITEST'])
174
+ return null;
175
+ const home = node_os_1.default.homedir();
176
+ return home ? node_path_1.default.join(home, '.condukt', 'claude-session-epochs.json') : null;
177
+ }
178
+ function createBridge(config) {
179
+ try {
180
+ return createBridgeUnsafe(config);
181
+ }
182
+ catch (e) {
183
+ return Promise.reject(e);
184
+ }
185
+ }
186
+ function createBridgeUnsafe(config) {
187
+ if (!/^https?:\/\//i.test(config.upstreamBaseUrl)) {
188
+ throw new Error('upstreamBaseUrl must start with http:// or https://');
189
+ }
190
+ const derived = (0, protocol_router_1.deriveEndpoints)(config.upstreamBaseUrl);
191
+ const endpoints = { ...derived, ...(config.endpoints ?? {}) };
192
+ for (const id of protocol_router_1.PROTOCOL_IDS) {
193
+ if (!/^https?:\/\//i.test(endpoints[id])) {
194
+ throw new Error(`endpoint for protocol '${id}' must start with http:// or https://`);
195
+ }
196
+ }
197
+ const zenBase = config.zenBaseUrl ??
198
+ ((0, protocol_router_1.recognizeBase)(config.upstreamBaseUrl) === 'opencode' ? (0, protocol_router_1.deriveZenBase)(config.upstreamBaseUrl) : null);
199
+ const zenDerived = zenBase ? (0, protocol_router_1.deriveEndpoints)(zenBase) : derived;
200
+ const zenEndpoints = { ...zenDerived, ...(config.zenEndpoints ?? {}) };
201
+ for (const id of protocol_router_1.PROTOCOL_IDS) {
202
+ if (!/^https?:\/\//i.test(zenEndpoints[id])) {
203
+ throw new Error(`zen endpoint for protocol '${id}' must start with http:// or https://`);
204
+ }
205
+ }
206
+ const maxAttempts = Math.max(1, config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
207
+ const requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
208
+ const keepAliveMs = config.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
209
+ const idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
210
+ const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
211
+ const cacheKeyEnabled = config.cacheKeyEnabled ?? true;
212
+ const userAgent = config.userAgent ?? DEFAULT_USER_AGENT;
213
+ const opencodeBase = (0, protocol_router_1.recognizeBase)(config.upstreamBaseUrl) === 'opencode';
214
+ const opencodeUserAgent = config.userAgent ?? exports.OPENCODE_CLIENT_USER_AGENT;
215
+ // Epoch kill switches: `CONDUKT_SESSION_EPOCH=false` /
216
+ // `CONDUKT_SESSION_ROTATE=false` force the corresponding feature off
217
+ // even when config enables it (operator brake); `true` only supplies
218
+ // the default. Config (session-level beats runtime-level, see
219
+ // claude-session ensureBridge) wins over the env default.
220
+ const epochEnv = envFlag('CONDUKT_SESSION_EPOCH');
221
+ const rotateEnv = envFlag('CONDUKT_SESSION_ROTATE');
222
+ const epochEnabled = epochEnv === false ? false : (config.epochEnabled ?? epochEnv ?? true);
223
+ const epochRotateEnabled = rotateEnv === false ? false : (config.epochRotateEnabled ?? rotateEnv ?? true);
224
+ const epochConst = (0, session_epoch_1.epochConstants)(config.epochConstants);
225
+ const epochStateFile = resolveEpochStateFile(config);
226
+ return new Promise((resolve, reject) => {
227
+ const downgrade = new bridge_cache_1.DowngradeSet();
228
+ const metrics = {
229
+ requestCount: 0,
230
+ downgradeCount: 0,
231
+ cacheStampedCount: 0,
232
+ errorCount: 0,
233
+ epochBumps: 0,
234
+ epochRotations: 0,
235
+ cacheReadTokens: 0,
236
+ cacheWriteTokens: 0,
237
+ protocolCounts: { anthropic: 0, openai: 0, responses: 0 },
238
+ };
239
+ if (config.stateFile) {
240
+ try {
241
+ const raw = node_fs_1.default.readFileSync(config.stateFile, 'utf8');
242
+ const arr = JSON.parse(raw);
243
+ if (Array.isArray(arr))
244
+ for (const m of arr)
245
+ downgrade.add(String(m));
246
+ }
247
+ catch {
248
+ /* missing/corrupt state starts empty */
249
+ }
250
+ }
251
+ const persist = () => {
252
+ if (!config.stateFile)
253
+ return;
254
+ try {
255
+ node_fs_1.default.writeFileSync(config.stateFile, JSON.stringify(downgrade.list()), 'utf8');
256
+ }
257
+ catch {
258
+ /* ignore */
259
+ }
260
+ };
261
+ // ── Session compaction epochs (-eN / -rM identity) ───────────────
262
+ // One bridge serves one configured session; the tracked key is the
263
+ // sanitized base id (pre-suffix, stable across eras). Persistence
264
+ // shares one in-process store per resolved path so several bridges
265
+ // in one process cannot clobber each other's keys.
266
+ const baseSession = (0, protocol_router_1.sanitizeSessionValue)(config.sessionId);
267
+ const epochStore = epochStateFile && baseSession ? (0, epoch_store_1.sharedEpochStore)(epochStateFile, Date.now(), epochConst) : null;
268
+ const epochState = baseSession
269
+ ? epochStore
270
+ ? (0, epoch_store_1.epochEntry)(epochStore, baseSession, Date.now())
271
+ : (0, session_epoch_1.initialEpochState)()
272
+ : null;
273
+ let epochSaveCalls = 0;
274
+ function saveEpochState(sync) {
275
+ if (!epochStore || !epochStateFile || !epochState || !baseSession)
276
+ return;
277
+ if (sync) {
278
+ // Bump/rotate/compaction: persist immediately (Python
279
+ // `_epoch_save_now`), refreshing lastSeen on the exact event.
280
+ epochState.lastSeen = Date.now();
281
+ }
282
+ else {
283
+ // Throttled cadence (Python `_epoch_save`: every 16th call,
284
+ // counted on the throttled path only).
285
+ epochSaveCalls += 1;
286
+ if (epochSaveCalls % EPOCH_SAVE_EVERY !== 0)
287
+ return;
288
+ }
289
+ // Re-assert ownership: another bridge's write may have TTL-GC'd a
290
+ // stale-looking key while this bridge was idle; this request proves
291
+ // the session is alive, so keep its era attached.
292
+ epochStore[baseSession] = epochState;
293
+ (0, epoch_store_1.writeEpochStore)(epochStateFile, epochStore, Date.now(), epochConst);
294
+ }
295
+ /** Per-request verdict: effective (era-suffixed) session id + era flag. */
296
+ function resolveEpochVerdict(body) {
297
+ if (!baseSession || !epochState)
298
+ return { effective: '', advanced: false };
299
+ try {
300
+ const verdict = (0, session_epoch_1.applySessionEpoch)(baseSession, epochState, body, {
301
+ enabled: epochEnabled,
302
+ rotate: epochRotateEnabled,
303
+ constants: epochConst,
304
+ });
305
+ if (verdict.fired) {
306
+ metrics.epochBumps += 1;
307
+ saveEpochState(true);
308
+ epochDebug(`epoch bump → ${verdict.effectiveSession} (max ${verdict.firedDetail?.prevMax ?? 0} → ${verdict.firedDetail?.size ?? 0} est tokens)`);
309
+ }
310
+ else if (verdict.rotated) {
311
+ metrics.epochRotations += 1;
312
+ saveEpochState(true);
313
+ epochDebug(`stagnation rotation → ${verdict.effectiveSession} (cached ${verdict.rotatedDetail?.prevCached ?? 0}, gap ${verdict.rotatedDetail?.gap ?? 0}, streak ${verdict.rotatedDetail?.prevStreak ?? 0})`);
314
+ }
315
+ else if (verdict.observed) {
316
+ // Read-only (unusable fingerprint) requests neither persist nor
317
+ // extend the TTL (reference parity).
318
+ saveEpochState(false);
319
+ }
320
+ return {
321
+ effective: verdict.effectiveSession,
322
+ advanced: verdict.fired || verdict.rotated || epochState.epoch > 0 || epochState.rot > 0,
323
+ };
324
+ }
325
+ catch {
326
+ return { effective: baseSession, advanced: false };
327
+ }
328
+ }
329
+ /** Post-response usage note feeding the stagnation detector. */
330
+ function noteEpochUsage(body, usage) {
331
+ if (!usage || !usage.measured)
332
+ return;
333
+ metrics.cacheReadTokens += usage.cacheReadTokens;
334
+ metrics.cacheWriteTokens += usage.cacheCreationTokens;
335
+ if (!epochState)
336
+ return;
337
+ try {
338
+ // Note even a reported zero (reference parity): a 0-input turn is
339
+ // an observation that resets a continuing streak, not a gap.
340
+ const noted = (0, session_epoch_1.noteTurn)(epochState, body, usage.inputTokens, usage.cacheReadTokens + usage.cacheCreationTokens, true, epochConst, Date.now());
341
+ if (noted)
342
+ saveEpochState(false);
343
+ }
344
+ catch {
345
+ /* observability only: usage notes never fail a request */
346
+ }
347
+ }
348
+ /** Exact compaction observed on the SDK stream: advance immediately. */
349
+ function notifyCompaction() {
350
+ if (!epochEnabled || !epochState)
351
+ return;
352
+ try {
353
+ (0, session_epoch_1.bumpEpochOnCompaction)(epochState, epochConst, Date.now());
354
+ metrics.epochBumps += 1;
355
+ saveEpochState(true);
356
+ epochDebug(`exact compaction observed → epoch ${epochState.epoch}`);
357
+ }
358
+ catch {
359
+ /* never fail a session on the epoch sidecar */
360
+ }
361
+ }
362
+ // ── helpers ──────────────────────────────────────────────────────
363
+ function headerValue(headers, name) {
364
+ const raw = headers[name];
365
+ if (Array.isArray(raw))
366
+ return raw[0] ?? null;
367
+ return typeof raw === 'string' ? raw : null;
368
+ }
369
+ function checkAuth(headers) {
370
+ if (!config.bridgeSecret)
371
+ return true;
372
+ return headerValue(headers, 'x-condukt-bridge-secret') === config.bridgeSecret;
373
+ }
374
+ // The Console free tier requires a client-shaped `x-opencode-session`;
375
+ // mint one per configured session id so affinity stays stable.
376
+ const opencodeSessions = new Map();
377
+ function opencodeSessionValue(session) {
378
+ if ((0, protocol_router_1.isOpencodeSessionId)(session))
379
+ return session;
380
+ let minted = opencodeSessions.get(session);
381
+ if (!minted) {
382
+ minted = (0, protocol_router_1.mintOpencodeSessionId)();
383
+ opencodeSessions.set(session, minted);
384
+ }
385
+ return minted;
386
+ }
387
+ function buildUpstreamHeaders(clientHeaders, protocol, effectiveSession, eraAdvanced) {
388
+ const h = {
389
+ 'content-type': 'application/json',
390
+ accept: 'application/json',
391
+ 'user-agent': opencodeBase ? opencodeUserAgent : userAgent,
392
+ };
393
+ if (protocol === 'anthropic') {
394
+ if (config.upstreamApiKey)
395
+ h['x-api-key'] = config.upstreamApiKey;
396
+ h['anthropic-version'] = headerValue(clientHeaders, 'anthropic-version') ?? '2023-06-01';
397
+ const beta = headerValue(clientHeaders, 'anthropic-beta');
398
+ if (beta)
399
+ h['anthropic-beta'] = beta;
400
+ }
401
+ else if (config.upstreamApiKey) {
402
+ h['authorization'] = `Bearer ${config.upstreamApiKey}`;
403
+ }
404
+ if (baseSession) {
405
+ if (opencodeBase) {
406
+ // Era 0 keeps the configured base verbatim (client-shaped ids
407
+ // pass through, as before); post-bump eras mint a fresh
408
+ // client-shaped id per era value so affinity rotates with the
409
+ // cache namespace.
410
+ h['x-opencode-session'] = opencodeSessionValue(eraAdvanced ? effectiveSession : baseSession);
411
+ }
412
+ else {
413
+ // Non-OpenCode hosts get the literal era suffix from birth
414
+ // (verbatim again under the epoch kill-switch).
415
+ h['x-opencode-session'] = effectiveSession;
416
+ }
417
+ }
418
+ if (opencodeBase)
419
+ h['x-opencode-client'] = 'cli';
420
+ return h;
421
+ }
422
+ function resolveForModel(requested) {
423
+ const upstreamModel = config.modelAliasMap?.[requested] ?? requested;
424
+ const blocked = (0, protocol_router_1.blockedReason)(upstreamModel);
425
+ const protocol = config.protocol ??
426
+ (0, protocol_router_1.resolveProtocol)(upstreamModel, { baseUrl: config.upstreamBaseUrl, protocolMap: config.protocolMap });
427
+ // Zen gap-fill: Zen-only models use the Console base when available.
428
+ const base = (0, protocol_router_1.recognizeBase)(config.upstreamBaseUrl) === 'opencode' ? (0, protocol_router_1.resolveBase)(upstreamModel) : 'go';
429
+ const epSet = base === 'zen' ? zenEndpoints : endpoints;
430
+ return { protocol, upstreamModel, endpoint: epSet[protocol], blocked };
431
+ }
432
+ function stripCacheFields(body) {
433
+ const copy = { ...body };
434
+ delete copy['prompt_cache_key'];
435
+ delete copy['prompt_cache_retention'];
436
+ return copy;
437
+ }
438
+ function stampCacheKey(body, upstreamModel, effectiveSession) {
439
+ if (!cacheKeyEnabled)
440
+ return body;
441
+ if ((0, bridge_cache_1.cacheKeyExcluded)(upstreamModel) || downgrade.has(upstreamModel)) {
442
+ return stripCacheFields(body);
443
+ }
444
+ const key = (0, bridge_cache_1.cacheKeyFor)(effectiveSession || baseSession || config.sessionId);
445
+ if (!key)
446
+ return body;
447
+ metrics.cacheStampedCount += 1;
448
+ return { ...body, prompt_cache_key: key, prompt_cache_retention: '24h' };
449
+ }
450
+ async function readBody(req) {
451
+ const chunks = [];
452
+ let total = 0;
453
+ let tooLarge = false;
454
+ for await (const chunk of req) {
455
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
456
+ total += buf.length;
457
+ if (total > maxBodyBytes) {
458
+ tooLarge = true;
459
+ break;
460
+ }
461
+ chunks.push(buf);
462
+ }
463
+ if (tooLarge) {
464
+ req.resume(); // drain so keep-alive connections do not stall
465
+ return { ok: false, tooLarge: true };
466
+ }
467
+ return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
468
+ }
469
+ function sendJson(res, status, payload) {
470
+ res.statusCode = status;
471
+ res.setHeader('content-type', 'application/json');
472
+ res.end(payload);
473
+ }
474
+ function sendError(res, status, message) {
475
+ sendJson(res, status, anthropicErrorPayload(anthropicErrorTypeForStatus(status), message));
476
+ }
477
+ /**
478
+ * Read an upstream body with keepalive pings and idle detection.
479
+ * `emit` receives decoded text chunks and may throw (translators do,
480
+ * on fail-closed conditions); the error propagates to the caller.
481
+ */
482
+ async function pump(res, reader, emit) {
483
+ const decoder = new TextDecoder();
484
+ let pending = reader.read();
485
+ let lastData = Date.now();
486
+ while (true) {
487
+ const state = { settled: false, result: null, error: null };
488
+ await new Promise((resolveRace) => {
489
+ const timer = setTimeout(() => resolveRace(), keepAliveMs);
490
+ pending.then((r) => {
491
+ clearTimeout(timer);
492
+ state.settled = true;
493
+ state.result = r;
494
+ resolveRace();
495
+ }, (err) => {
496
+ clearTimeout(timer);
497
+ state.settled = true;
498
+ state.error = err;
499
+ resolveRace();
500
+ });
501
+ });
502
+ if (!state.settled) {
503
+ if (Date.now() - lastData > idleTimeoutMs)
504
+ throw new Error('upstream idle timeout');
505
+ if (!res.writableEnded)
506
+ res.write(': ping\n\n');
507
+ continue;
508
+ }
509
+ if (state.error)
510
+ throw state.error;
511
+ const result = state.result;
512
+ if (!result || result.done)
513
+ return;
514
+ lastData = Date.now();
515
+ if (result.value && result.value.length > 0) {
516
+ emit(decoder.decode(result.value, { stream: true }));
517
+ }
518
+ pending = reader.read();
519
+ }
520
+ }
521
+ // ── request routing ──────────────────────────────────────────────
522
+ async function handleRequest(req, res) {
523
+ const method = req.method ?? 'GET';
524
+ let pathname = '/';
525
+ try {
526
+ pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname.replace(/\/+$/, '') || '/';
527
+ }
528
+ catch {
529
+ sendError(res, 400, 'invalid request URL');
530
+ return;
531
+ }
532
+ if (!checkAuth(req.headers)) {
533
+ sendError(res, 401, 'unauthorized');
534
+ return;
535
+ }
536
+ if (method === 'GET' && pathname === '/v1/models') {
537
+ const ids = Object.keys(config.modelAliasMap ?? {});
538
+ const now = new Date().toISOString();
539
+ const data = ids.map((id) => ({ type: 'model', id, display_name: id, created_at: now }));
540
+ sendJson(res, 200, JSON.stringify({
541
+ data,
542
+ has_more: false,
543
+ first_id: data[0]?.id ?? null,
544
+ last_id: data[data.length - 1]?.id ?? null,
545
+ }));
546
+ return;
547
+ }
548
+ if (method !== 'POST' || (pathname !== '/v1/messages' && pathname !== '/v1/messages/count_tokens')) {
549
+ sendError(res, 404, 'not found');
550
+ return;
551
+ }
552
+ const bodyResult = await readBody(req);
553
+ if (!bodyResult.ok) {
554
+ sendError(res, 413, 'request too large');
555
+ return;
556
+ }
557
+ let body;
558
+ try {
559
+ const parsed = JSON.parse(bodyResult.text || '{}');
560
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
561
+ throw new Error('not an object');
562
+ body = parsed;
563
+ }
564
+ catch {
565
+ sendError(res, 400, 'invalid JSON request body');
566
+ return;
567
+ }
568
+ metrics.requestCount += 1;
569
+ if (pathname === '/v1/messages/count_tokens') {
570
+ sendJson(res, 200, JSON.stringify({ input_tokens: (0, translate_chat_1.estimateInputTokens)(body) }));
571
+ return;
572
+ }
573
+ const requested = typeof body['model'] === 'string' ? body['model'] : '';
574
+ if (!requested) {
575
+ sendError(res, 400, 'model is required');
576
+ return;
577
+ }
578
+ const { protocol, upstreamModel, endpoint, blocked } = resolveForModel(requested);
579
+ if (blocked) {
580
+ metrics.errorCount += 1;
581
+ sendError(res, 400, `model ${requested} is not servable: ${blocked}`);
582
+ return;
583
+ }
584
+ metrics.protocolCounts[protocol] += 1;
585
+ const epochVerdict = resolveEpochVerdict(body);
586
+ const upstreamHeaders = buildUpstreamHeaders(req.headers, protocol, epochVerdict.effective, epochVerdict.advanced);
587
+ if (protocol === 'anthropic') {
588
+ await handleAnthropicPassthrough(res, body, upstreamModel, endpoint, upstreamHeaders);
589
+ return;
590
+ }
591
+ if (protocol === 'openai') {
592
+ await handleChat(res, body, requested, upstreamModel, endpoint, upstreamHeaders, epochVerdict.effective);
593
+ return;
594
+ }
595
+ await handleResponses(res, body, requested, upstreamModel, endpoint, upstreamHeaders, epochVerdict.effective);
596
+ }
597
+ // ── Anthropic pass-through ───────────────────────────────────────
598
+ async function handleAnthropicPassthrough(res, body, upstreamModel, endpoint, headers) {
599
+ const stream = !!body['stream'];
600
+ const payload = JSON.stringify({ ...body, model: upstreamModel });
601
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
602
+ const ts = timeoutSignal(requestTimeoutMs);
603
+ let upstream;
604
+ try {
605
+ upstream = await fetch(endpoint, {
606
+ method: 'POST',
607
+ headers: { ...headers, accept: stream ? 'text/event-stream' : 'application/json' },
608
+ body: payload,
609
+ signal: ts.signal,
610
+ });
611
+ }
612
+ catch (e) {
613
+ ts.clear();
614
+ if (attempt < maxAttempts && !res.headersSent) {
615
+ await sleep(150 * attempt);
616
+ continue;
617
+ }
618
+ metrics.errorCount += 1;
619
+ sendError(res, 502, `upstream request failed: ${e instanceof Error ? e.message : String(e)}`);
620
+ return;
621
+ }
622
+ ts.clear();
623
+ if (!upstream.ok) {
624
+ const text = await upstream.text().catch(() => '');
625
+ if (isRetryableStatus(upstream.status) && attempt < maxAttempts && !res.headersSent) {
626
+ await sleep(150 * attempt);
627
+ continue;
628
+ }
629
+ metrics.errorCount += 1;
630
+ sendError(res, upstream.status || 502, `HTTP ${upstream.status}: ${text.slice(0, 1000)}`);
631
+ return;
632
+ }
633
+ if (!stream) {
634
+ const buf = Buffer.from(await upstream.arrayBuffer());
635
+ try {
636
+ const parsed = JSON.parse(buf.toString('utf8'));
637
+ noteEpochUsage(body, (0, bridge_cache_1.upstreamUsageFromAnthropic)(parsed['usage']));
638
+ }
639
+ catch {
640
+ /* non-JSON relay: no usage to note */
641
+ }
642
+ res.statusCode = 200;
643
+ res.setHeader('content-type', upstream.headers.get('content-type') ?? 'application/json');
644
+ res.end(buf);
645
+ return;
646
+ }
647
+ res.statusCode = 200;
648
+ res.setHeader('content-type', 'text/event-stream');
649
+ res.setHeader('cache-control', 'no-cache');
650
+ if (!upstream.body) {
651
+ res.end();
652
+ return;
653
+ }
654
+ // Raw relay with a passive usage tap: message_start carries the
655
+ // input/cache counts, message_delta the output count. Bytes are
656
+ // forwarded untouched; the tap only reads.
657
+ const usageReader = (0, sse_1.createLineReader)();
658
+ let anthropicUsage = {};
659
+ const tapUsage = (lines) => {
660
+ for (const line of lines) {
661
+ const data = (0, sse_1.parseDataLine)(line);
662
+ if (!data)
663
+ continue;
664
+ try {
665
+ const ev = JSON.parse(data);
666
+ if (ev['type'] === 'message_start') {
667
+ const u = ev['message']?.['usage'];
668
+ if (u && typeof u === 'object') {
669
+ anthropicUsage = { ...anthropicUsage, ...u };
670
+ }
671
+ }
672
+ else if (ev['type'] === 'message_delta' && ev['usage'] && typeof ev['usage'] === 'object') {
673
+ anthropicUsage = { ...anthropicUsage, ...ev['usage'] };
674
+ }
675
+ }
676
+ catch {
677
+ /* non-JSON SSE payload */
678
+ }
679
+ }
680
+ };
681
+ try {
682
+ await pump(res, upstream.body.getReader(), (text) => {
683
+ if (!res.writableEnded)
684
+ res.write(text);
685
+ tapUsage(usageReader.push(text));
686
+ });
687
+ // Drain an unterminated trailing line (usage-only event at EOF).
688
+ tapUsage(usageReader.flush());
689
+ noteEpochUsage(body, (0, bridge_cache_1.upstreamUsageFromAnthropic)(Object.keys(anthropicUsage).length > 0 ? anthropicUsage : null));
690
+ }
691
+ catch (e) {
692
+ metrics.errorCount += 1;
693
+ if (!res.writableEnded) {
694
+ res.write(`event: error\ndata: ${anthropicErrorPayload('api_error', e instanceof Error ? e.message : String(e))}\n\n`);
695
+ }
696
+ }
697
+ finally {
698
+ res.end();
699
+ }
700
+ return;
701
+ }
702
+ if (!res.writableEnded)
703
+ sendError(res, 502, 'upstream request failed after retries');
704
+ }
705
+ /**
706
+ * POST `payload` with retries. Returns the first successful response
707
+ * (body not yet read) or an error carrying the last upstream failure.
708
+ * Cache-field 400s downgrade the model once (stripping cache fields)
709
+ * and retry.
710
+ */
711
+ async function postWithRetry(payload, endpoint, headers, upstreamModel, allowCacheDowngrade, streaming) {
712
+ let current = payload;
713
+ let stripped = false;
714
+ let lastError = null;
715
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
716
+ const ts = streaming ? timeoutSignal(requestTimeoutMs) : null; // stream: headers-only timeout
717
+ let resp;
718
+ try {
719
+ resp = await fetch(endpoint, {
720
+ method: 'POST',
721
+ headers,
722
+ body: JSON.stringify(current),
723
+ signal: streaming ? ts?.signal : AbortSignal.timeout(requestTimeoutMs),
724
+ });
725
+ }
726
+ catch (e) {
727
+ ts?.clear();
728
+ lastError = e instanceof Error ? e : new Error(String(e));
729
+ if (attempt < maxAttempts) {
730
+ await sleep(150 * attempt);
731
+ continue;
732
+ }
733
+ return { resp: null, error: lastError };
734
+ }
735
+ ts?.clear();
736
+ if (resp.ok)
737
+ return { resp, error: null };
738
+ const text = await resp.text().catch(() => '');
739
+ if (resp.status === 400 && allowCacheDowngrade && !stripped && (0, bridge_cache_1.isCacheFieldRejection)(400, text)) {
740
+ downgrade.add(upstreamModel);
741
+ metrics.downgradeCount += 1;
742
+ persist();
743
+ current = stripCacheFields(current);
744
+ stripped = true;
745
+ continue; // bounded by maxAttempts
746
+ }
747
+ if (isRetryableStatus(resp.status) && attempt < maxAttempts) {
748
+ await sleep(150 * attempt);
749
+ continue;
750
+ }
751
+ return { resp, error: new Error(`HTTP ${resp.status}: ${text.slice(0, 1000)}`) };
752
+ }
753
+ return { resp: null, error: lastError ?? new Error('upstream request failed') };
754
+ }
755
+ async function readJson(resp) {
756
+ try {
757
+ const data = await resp.json();
758
+ return data && typeof data === 'object' ? data : null;
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ }
764
+ // ── OpenAI chat/completions translation ──────────────────────────
765
+ async function handleChat(res, body, requested, upstreamModel, endpoint, headers, effectiveSession) {
766
+ const stream = !!body['stream'];
767
+ const oaiBody = (0, translate_chat_1.anthropicToOpenAI)(body, upstreamModel);
768
+ try {
769
+ // user_id carries the POST-epoch session (reference passes the
770
+ // era-suffixed id), so KV-cache isolation follows the same
771
+ // namespace separation the routing header/cache key do.
772
+ (0, translate_chat_1.applyChatExtras)(oaiBody, upstreamModel, effectiveSession || config.sessionId, (0, translate_chat_1.extractEffort)(body), (0, translate_chat_1.extractThinkingType)(body));
773
+ }
774
+ catch (e) {
775
+ metrics.errorCount += 1;
776
+ sendError(res, 400, e instanceof Error ? e.message : String(e));
777
+ return;
778
+ }
779
+ const payload = stampCacheKey(oaiBody, upstreamModel, effectiveSession);
780
+ // The OpenCode client always streams chat with usage reporting enabled;
781
+ // mirror that for OpenCode hosts (Console gates read the request shape).
782
+ if (stream && opencodeBase && payload['stream_options'] === undefined) {
783
+ payload['stream_options'] = { include_usage: true };
784
+ }
785
+ const estimatedInput = (0, translate_chat_1.estimateInputTokens)(body);
786
+ if (!stream) {
787
+ const outcome = await postWithRetry(payload, endpoint, headers, upstreamModel, true, false);
788
+ if (outcome.resp === null) {
789
+ metrics.errorCount += 1;
790
+ sendError(res, 502, outcome.error?.message ?? 'upstream request failed');
791
+ return;
792
+ }
793
+ if (!outcome.resp.ok) {
794
+ metrics.errorCount += 1;
795
+ const text = await outcome.resp.text().catch(() => '');
796
+ sendError(res, outcome.resp.status || 502, `HTTP ${outcome.resp.status}: ${text.slice(0, 1000)}`);
797
+ return;
798
+ }
799
+ const data = await readJson(outcome.resp);
800
+ if (!data) {
801
+ metrics.errorCount += 1;
802
+ sendError(res, 502, 'upstream returned a non-JSON body');
803
+ return;
804
+ }
805
+ let converted;
806
+ try {
807
+ converted = JSON.stringify((0, translate_chat_1.openAIToAnthropic)(data, requested));
808
+ }
809
+ catch (e) {
810
+ metrics.errorCount += 1;
811
+ sendError(res, 502, e instanceof Error ? e.message : String(e));
812
+ return;
813
+ }
814
+ // Note usage only after successful conversion (reference parity:
815
+ // a poisoned turn is an observation gap, not a streak event).
816
+ noteEpochUsage(body, (0, bridge_cache_1.upstreamUsageFromOpenAI)(data['usage']));
817
+ sendJson(res, 200, converted);
818
+ return;
819
+ }
820
+ // Streaming: fetch before sending SSE headers so pre-stream
821
+ // failures produce a clean JSON error the SDK understands.
822
+ let headersSent = false;
823
+ let clientStarted = false;
824
+ const makeTranslator = () => new translate_chat_1.ChatStreamTranslator({
825
+ requestedModel: requested,
826
+ estimatedInput,
827
+ onFinalUsage: (u) => noteEpochUsage(body, u),
828
+ });
829
+ let translator = makeTranslator();
830
+ const writeFrames = (frames) => {
831
+ for (const f of frames) {
832
+ if (res.writableEnded)
833
+ return;
834
+ res.write(f);
835
+ clientStarted = true;
836
+ }
837
+ };
838
+ const extraHeaders = { ...headers, accept: 'text/event-stream' };
839
+ let currentPayload = payload;
840
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
841
+ // Fresh translator per attempt: a failed attempt may have consumed
842
+ // partial stream bytes (line buffer, usage-only chunk) without
843
+ // emitting client frames, and that state must not leak into the
844
+ // retry's stream.
845
+ translator = makeTranslator();
846
+ const ts = timeoutSignal(requestTimeoutMs);
847
+ let upstream;
848
+ try {
849
+ upstream = await fetch(endpoint, {
850
+ method: 'POST',
851
+ headers: extraHeaders,
852
+ body: JSON.stringify(currentPayload),
853
+ signal: ts.signal,
854
+ });
855
+ }
856
+ catch (e) {
857
+ ts.clear();
858
+ if (!clientStarted && attempt < maxAttempts) {
859
+ await sleep(150 * attempt);
860
+ continue;
861
+ }
862
+ metrics.errorCount += 1;
863
+ if (!headersSent) {
864
+ sendError(res, 502, e instanceof Error ? e.message : String(e));
865
+ return;
866
+ }
867
+ writeFrames(translator.fail(e instanceof Error ? e.message : String(e)));
868
+ res.end();
869
+ return;
870
+ }
871
+ ts.clear();
872
+ if (!upstream.ok) {
873
+ const text = await upstream.text().catch(() => '');
874
+ if (upstream.status === 400 &&
875
+ !downgrade.has(upstreamModel) &&
876
+ (0, bridge_cache_1.isCacheFieldRejection)(400, text)) {
877
+ downgrade.add(upstreamModel);
878
+ metrics.downgradeCount += 1;
879
+ persist();
880
+ currentPayload = stripCacheFields(currentPayload);
881
+ continue; // bounded by maxAttempts
882
+ }
883
+ if (isRetryableStatus(upstream.status) && !clientStarted && attempt < maxAttempts) {
884
+ await sleep(150 * attempt);
885
+ continue;
886
+ }
887
+ metrics.errorCount += 1;
888
+ if (!headersSent) {
889
+ sendError(res, upstream.status || 502, `HTTP ${upstream.status}: ${text.slice(0, 1000)}`);
890
+ return;
891
+ }
892
+ writeFrames(translator.fail(`HTTP ${upstream.status}: ${text.slice(0, 300)}`));
893
+ res.end();
894
+ return;
895
+ }
896
+ if (!headersSent) {
897
+ res.statusCode = 200;
898
+ res.setHeader('content-type', 'text/event-stream');
899
+ res.setHeader('cache-control', 'no-cache');
900
+ headersSent = true;
901
+ }
902
+ if (!upstream.body) {
903
+ writeFrames(translator.finish());
904
+ res.end();
905
+ return;
906
+ }
907
+ try {
908
+ await pump(res, upstream.body.getReader(), (text) => writeFrames(translator.push(text)));
909
+ writeFrames(translator.finish());
910
+ res.end();
911
+ return;
912
+ }
913
+ catch (e) {
914
+ if (!clientStarted && attempt < maxAttempts)
915
+ continue;
916
+ metrics.errorCount += 1;
917
+ writeFrames(translator.fail(e instanceof Error ? e.message : String(e)));
918
+ res.end();
919
+ return;
920
+ }
921
+ }
922
+ if (!res.writableEnded) {
923
+ metrics.errorCount += 1;
924
+ writeFrames(translator.fail('upstream request failed after retries'));
925
+ res.end();
926
+ }
927
+ }
928
+ // ── OpenAI Responses translation ─────────────────────────────────
929
+ async function handleResponses(res, body, requested, upstreamModel, endpoint, headers, _effectiveSession) {
930
+ const stream = !!body['stream'];
931
+ let rBody;
932
+ try {
933
+ rBody = (0, translate_responses_1.anthropicToResponses)(body, upstreamModel);
934
+ }
935
+ catch (e) {
936
+ metrics.errorCount += 1;
937
+ sendError(res, 400, e instanceof Error ? e.message : String(e));
938
+ return;
939
+ }
940
+ const estimatedInput = (0, translate_chat_1.estimateInputTokens)(body);
941
+ if (!stream) {
942
+ const outcome = await postWithRetry(rBody, endpoint, headers, upstreamModel, false, false);
943
+ if (outcome.resp === null) {
944
+ metrics.errorCount += 1;
945
+ sendError(res, 502, outcome.error?.message ?? 'upstream request failed');
946
+ return;
947
+ }
948
+ if (!outcome.resp.ok) {
949
+ metrics.errorCount += 1;
950
+ const text = await outcome.resp.text().catch(() => '');
951
+ sendError(res, outcome.resp.status || 502, `HTTP ${outcome.resp.status}: ${text.slice(0, 1000)}`);
952
+ return;
953
+ }
954
+ const data = await readJson(outcome.resp);
955
+ if (!data) {
956
+ metrics.errorCount += 1;
957
+ sendError(res, 502, 'upstream returned a non-JSON body');
958
+ return;
959
+ }
960
+ let converted;
961
+ try {
962
+ converted = JSON.stringify((0, translate_responses_1.responsesToAnthropic)(data, requested));
963
+ }
964
+ catch (e) {
965
+ metrics.errorCount += 1;
966
+ sendError(res, 502, e instanceof Error ? e.message : String(e));
967
+ return;
968
+ }
969
+ // Note usage only after successful conversion (reference parity).
970
+ noteEpochUsage(body, (0, bridge_cache_1.upstreamUsageFromResponses)(data['usage']));
971
+ sendJson(res, 200, converted);
972
+ return;
973
+ }
974
+ let headersSent = false;
975
+ let clientStarted = false;
976
+ const makeTranslator = () => new translate_responses_1.ResponsesStreamTranslator({
977
+ requestedModel: requested,
978
+ estimatedInput,
979
+ onFinalUsage: (u) => noteEpochUsage(body, u),
980
+ });
981
+ let translator = makeTranslator();
982
+ const writeFrames = (frames) => {
983
+ for (const f of frames) {
984
+ if (res.writableEnded)
985
+ return;
986
+ res.write(f);
987
+ clientStarted = true;
988
+ }
989
+ };
990
+ const extraHeaders = { ...headers, accept: 'text/event-stream' };
991
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
992
+ // Fresh translator per attempt (see the chat handler note).
993
+ translator = makeTranslator();
994
+ const ts = timeoutSignal(requestTimeoutMs);
995
+ let upstream;
996
+ try {
997
+ upstream = await fetch(endpoint, {
998
+ method: 'POST',
999
+ headers: extraHeaders,
1000
+ body: JSON.stringify(rBody),
1001
+ signal: ts.signal,
1002
+ });
1003
+ }
1004
+ catch (e) {
1005
+ ts.clear();
1006
+ if (!clientStarted && attempt < maxAttempts) {
1007
+ await sleep(150 * attempt);
1008
+ continue;
1009
+ }
1010
+ metrics.errorCount += 1;
1011
+ if (!headersSent) {
1012
+ sendError(res, 502, e instanceof Error ? e.message : String(e));
1013
+ return;
1014
+ }
1015
+ writeFrames(translator.fail(e instanceof Error ? e.message : String(e)));
1016
+ res.end();
1017
+ return;
1018
+ }
1019
+ ts.clear();
1020
+ if (!upstream.ok) {
1021
+ const text = await upstream.text().catch(() => '');
1022
+ if (isRetryableStatus(upstream.status) && !clientStarted && attempt < maxAttempts) {
1023
+ await sleep(150 * attempt);
1024
+ continue;
1025
+ }
1026
+ metrics.errorCount += 1;
1027
+ if (!headersSent) {
1028
+ sendError(res, upstream.status || 502, `HTTP ${upstream.status}: ${text.slice(0, 1000)}`);
1029
+ return;
1030
+ }
1031
+ writeFrames(translator.fail(`HTTP ${upstream.status}: ${text.slice(0, 300)}`));
1032
+ res.end();
1033
+ return;
1034
+ }
1035
+ if (!headersSent) {
1036
+ res.statusCode = 200;
1037
+ res.setHeader('content-type', 'text/event-stream');
1038
+ res.setHeader('cache-control', 'no-cache');
1039
+ headersSent = true;
1040
+ }
1041
+ if (!upstream.body) {
1042
+ writeFrames(translator.endOfStream());
1043
+ res.end();
1044
+ return;
1045
+ }
1046
+ const lineReader = (0, sse_1.createLineReader)();
1047
+ const feedLines = (lines) => {
1048
+ for (const line of lines) {
1049
+ const data = (0, sse_1.parseDataLine)(line);
1050
+ if (!data)
1051
+ continue;
1052
+ let event;
1053
+ try {
1054
+ const parsed = JSON.parse(data);
1055
+ if (!parsed || typeof parsed !== 'object')
1056
+ continue;
1057
+ event = parsed;
1058
+ }
1059
+ catch {
1060
+ continue;
1061
+ }
1062
+ if (typeof event['type'] !== 'string')
1063
+ continue;
1064
+ writeFrames(translator.push(event));
1065
+ if (translator.isTerminal)
1066
+ break;
1067
+ }
1068
+ };
1069
+ try {
1070
+ await pump(res, upstream.body.getReader(), (text) => {
1071
+ // Write per event so a fail-closed throw mid-chunk cannot
1072
+ // discard frames already produced for earlier events.
1073
+ feedLines(lineReader.push(text));
1074
+ });
1075
+ // Drain an unterminated trailing line before finishing.
1076
+ if (!translator.isTerminal)
1077
+ feedLines(lineReader.flush());
1078
+ if (!translator.isTerminal)
1079
+ writeFrames(translator.endOfStream());
1080
+ res.end();
1081
+ return;
1082
+ }
1083
+ catch (e) {
1084
+ if (!clientStarted && attempt < maxAttempts)
1085
+ continue;
1086
+ metrics.errorCount += 1;
1087
+ if (e instanceof translate_responses_1.StreamTranslationError)
1088
+ writeFrames(e.frames);
1089
+ writeFrames(translator.fail(e instanceof Error ? e.message : String(e)));
1090
+ res.end();
1091
+ return;
1092
+ }
1093
+ }
1094
+ if (!res.writableEnded) {
1095
+ metrics.errorCount += 1;
1096
+ writeFrames(translator.fail('upstream request failed after retries'));
1097
+ res.end();
1098
+ }
1099
+ }
1100
+ // ── server / handle ──────────────────────────────────────────────
1101
+ const server = node_http_1.default.createServer((req, res) => {
1102
+ void handleRequest(req, res).catch((e) => {
1103
+ metrics.errorCount += 1;
1104
+ if (!res.headersSent) {
1105
+ sendError(res, 500, e instanceof Error ? e.message : String(e));
1106
+ }
1107
+ else {
1108
+ try {
1109
+ res.end();
1110
+ }
1111
+ catch {
1112
+ /* ignore */
1113
+ }
1114
+ }
1115
+ });
1116
+ });
1117
+ let actualPort = config.port;
1118
+ const handle = {
1119
+ get port() {
1120
+ return actualPort;
1121
+ },
1122
+ get baseUrl() {
1123
+ return `http://127.0.0.1:${actualPort}`;
1124
+ },
1125
+ endpoints,
1126
+ zenEndpoints,
1127
+ get metrics() {
1128
+ return {
1129
+ requestCount: metrics.requestCount,
1130
+ downgradeCount: metrics.downgradeCount,
1131
+ cacheStampedCount: metrics.cacheStampedCount,
1132
+ errorCount: metrics.errorCount,
1133
+ epochBumps: metrics.epochBumps,
1134
+ epochRotations: metrics.epochRotations,
1135
+ cacheReadTokens: metrics.cacheReadTokens,
1136
+ cacheWriteTokens: metrics.cacheWriteTokens,
1137
+ protocolCounts: { ...metrics.protocolCounts },
1138
+ };
1139
+ },
1140
+ notifyCompaction: () => notifyCompaction(),
1141
+ close: () => new Promise((resolveClose) => {
1142
+ try {
1143
+ if (epochStore && epochStateFile && baseSession && epochState) {
1144
+ (0, epoch_store_1.writeEpochStore)(epochStateFile, epochStore, Date.now(), epochConst);
1145
+ }
1146
+ }
1147
+ catch {
1148
+ /* persistence is best effort */
1149
+ }
1150
+ try {
1151
+ server.closeAllConnections();
1152
+ }
1153
+ catch {
1154
+ /* older runtimes */
1155
+ }
1156
+ server.close(() => resolveClose());
1157
+ }),
1158
+ };
1159
+ server.on('error', (err) => {
1160
+ reject(err);
1161
+ });
1162
+ server.listen(config.port, '127.0.0.1', () => {
1163
+ const addr = server.address();
1164
+ if (addr && typeof addr === 'object')
1165
+ actualPort = addr.port;
1166
+ resolve(handle);
1167
+ });
1168
+ });
1169
+ }
1170
+ //# sourceMappingURL=protocol-bridge.js.map