responses-proxy 0.1.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 (161) hide show
  1. package/README.md +56 -0
  2. package/cli.js +118 -0
  3. package/dist/anthropic-messages.js +383 -0
  4. package/dist/anthropic-messages.test.js +209 -0
  5. package/dist/audit-log.js +138 -0
  6. package/dist/audit-log.test.js +480 -0
  7. package/dist/billing-expiration.js +70 -0
  8. package/dist/billing-expiration.test.js +114 -0
  9. package/dist/billing.js +716 -0
  10. package/dist/billing.test.js +228 -0
  11. package/dist/chatgpt-oauth-store.js +240 -0
  12. package/dist/chatgpt-oauth-store.test.js +88 -0
  13. package/dist/chatgpt-oauth.js +118 -0
  14. package/dist/chatgpt-oauth.test.js +63 -0
  15. package/dist/chatgpt-provider-auth.js +60 -0
  16. package/dist/chatgpt-provider-auth.test.js +101 -0
  17. package/dist/client/app-icon.svg +17 -0
  18. package/dist/client/assets/index-C7Vvhst8.js +14 -0
  19. package/dist/client/assets/index-DpqgYK3L.css +1 -0
  20. package/dist/client/favicon.svg +17 -0
  21. package/dist/client/index.html +31 -0
  22. package/dist/client-config-apply.js +345 -0
  23. package/dist/client-config-apply.test.js +185 -0
  24. package/dist/client-token-limits.js +111 -0
  25. package/dist/client-token-limits.test.js +129 -0
  26. package/dist/codex-config.js +47 -0
  27. package/dist/codex-setup.js +87 -0
  28. package/dist/codex-setup.test.js +30 -0
  29. package/dist/config.js +314 -0
  30. package/dist/cost-analytics.js +31 -0
  31. package/dist/cost-analytics.test.js +38 -0
  32. package/dist/customer-key-access.js +126 -0
  33. package/dist/customer-key-access.test.js +178 -0
  34. package/dist/customer-keys.js +209 -0
  35. package/dist/customer-keys.test.js +68 -0
  36. package/dist/customer-usage.js +18 -0
  37. package/dist/customer-usage.test.js +55 -0
  38. package/dist/dashboard-auth.js +318 -0
  39. package/dist/dashboard-auth.test.js +133 -0
  40. package/dist/dashboard-serving.test.js +235 -0
  41. package/dist/error-response.js +174 -0
  42. package/dist/error-response.test.js +88 -0
  43. package/dist/forward.js +357 -0
  44. package/dist/health-websocket-manager.js +174 -0
  45. package/dist/http-rate-limit.js +36 -0
  46. package/dist/http-rate-limit.test.js +62 -0
  47. package/dist/kiro-auth.js +136 -0
  48. package/dist/kiro-auth.test.js +234 -0
  49. package/dist/kiro-codewhisperer.js +646 -0
  50. package/dist/kiro-codewhisperer.test.js +219 -0
  51. package/dist/kiro-device-login.js +338 -0
  52. package/dist/kiro-eventstream.js +219 -0
  53. package/dist/kiro-eventstream.test.js +79 -0
  54. package/dist/kiro-forward.js +401 -0
  55. package/dist/kiro-import-cli.js +69 -0
  56. package/dist/kiro-import.js +94 -0
  57. package/dist/kiro-import.test.js +125 -0
  58. package/dist/kiro-token-store.js +196 -0
  59. package/dist/kiro-token-store.test.js +207 -0
  60. package/dist/krouter-usage.js +243 -0
  61. package/dist/model-combo-repository.js +147 -0
  62. package/dist/model-routing.js +69 -0
  63. package/dist/model-routing.test.js +41 -0
  64. package/dist/normalize-request.js +531 -0
  65. package/dist/normalize-request.test.js +277 -0
  66. package/dist/omv-public-firewall.test.js +11 -0
  67. package/dist/package.json +17 -0
  68. package/dist/prompt-cache-state.js +146 -0
  69. package/dist/prompt-cache-state.test.js +71 -0
  70. package/dist/prompt-cache.js +229 -0
  71. package/dist/provider-health-service.js +404 -0
  72. package/dist/provider-request-parameters.js +107 -0
  73. package/dist/provider-request-parameters.test.js +26 -0
  74. package/dist/provider-routing.js +114 -0
  75. package/dist/provider-routing.test.js +64 -0
  76. package/dist/provider-usage.js +314 -0
  77. package/dist/request-timeout-policy.js +61 -0
  78. package/dist/request-timeout-policy.test.js +40 -0
  79. package/dist/response-cache.js +69 -0
  80. package/dist/response-cache.test.js +28 -0
  81. package/dist/routing-combo-repository.js +300 -0
  82. package/dist/routing-engine.js +377 -0
  83. package/dist/routing-integration.js +155 -0
  84. package/dist/routing-simulation-engine.js +326 -0
  85. package/dist/rtk-layer.js +483 -0
  86. package/dist/rtk-layer.test.js +198 -0
  87. package/dist/runtime-provider-repository.js +1742 -0
  88. package/dist/runtime-provider-repository.test.js +1177 -0
  89. package/dist/schema.js +118 -0
  90. package/dist/schema.test.js +16 -0
  91. package/dist/sepay-webhook.js +87 -0
  92. package/dist/sepay-webhook.test.js +142 -0
  93. package/dist/server-body-limit.test.js +35 -0
  94. package/dist/server-client-token-limits.test.js +161 -0
  95. package/dist/server-codex-config-setup.test.js +76 -0
  96. package/dist/server-http-rate-limit.test.js +80 -0
  97. package/dist/server-response-cache.test.js +105 -0
  98. package/dist/server-routes-alias.test.js +39 -0
  99. package/dist/server-sepay-webhook-security.test.js +59 -0
  100. package/dist/server.js +5906 -0
  101. package/dist/session-log.js +178 -0
  102. package/dist/tailnet-funnel-script.test.js +33 -0
  103. package/dist/telegram-bot/actions.js +118 -0
  104. package/dist/telegram-bot/admin-actions.js +103 -0
  105. package/dist/telegram-bot/auth.js +46 -0
  106. package/dist/telegram-bot/auth.test.js +1 -0
  107. package/dist/telegram-bot/bot-identity-repository.js +189 -0
  108. package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
  109. package/dist/telegram-bot/callbacks.js +30 -0
  110. package/dist/telegram-bot/codex-config-delivery.js +38 -0
  111. package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
  112. package/dist/telegram-bot/commands/accounts.js +140 -0
  113. package/dist/telegram-bot/commands/apikey.js +737 -0
  114. package/dist/telegram-bot/commands/apply.js +265 -0
  115. package/dist/telegram-bot/commands/clients.js +13 -0
  116. package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
  117. package/dist/telegram-bot/commands/grant.js +138 -0
  118. package/dist/telegram-bot/commands/grant.test.js +217 -0
  119. package/dist/telegram-bot/commands/help.js +52 -0
  120. package/dist/telegram-bot/commands/me.js +53 -0
  121. package/dist/telegram-bot/commands/models.js +6 -0
  122. package/dist/telegram-bot/commands/oauth.js +64 -0
  123. package/dist/telegram-bot/commands/plans.js +96 -0
  124. package/dist/telegram-bot/commands/providers.js +27 -0
  125. package/dist/telegram-bot/commands/quota.js +10 -0
  126. package/dist/telegram-bot/commands/renew-user.js +139 -0
  127. package/dist/telegram-bot/commands/renew-user.test.js +184 -0
  128. package/dist/telegram-bot/commands/renew.js +1369 -0
  129. package/dist/telegram-bot/commands/renew.test.js +1633 -0
  130. package/dist/telegram-bot/commands/start.js +212 -0
  131. package/dist/telegram-bot/commands/start.test.js +280 -0
  132. package/dist/telegram-bot/commands/status.js +6 -0
  133. package/dist/telegram-bot/commands/tailscale.js +15 -0
  134. package/dist/telegram-bot/commands/tailscale.test.js +76 -0
  135. package/dist/telegram-bot/commands/test.js +51 -0
  136. package/dist/telegram-bot/commands/test.test.js +14 -0
  137. package/dist/telegram-bot/commands/usage.js +10 -0
  138. package/dist/telegram-bot/config.js +98 -0
  139. package/dist/telegram-bot/config.test.js +42 -0
  140. package/dist/telegram-bot/customer-actions.js +160 -0
  141. package/dist/telegram-bot/customer-api-keys.js +68 -0
  142. package/dist/telegram-bot/customer-billing.js +72 -0
  143. package/dist/telegram-bot/customer-workspace-repository.js +134 -0
  144. package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
  145. package/dist/telegram-bot/dashboard-login.js +39 -0
  146. package/dist/telegram-bot/format.js +140 -0
  147. package/dist/telegram-bot/grants.js +370 -0
  148. package/dist/telegram-bot/grants.test.js +290 -0
  149. package/dist/telegram-bot/index.js +85 -0
  150. package/dist/telegram-bot/message-cleanup.js +55 -0
  151. package/dist/telegram-bot/message-cleanup.test.js +77 -0
  152. package/dist/telegram-bot/message-format.js +45 -0
  153. package/dist/telegram-bot/message-format.test.js +10 -0
  154. package/dist/telegram-bot/proxy-client.js +174 -0
  155. package/dist/telegram-bot/rate-limit.js +95 -0
  156. package/dist/telegram-bot/rate-limit.test.js +58 -0
  157. package/dist/telegram-bot/sessions.js +171 -0
  158. package/dist/telegram-bot/sessions.test.js +107 -0
  159. package/dist/telegram-bot/telegram-adapter.js +126 -0
  160. package/dist/telegram-bot/worker.js +63 -0
  161. package/package.json +39 -0
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { KiroAuthError, resolveKiroCredentials } from "./kiro-auth.js";
3
+ import { CODEWHISPERER_GENERATE_PATH, KiroResponseAccumulator, buildCodeWhispererRequest, buildCodeWhispererRequestFromTurns, buildResponsesJson, buildSseDeltaFrame, buildSseFinaleFrames, buildSsePreludeFrames, collectInputText, estimateTokens, extractAssistantDelta, extractCodeWhispererError, mapModelToCodeWhisperer, newSseStreamIds, } from "./kiro-codewhisperer.js";
4
+ import { AnthropicSseEmitter, buildAnthropicMessage, } from "./anthropic-messages.js";
5
+ import { EventStreamParser } from "./kiro-eventstream.js";
6
+ function buildUsage(inputText, outputText) {
7
+ const inputTokens = estimateTokens(inputText);
8
+ const outputTokens = estimateTokens(outputText);
9
+ return {
10
+ usage: {
11
+ input_tokens: inputTokens,
12
+ output_tokens: outputTokens,
13
+ total_tokens: inputTokens + outputTokens,
14
+ },
15
+ };
16
+ }
17
+ /**
18
+ * Error thrown when the CodeWhisperer upstream rejects a request. Carries the
19
+ * upstream status + body so `server.ts` can fold it into the standard proxy error
20
+ * envelope (it mirrors the `statusCode`/`body` shape `buildUpstreamError` produces).
21
+ */
22
+ export class KiroUpstreamError extends Error {
23
+ statusCode;
24
+ body;
25
+ constructor(requestId, status, body) {
26
+ super(`[${requestId}] Kiro upstream rejected request (${status})`);
27
+ this.statusCode = status;
28
+ this.body = body;
29
+ }
30
+ }
31
+ /**
32
+ * Resolve a Kiro credential and open the `generateAssistantResponse` connection
33
+ * with an overall timeout. The CodeWhisperer request is produced by `buildRequest`
34
+ * once credentials are known (so the resolved `profileArn` and mapped `modelId` can
35
+ * be injected), letting the Responses and Anthropic paths share all auth/rotation/
36
+ * timeout/connection logic. The caller consumes `response.body` and invokes
37
+ * `cleanup()` (clears the abort timer) when finished.
38
+ */
39
+ async function openKiroStream(args) {
40
+ const fetchImpl = args.fetchImpl ?? fetch;
41
+ const credentials = await resolveKiroCredentials({
42
+ store: args.store,
43
+ rotationMode: "round_robin",
44
+ defaultRegion: args.config.KIRO_DEFAULT_REGION,
45
+ refreshLeadSeconds: args.config.KIRO_REFRESH_LEAD_SECONDS,
46
+ poolKey: args.provider.id,
47
+ fetchImpl,
48
+ });
49
+ const modelId = mapModelToCodeWhisperer(args.model, args.provider.capabilities.modelAliases);
50
+ const cwRequest = args.buildRequest({ profileArn: credentials.profileArn, modelId });
51
+ const inputText = args.inputText;
52
+ const url = `https://codewhisperer.${credentials.region}.amazonaws.com${CODEWHISPERER_GENERATE_PATH}`;
53
+ const controller = new AbortController();
54
+ const timeout = setTimeout(() => controller.abort(), args.config.REQUEST_TIMEOUT_MS);
55
+ const cleanup = () => clearTimeout(timeout);
56
+ const startedAt = Date.now();
57
+ let response;
58
+ try {
59
+ response = await fetchImpl(url, {
60
+ method: "POST",
61
+ headers: {
62
+ Authorization: `Bearer ${credentials.accessToken}`,
63
+ "Content-Type": "application/json",
64
+ Accept: "application/vnd.amazon.eventstream",
65
+ // CodeWhisperer is an AWS JSON-RPC service; X-Amz-Target selects the
66
+ // operation and the user-agent pair identifies the Kiro IDE client.
67
+ // These mirror the headers 9router sends and are required for routing.
68
+ "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
69
+ "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
70
+ "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
71
+ "Amz-Sdk-Request": "attempt=1; max=3",
72
+ "Amz-Sdk-Invocation-Id": randomUUID(),
73
+ },
74
+ body: JSON.stringify(cwRequest),
75
+ signal: controller.signal,
76
+ });
77
+ }
78
+ catch (error) {
79
+ cleanup();
80
+ const reason = error instanceof Error ? error.message : String(error);
81
+ throw new KiroUpstreamError(args.requestId, 502, `Kiro upstream request failed: ${reason}`);
82
+ }
83
+ args.logger.info({
84
+ requestId: args.requestId,
85
+ provider: args.provider.id,
86
+ accountId: credentials.accountId,
87
+ upstreamStatus: response.status,
88
+ connectMs: Date.now() - startedAt,
89
+ modelId,
90
+ }, "kiro codewhisperer response received");
91
+ if (!response.ok) {
92
+ const errorBody = await response.text().catch(() => "");
93
+ cleanup();
94
+ throw new KiroUpstreamError(args.requestId, response.status, errorBody);
95
+ }
96
+ return { response, model: args.model || modelId, inputText, cleanup };
97
+ }
98
+ /** Non-streaming Kiro path: buffers the stream, returns a Responses JSON payload + usage. */
99
+ export async function forwardKiroJson(args) {
100
+ const opened = await openKiroStream({
101
+ ...args,
102
+ model: typeof args.body.model === "string" ? args.body.model : "",
103
+ inputText: collectInputText(args.body),
104
+ buildRequest: ({ profileArn, modelId }) => buildCodeWhispererRequest({ body: args.body, modelId, profileArn }),
105
+ });
106
+ try {
107
+ const buffer = Buffer.from(await opened.response.arrayBuffer());
108
+ const parser = new EventStreamParser();
109
+ const messages = parser.push(buffer);
110
+ let text = "";
111
+ for (const message of messages) {
112
+ const errText = extractCodeWhispererError(message);
113
+ if (errText) {
114
+ throw new KiroUpstreamError(args.requestId, 502, errText);
115
+ }
116
+ text += extractAssistantDelta(message);
117
+ }
118
+ const usage = buildUsage(opened.inputText, text);
119
+ const payload = buildResponsesJson({
120
+ text,
121
+ model: opened.model,
122
+ inputText: opened.inputText,
123
+ });
124
+ return { payload, usage };
125
+ }
126
+ finally {
127
+ opened.cleanup();
128
+ }
129
+ }
130
+ /**
131
+ * Streaming Kiro path: reads the CodeWhisperer event-stream incrementally and
132
+ * writes Responses SSE frames as text arrives, so clients see real token-by-token
133
+ * output. Returns usage totals once the stream completes.
134
+ */
135
+ export async function forwardKiroSse(args) {
136
+ const opened = await openKiroStream({
137
+ ...args,
138
+ model: typeof args.body.model === "string" ? args.body.model : "",
139
+ inputText: collectInputText(args.body),
140
+ buildRequest: ({ profileArn, modelId }) => buildCodeWhispererRequest({ body: args.body, modelId, profileArn }),
141
+ });
142
+ const { response } = opened;
143
+ if (!response.body) {
144
+ opened.cleanup();
145
+ throw new KiroUpstreamError(args.requestId, 502, "Kiro upstream returned an empty body");
146
+ }
147
+ const ids = newSseStreamIds(opened.model);
148
+ const parser = new EventStreamParser();
149
+ const reader = response.body.getReader();
150
+ let headersSent = false;
151
+ let fullText = "";
152
+ let streamError;
153
+ const ensureHeaders = () => {
154
+ if (headersSent) {
155
+ return;
156
+ }
157
+ args.responseRaw.setHeader("Content-Type", "text/event-stream");
158
+ args.responseRaw.setHeader("Cache-Control", "no-cache, no-transform");
159
+ args.responseRaw.setHeader("Connection", "keep-alive");
160
+ args.responseRaw.setHeader("X-Accel-Buffering", "no");
161
+ args.responseRaw.flushHeaders?.();
162
+ for (const frame of buildSsePreludeFrames(ids)) {
163
+ args.responseRaw.write(frame);
164
+ }
165
+ headersSent = true;
166
+ };
167
+ let idleTimer;
168
+ const resetIdleTimer = () => {
169
+ if (idleTimer) {
170
+ clearTimeout(idleTimer);
171
+ }
172
+ idleTimer = setTimeout(() => {
173
+ args.logger.warn({ requestId: args.requestId }, "kiro upstream stream idle timeout reached");
174
+ void reader.cancel().catch(() => undefined);
175
+ }, args.config.STREAM_IDLE_TIMEOUT_MS);
176
+ };
177
+ try {
178
+ resetIdleTimer();
179
+ while (true) {
180
+ const { done, value } = await reader.read();
181
+ if (done) {
182
+ break;
183
+ }
184
+ resetIdleTimer();
185
+ if (!value) {
186
+ continue;
187
+ }
188
+ const messages = parser.push(value);
189
+ for (const message of messages) {
190
+ const errText = extractCodeWhispererError(message);
191
+ if (errText) {
192
+ streamError = errText;
193
+ // If we have not emitted anything yet, surface a clean error envelope.
194
+ if (!headersSent) {
195
+ throw new KiroUpstreamError(args.requestId, 502, errText);
196
+ }
197
+ continue;
198
+ }
199
+ const delta = extractAssistantDelta(message);
200
+ if (delta) {
201
+ ensureHeaders();
202
+ fullText += delta;
203
+ args.responseRaw.write(buildSseDeltaFrame(ids, delta));
204
+ }
205
+ }
206
+ }
207
+ }
208
+ catch (error) {
209
+ if (idleTimer) {
210
+ clearTimeout(idleTimer);
211
+ }
212
+ opened.cleanup();
213
+ // Headers already sent: we cannot change status, so propagate for the caller
214
+ // to destroy the socket. Otherwise rethrow so a JSON error envelope is sent.
215
+ if (error instanceof KiroUpstreamError) {
216
+ throw error;
217
+ }
218
+ const reason = error instanceof Error ? error.message : String(error);
219
+ throw new KiroUpstreamError(args.requestId, 502, `Kiro stream read failed: ${reason}`);
220
+ }
221
+ if (idleTimer) {
222
+ clearTimeout(idleTimer);
223
+ }
224
+ opened.cleanup();
225
+ // Ensure the client always receives a well-formed completion, even for an empty
226
+ // response or one whose only signal was a (post-output) error frame.
227
+ ensureHeaders();
228
+ const usage = buildUsage(opened.inputText, fullText);
229
+ for (const frame of buildSseFinaleFrames(ids, {
230
+ text: fullText,
231
+ inputTokens: usage.usage.input_tokens,
232
+ outputTokens: usage.usage.output_tokens,
233
+ })) {
234
+ args.responseRaw.write(frame);
235
+ }
236
+ args.responseRaw.end();
237
+ if (streamError) {
238
+ args.logger.warn({ requestId: args.requestId, streamError }, "kiro stream completed with a post-output error frame");
239
+ }
240
+ return usage;
241
+ }
242
+ export { KiroAuthError };
243
+ function anthropicUsage(parsed, outputText) {
244
+ return buildUsage(parsed.inputText, outputText);
245
+ }
246
+ function openAnthropicStream(args) {
247
+ return openKiroStream({
248
+ store: args.store,
249
+ provider: args.provider,
250
+ config: args.config,
251
+ requestId: args.requestId,
252
+ body: {},
253
+ logger: args.logger,
254
+ fetchImpl: args.fetchImpl,
255
+ model: args.parsed.model,
256
+ inputText: args.parsed.inputText,
257
+ buildRequest: ({ profileArn, modelId }) => buildCodeWhispererRequestFromTurns({
258
+ turns: args.parsed.turns,
259
+ modelId,
260
+ tools: args.parsed.tools,
261
+ profileArn,
262
+ maxTokens: args.parsed.maxTokens,
263
+ temperature: args.parsed.temperature,
264
+ topP: args.parsed.topP,
265
+ }),
266
+ });
267
+ }
268
+ /** Non-streaming Anthropic path: buffers the stream, returns an Anthropic message + usage. */
269
+ export async function forwardKiroAnthropicJson(args) {
270
+ const opened = await openAnthropicStream(args);
271
+ try {
272
+ const buffer = Buffer.from(await opened.response.arrayBuffer());
273
+ const parser = new EventStreamParser();
274
+ const accumulator = new KiroResponseAccumulator();
275
+ for (const message of parser.push(buffer)) {
276
+ accumulator.push(message);
277
+ }
278
+ if (accumulator.error) {
279
+ throw new KiroUpstreamError(args.requestId, 502, accumulator.error);
280
+ }
281
+ const toolUses = accumulator.toolUses();
282
+ const outputText = accumulator.text + toolUses.map((t) => JSON.stringify(t.input)).join("");
283
+ const usage = anthropicUsage(args.parsed, outputText);
284
+ const payload = buildAnthropicMessage({
285
+ text: accumulator.text,
286
+ toolUses,
287
+ model: opened.model,
288
+ inputTokens: usage.usage.input_tokens,
289
+ outputTokens: usage.usage.output_tokens,
290
+ });
291
+ return { payload, usage };
292
+ }
293
+ finally {
294
+ opened.cleanup();
295
+ }
296
+ }
297
+ /**
298
+ * Streaming Anthropic path: reads the CodeWhisperer event-stream incrementally and
299
+ * writes the Anthropic Messages SSE sequence (message_start → content_block_* →
300
+ * message_delta → message_stop) as text and tool-use deltas arrive.
301
+ */
302
+ export async function forwardKiroAnthropicSse(args) {
303
+ const opened = await openAnthropicStream(args);
304
+ const { response } = opened;
305
+ if (!response.body) {
306
+ opened.cleanup();
307
+ throw new KiroUpstreamError(args.requestId, 502, "Kiro upstream returned an empty body");
308
+ }
309
+ const inputTokens = estimateTokens(args.parsed.inputText);
310
+ const emitter = new AnthropicSseEmitter({ model: opened.model, inputTokens });
311
+ const parser = new EventStreamParser();
312
+ const accumulator = new KiroResponseAccumulator();
313
+ const reader = response.body.getReader();
314
+ let headersSent = false;
315
+ const ensureHeaders = () => {
316
+ if (headersSent) {
317
+ return;
318
+ }
319
+ args.responseRaw.setHeader("Content-Type", "text/event-stream");
320
+ args.responseRaw.setHeader("Cache-Control", "no-cache, no-transform");
321
+ args.responseRaw.setHeader("Connection", "keep-alive");
322
+ args.responseRaw.setHeader("X-Accel-Buffering", "no");
323
+ args.responseRaw.flushHeaders?.();
324
+ for (const frame of emitter.start()) {
325
+ args.responseRaw.write(frame);
326
+ }
327
+ headersSent = true;
328
+ };
329
+ let idleTimer;
330
+ const resetIdleTimer = () => {
331
+ if (idleTimer) {
332
+ clearTimeout(idleTimer);
333
+ }
334
+ idleTimer = setTimeout(() => {
335
+ args.logger.warn({ requestId: args.requestId }, "kiro upstream stream idle timeout reached");
336
+ void reader.cancel().catch(() => undefined);
337
+ }, args.config.STREAM_IDLE_TIMEOUT_MS);
338
+ };
339
+ try {
340
+ resetIdleTimer();
341
+ while (true) {
342
+ const { done, value } = await reader.read();
343
+ if (done) {
344
+ break;
345
+ }
346
+ resetIdleTimer();
347
+ if (!value) {
348
+ continue;
349
+ }
350
+ for (const message of parser.push(value)) {
351
+ const errText = extractCodeWhispererError(message);
352
+ if (errText) {
353
+ if (!headersSent) {
354
+ throw new KiroUpstreamError(args.requestId, 502, errText);
355
+ }
356
+ continue;
357
+ }
358
+ const result = accumulator.push(message);
359
+ if (result.textDelta) {
360
+ ensureHeaders();
361
+ for (const frame of emitter.textDelta(result.textDelta)) {
362
+ args.responseRaw.write(frame);
363
+ }
364
+ }
365
+ if (result.toolUse) {
366
+ ensureHeaders();
367
+ for (const frame of emitter.toolUseDelta(result.toolUse)) {
368
+ args.responseRaw.write(frame);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+ catch (error) {
375
+ if (idleTimer) {
376
+ clearTimeout(idleTimer);
377
+ }
378
+ opened.cleanup();
379
+ if (error instanceof KiroUpstreamError) {
380
+ throw error;
381
+ }
382
+ const reason = error instanceof Error ? error.message : String(error);
383
+ throw new KiroUpstreamError(args.requestId, 502, `Kiro stream read failed: ${reason}`);
384
+ }
385
+ if (idleTimer) {
386
+ clearTimeout(idleTimer);
387
+ }
388
+ opened.cleanup();
389
+ ensureHeaders();
390
+ const toolUses = accumulator.toolUses();
391
+ const outputText = accumulator.text + toolUses.map((t) => JSON.stringify(t.input)).join("");
392
+ const usage = anthropicUsage(args.parsed, outputText);
393
+ for (const frame of emitter.finish(usage.usage.output_tokens)) {
394
+ args.responseRaw.write(frame);
395
+ }
396
+ args.responseRaw.end();
397
+ if (accumulator.error) {
398
+ args.logger.warn({ requestId: args.requestId, streamError: accumulator.error }, "kiro anthropic stream completed with a post-output error frame");
399
+ }
400
+ return usage;
401
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CLI: copy Kiro OAuth accounts from a 9router SQLite DB into a resproxy-owned DB
3
+ * so the proxy can own token refresh (write-back) without sharing 9router's live
4
+ * database.
5
+ *
6
+ * Usage:
7
+ * npm run kiro:import -- [--from <9router.sqlite>] [--to <dest.sqlite>] [--provider kiro]
8
+ *
9
+ * Defaults:
10
+ * --from $KIRO_SOURCE_DB_PATH or ~/.9router/db/data.sqlite
11
+ * --to $KIRO_DB_PATH or ./logs/kiro.sqlite
12
+ */
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { importKiroAccounts, KiroImportError } from "./kiro-import.js";
16
+ function parseArgs(argv) {
17
+ const out = {};
18
+ for (let i = 0; i < argv.length; i += 1) {
19
+ const arg = argv[i];
20
+ if (arg.startsWith("--")) {
21
+ const key = arg.slice(2);
22
+ const next = argv[i + 1];
23
+ if (next && !next.startsWith("--")) {
24
+ out[key] = next;
25
+ i += 1;
26
+ }
27
+ else {
28
+ out[key] = "true";
29
+ }
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ function defaultSource() {
35
+ return (process.env.KIRO_SOURCE_DB_PATH?.trim() ||
36
+ path.join(os.homedir(), ".9router", "db", "data.sqlite"));
37
+ }
38
+ function defaultDest() {
39
+ return process.env.KIRO_DB_PATH?.trim() || path.resolve("logs", "kiro.sqlite");
40
+ }
41
+ function main() {
42
+ const args = parseArgs(process.argv.slice(2));
43
+ const sourceDbPath = args.from ?? defaultSource();
44
+ const destDbPath = args.to ?? defaultDest();
45
+ const provider = args.provider ?? "kiro";
46
+ try {
47
+ const result = importKiroAccounts({ sourceDbPath, destDbPath, provider });
48
+ console.log(`[kiro:import] source : ${result.source}`);
49
+ console.log(`[kiro:import] dest : ${result.dest}`);
50
+ console.log(`[kiro:import] copied : ${result.imported} ${provider} account(s)`);
51
+ for (const id of result.ids) {
52
+ console.log(`[kiro:import] - ${id}`);
53
+ }
54
+ if (result.imported === 0) {
55
+ console.log(`[kiro:import] WARNING: no '${provider}' accounts found in the source DB. Nothing to import.`);
56
+ }
57
+ else {
58
+ console.log(`[kiro:import] Done. Point KIRO_DB_PATH at ${result.dest} and set KIRO_WRITE_BACK_ENABLED=true.`);
59
+ }
60
+ }
61
+ catch (error) {
62
+ if (error instanceof KiroImportError) {
63
+ console.error(`[kiro:import] ${error.message}`);
64
+ process.exit(1);
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ main();
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import BetterSqlite3 from "better-sqlite3";
4
+ /**
5
+ * Schema for the `providerConnections` table, mirroring the one 9router creates.
6
+ * Used to materialize a resproxy-owned copy of the Kiro accounts so the proxy can
7
+ * own token refresh (write-back) without sharing 9router's live database.
8
+ */
9
+ export const PROVIDER_CONNECTIONS_DDL = `
10
+ CREATE TABLE IF NOT EXISTS providerConnections (
11
+ id TEXT PRIMARY KEY,
12
+ provider TEXT NOT NULL,
13
+ authType TEXT NOT NULL,
14
+ name TEXT,
15
+ email TEXT,
16
+ priority INTEGER,
17
+ isActive INTEGER DEFAULT 1,
18
+ data TEXT NOT NULL,
19
+ createdAt TEXT NOT NULL,
20
+ updatedAt TEXT NOT NULL
21
+ );
22
+ CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider);
23
+ CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive);
24
+ CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority);
25
+ `;
26
+ export class KiroImportError extends Error {
27
+ }
28
+ /**
29
+ * Copies the OAuth accounts for a provider (default `kiro`) from a source 9router
30
+ * SQLite database into a resproxy-owned destination database, preserving every
31
+ * field verbatim. Existing rows in the destination are upserted by id, so a
32
+ * re-import re-syncs from 9router (overwriting any tokens the proxy refreshed).
33
+ *
34
+ * The source is opened read-only; the destination is created if missing.
35
+ */
36
+ export function importKiroAccounts(args) {
37
+ const provider = args.provider ?? "kiro";
38
+ const sourceDbPath = path.resolve(args.sourceDbPath);
39
+ const destDbPath = path.resolve(args.destDbPath);
40
+ if (!existsSync(sourceDbPath)) {
41
+ throw new KiroImportError(`Source 9router DB not found at ${sourceDbPath}`);
42
+ }
43
+ if (sourceDbPath === destDbPath) {
44
+ throw new KiroImportError("Source and destination databases must be different files");
45
+ }
46
+ const source = new BetterSqlite3(sourceDbPath, { fileMustExist: true, readonly: true });
47
+ let rows;
48
+ try {
49
+ rows = source
50
+ .prepare(`SELECT id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt
51
+ FROM providerConnections
52
+ WHERE provider = ?
53
+ ORDER BY priority IS NULL, priority, createdAt`)
54
+ .all(provider);
55
+ }
56
+ finally {
57
+ source.close();
58
+ }
59
+ mkdirSync(path.dirname(destDbPath), { recursive: true });
60
+ const dest = new BetterSqlite3(destDbPath);
61
+ try {
62
+ dest.pragma("journal_mode = WAL");
63
+ dest.exec(PROVIDER_CONNECTIONS_DDL);
64
+ const upsert = dest.prepare(`INSERT INTO providerConnections
65
+ (id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt)
66
+ VALUES
67
+ (@id, @provider, @authType, @name, @email, @priority, @isActive, @data, @createdAt, @updatedAt)
68
+ ON CONFLICT(id) DO UPDATE SET
69
+ provider = excluded.provider,
70
+ authType = excluded.authType,
71
+ name = excluded.name,
72
+ email = excluded.email,
73
+ priority = excluded.priority,
74
+ isActive = excluded.isActive,
75
+ data = excluded.data,
76
+ createdAt = excluded.createdAt,
77
+ updatedAt = excluded.updatedAt`);
78
+ const runAll = dest.transaction((items) => {
79
+ for (const row of items) {
80
+ upsert.run(row);
81
+ }
82
+ });
83
+ runAll(rows);
84
+ }
85
+ finally {
86
+ dest.close();
87
+ }
88
+ return {
89
+ source: sourceDbPath,
90
+ dest: destDbPath,
91
+ imported: rows.length,
92
+ ids: rows.map((row) => row.id),
93
+ };
94
+ }
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import BetterSqlite3 from "better-sqlite3";
7
+ import { importKiroAccounts, KiroImportError, PROVIDER_CONNECTIONS_DDL } from "./kiro-import.js";
8
+ function makeSourceDb(dir) {
9
+ const dbPath = path.join(dir, "source.sqlite");
10
+ const db = new BetterSqlite3(dbPath);
11
+ db.exec(PROVIDER_CONNECTIONS_DDL);
12
+ const insert = db.prepare(`INSERT INTO providerConnections
13
+ (id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt)
14
+ VALUES (@id, @provider, @authType, @name, @email, @priority, @isActive, @data, @createdAt, @updatedAt)`);
15
+ insert.run({
16
+ id: "acct-1",
17
+ provider: "kiro",
18
+ authType: "oauth",
19
+ name: "Account 1",
20
+ email: null,
21
+ priority: 1,
22
+ isActive: 1,
23
+ data: JSON.stringify({ accessToken: "a1", refreshToken: "r1", providerSpecificData: { region: "us-east-1" } }),
24
+ createdAt: "2026-01-01T00:00:00.000Z",
25
+ updatedAt: "2026-01-01T00:00:00.000Z",
26
+ });
27
+ insert.run({
28
+ id: "acct-2",
29
+ provider: "kiro",
30
+ authType: "oauth",
31
+ name: "Account 2",
32
+ email: null,
33
+ priority: 2,
34
+ isActive: 1,
35
+ data: JSON.stringify({ accessToken: "a2", refreshToken: "r2", providerSpecificData: {} }),
36
+ createdAt: "2026-01-02T00:00:00.000Z",
37
+ updatedAt: "2026-01-02T00:00:00.000Z",
38
+ });
39
+ // A non-kiro row that must NOT be copied.
40
+ insert.run({
41
+ id: "other-1",
42
+ provider: "cursor",
43
+ authType: "oauth",
44
+ name: "Cursor",
45
+ email: null,
46
+ priority: 1,
47
+ isActive: 1,
48
+ data: JSON.stringify({ accessToken: "x" }),
49
+ createdAt: "2026-01-03T00:00:00.000Z",
50
+ updatedAt: "2026-01-03T00:00:00.000Z",
51
+ });
52
+ db.close();
53
+ return dbPath;
54
+ }
55
+ function readDest(destPath) {
56
+ const db = new BetterSqlite3(destPath, { readonly: true });
57
+ const rows = db.prepare("SELECT * FROM providerConnections ORDER BY id").all();
58
+ db.close();
59
+ return rows;
60
+ }
61
+ test("importKiroAccounts copies only kiro rows verbatim", () => {
62
+ const dir = mkdtempSync(path.join(os.tmpdir(), "kiro-import-"));
63
+ try {
64
+ const source = makeSourceDb(dir);
65
+ const dest = path.join(dir, "nested", "kiro.sqlite");
66
+ const result = importKiroAccounts({ sourceDbPath: source, destDbPath: dest });
67
+ assert.equal(result.imported, 2);
68
+ assert.deepEqual(result.ids.sort(), ["acct-1", "acct-2"]);
69
+ const rows = readDest(dest);
70
+ assert.equal(rows.length, 2);
71
+ assert.equal(rows[0].id, "acct-1");
72
+ assert.equal(rows[0].provider, "kiro");
73
+ assert.equal(rows[0].data, JSON.stringify({ accessToken: "a1", refreshToken: "r1", providerSpecificData: { region: "us-east-1" } }));
74
+ assert.ok(!rows.some((r) => r.provider === "cursor"));
75
+ }
76
+ finally {
77
+ rmSync(dir, { recursive: true, force: true });
78
+ }
79
+ });
80
+ test("importKiroAccounts upserts on re-import (re-syncs from source)", () => {
81
+ const dir = mkdtempSync(path.join(os.tmpdir(), "kiro-import-"));
82
+ try {
83
+ const source = makeSourceDb(dir);
84
+ const dest = path.join(dir, "kiro.sqlite");
85
+ importKiroAccounts({ sourceDbPath: source, destDbPath: dest });
86
+ // Simulate the proxy refreshing a token in the dest DB.
87
+ const destDb = new BetterSqlite3(dest);
88
+ destDb
89
+ .prepare("UPDATE providerConnections SET data = ? WHERE id = ?")
90
+ .run(JSON.stringify({ accessToken: "PROXY_REFRESHED" }), "acct-1");
91
+ destDb.close();
92
+ // Re-import should overwrite with the source value and not duplicate rows.
93
+ const result = importKiroAccounts({ sourceDbPath: source, destDbPath: dest });
94
+ assert.equal(result.imported, 2);
95
+ const rows = readDest(dest);
96
+ assert.equal(rows.length, 2);
97
+ const acct1 = rows.find((r) => r.id === "acct-1");
98
+ assert.match(String(acct1?.data), /"accessToken":"a1"/);
99
+ }
100
+ finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+ test("importKiroAccounts rejects identical source and dest", () => {
105
+ const dir = mkdtempSync(path.join(os.tmpdir(), "kiro-import-"));
106
+ try {
107
+ const source = makeSourceDb(dir);
108
+ assert.throws(() => importKiroAccounts({ sourceDbPath: source, destDbPath: source }), (error) => error instanceof KiroImportError);
109
+ }
110
+ finally {
111
+ rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+ test("importKiroAccounts throws when the source DB is missing", () => {
115
+ const dir = mkdtempSync(path.join(os.tmpdir(), "kiro-import-"));
116
+ try {
117
+ assert.throws(() => importKiroAccounts({
118
+ sourceDbPath: path.join(dir, "nope.sqlite"),
119
+ destDbPath: path.join(dir, "kiro.sqlite"),
120
+ }), (error) => error instanceof KiroImportError);
121
+ }
122
+ finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });