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.
- package/README.md +56 -0
- package/cli.js +118 -0
- package/dist/anthropic-messages.js +383 -0
- package/dist/anthropic-messages.test.js +209 -0
- package/dist/audit-log.js +138 -0
- package/dist/audit-log.test.js +480 -0
- package/dist/billing-expiration.js +70 -0
- package/dist/billing-expiration.test.js +114 -0
- package/dist/billing.js +716 -0
- package/dist/billing.test.js +228 -0
- package/dist/chatgpt-oauth-store.js +240 -0
- package/dist/chatgpt-oauth-store.test.js +88 -0
- package/dist/chatgpt-oauth.js +118 -0
- package/dist/chatgpt-oauth.test.js +63 -0
- package/dist/chatgpt-provider-auth.js +60 -0
- package/dist/chatgpt-provider-auth.test.js +101 -0
- package/dist/client/app-icon.svg +17 -0
- package/dist/client/assets/index-C7Vvhst8.js +14 -0
- package/dist/client/assets/index-DpqgYK3L.css +1 -0
- package/dist/client/favicon.svg +17 -0
- package/dist/client/index.html +31 -0
- package/dist/client-config-apply.js +345 -0
- package/dist/client-config-apply.test.js +185 -0
- package/dist/client-token-limits.js +111 -0
- package/dist/client-token-limits.test.js +129 -0
- package/dist/codex-config.js +47 -0
- package/dist/codex-setup.js +87 -0
- package/dist/codex-setup.test.js +30 -0
- package/dist/config.js +314 -0
- package/dist/cost-analytics.js +31 -0
- package/dist/cost-analytics.test.js +38 -0
- package/dist/customer-key-access.js +126 -0
- package/dist/customer-key-access.test.js +178 -0
- package/dist/customer-keys.js +209 -0
- package/dist/customer-keys.test.js +68 -0
- package/dist/customer-usage.js +18 -0
- package/dist/customer-usage.test.js +55 -0
- package/dist/dashboard-auth.js +318 -0
- package/dist/dashboard-auth.test.js +133 -0
- package/dist/dashboard-serving.test.js +235 -0
- package/dist/error-response.js +174 -0
- package/dist/error-response.test.js +88 -0
- package/dist/forward.js +357 -0
- package/dist/health-websocket-manager.js +174 -0
- package/dist/http-rate-limit.js +36 -0
- package/dist/http-rate-limit.test.js +62 -0
- package/dist/kiro-auth.js +136 -0
- package/dist/kiro-auth.test.js +234 -0
- package/dist/kiro-codewhisperer.js +646 -0
- package/dist/kiro-codewhisperer.test.js +219 -0
- package/dist/kiro-device-login.js +338 -0
- package/dist/kiro-eventstream.js +219 -0
- package/dist/kiro-eventstream.test.js +79 -0
- package/dist/kiro-forward.js +401 -0
- package/dist/kiro-import-cli.js +69 -0
- package/dist/kiro-import.js +94 -0
- package/dist/kiro-import.test.js +125 -0
- package/dist/kiro-token-store.js +196 -0
- package/dist/kiro-token-store.test.js +207 -0
- package/dist/krouter-usage.js +243 -0
- package/dist/model-combo-repository.js +147 -0
- package/dist/model-routing.js +69 -0
- package/dist/model-routing.test.js +41 -0
- package/dist/normalize-request.js +531 -0
- package/dist/normalize-request.test.js +277 -0
- package/dist/omv-public-firewall.test.js +11 -0
- package/dist/package.json +17 -0
- package/dist/prompt-cache-state.js +146 -0
- package/dist/prompt-cache-state.test.js +71 -0
- package/dist/prompt-cache.js +229 -0
- package/dist/provider-health-service.js +404 -0
- package/dist/provider-request-parameters.js +107 -0
- package/dist/provider-request-parameters.test.js +26 -0
- package/dist/provider-routing.js +114 -0
- package/dist/provider-routing.test.js +64 -0
- package/dist/provider-usage.js +314 -0
- package/dist/request-timeout-policy.js +61 -0
- package/dist/request-timeout-policy.test.js +40 -0
- package/dist/response-cache.js +69 -0
- package/dist/response-cache.test.js +28 -0
- package/dist/routing-combo-repository.js +300 -0
- package/dist/routing-engine.js +377 -0
- package/dist/routing-integration.js +155 -0
- package/dist/routing-simulation-engine.js +326 -0
- package/dist/rtk-layer.js +483 -0
- package/dist/rtk-layer.test.js +198 -0
- package/dist/runtime-provider-repository.js +1742 -0
- package/dist/runtime-provider-repository.test.js +1177 -0
- package/dist/schema.js +118 -0
- package/dist/schema.test.js +16 -0
- package/dist/sepay-webhook.js +87 -0
- package/dist/sepay-webhook.test.js +142 -0
- package/dist/server-body-limit.test.js +35 -0
- package/dist/server-client-token-limits.test.js +161 -0
- package/dist/server-codex-config-setup.test.js +76 -0
- package/dist/server-http-rate-limit.test.js +80 -0
- package/dist/server-response-cache.test.js +105 -0
- package/dist/server-routes-alias.test.js +39 -0
- package/dist/server-sepay-webhook-security.test.js +59 -0
- package/dist/server.js +5906 -0
- package/dist/session-log.js +178 -0
- package/dist/tailnet-funnel-script.test.js +33 -0
- package/dist/telegram-bot/actions.js +118 -0
- package/dist/telegram-bot/admin-actions.js +103 -0
- package/dist/telegram-bot/auth.js +46 -0
- package/dist/telegram-bot/auth.test.js +1 -0
- package/dist/telegram-bot/bot-identity-repository.js +189 -0
- package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
- package/dist/telegram-bot/callbacks.js +30 -0
- package/dist/telegram-bot/codex-config-delivery.js +38 -0
- package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
- package/dist/telegram-bot/commands/accounts.js +140 -0
- package/dist/telegram-bot/commands/apikey.js +737 -0
- package/dist/telegram-bot/commands/apply.js +265 -0
- package/dist/telegram-bot/commands/clients.js +13 -0
- package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
- package/dist/telegram-bot/commands/grant.js +138 -0
- package/dist/telegram-bot/commands/grant.test.js +217 -0
- package/dist/telegram-bot/commands/help.js +52 -0
- package/dist/telegram-bot/commands/me.js +53 -0
- package/dist/telegram-bot/commands/models.js +6 -0
- package/dist/telegram-bot/commands/oauth.js +64 -0
- package/dist/telegram-bot/commands/plans.js +96 -0
- package/dist/telegram-bot/commands/providers.js +27 -0
- package/dist/telegram-bot/commands/quota.js +10 -0
- package/dist/telegram-bot/commands/renew-user.js +139 -0
- package/dist/telegram-bot/commands/renew-user.test.js +184 -0
- package/dist/telegram-bot/commands/renew.js +1369 -0
- package/dist/telegram-bot/commands/renew.test.js +1633 -0
- package/dist/telegram-bot/commands/start.js +212 -0
- package/dist/telegram-bot/commands/start.test.js +280 -0
- package/dist/telegram-bot/commands/status.js +6 -0
- package/dist/telegram-bot/commands/tailscale.js +15 -0
- package/dist/telegram-bot/commands/tailscale.test.js +76 -0
- package/dist/telegram-bot/commands/test.js +51 -0
- package/dist/telegram-bot/commands/test.test.js +14 -0
- package/dist/telegram-bot/commands/usage.js +10 -0
- package/dist/telegram-bot/config.js +98 -0
- package/dist/telegram-bot/config.test.js +42 -0
- package/dist/telegram-bot/customer-actions.js +160 -0
- package/dist/telegram-bot/customer-api-keys.js +68 -0
- package/dist/telegram-bot/customer-billing.js +72 -0
- package/dist/telegram-bot/customer-workspace-repository.js +134 -0
- package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
- package/dist/telegram-bot/dashboard-login.js +39 -0
- package/dist/telegram-bot/format.js +140 -0
- package/dist/telegram-bot/grants.js +370 -0
- package/dist/telegram-bot/grants.test.js +290 -0
- package/dist/telegram-bot/index.js +85 -0
- package/dist/telegram-bot/message-cleanup.js +55 -0
- package/dist/telegram-bot/message-cleanup.test.js +77 -0
- package/dist/telegram-bot/message-format.js +45 -0
- package/dist/telegram-bot/message-format.test.js +10 -0
- package/dist/telegram-bot/proxy-client.js +174 -0
- package/dist/telegram-bot/rate-limit.js +95 -0
- package/dist/telegram-bot/rate-limit.test.js +58 -0
- package/dist/telegram-bot/sessions.js +171 -0
- package/dist/telegram-bot/sessions.test.js +107 -0
- package/dist/telegram-bot/telegram-adapter.js +126 -0
- package/dist/telegram-bot/worker.js +63 -0
- package/package.json +39 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
3
|
+
/**
|
|
4
|
+
* Reads (and optionally writes back) Kiro/CodeWhisperer OAuth accounts stored by
|
|
5
|
+
* the 9router app in its own SQLite database (providerConnections table).
|
|
6
|
+
*
|
|
7
|
+
* The same database is owned by a live 9router process, so write-back is scoped
|
|
8
|
+
* to only the token fields and preserves every other key 9router manages.
|
|
9
|
+
*/
|
|
10
|
+
export class KiroTokenStore {
|
|
11
|
+
db;
|
|
12
|
+
writeBackEnabled;
|
|
13
|
+
constructor(db, writeBackEnabled) {
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.writeBackEnabled = writeBackEnabled;
|
|
16
|
+
}
|
|
17
|
+
static open(dbFile, options = {}) {
|
|
18
|
+
if (!existsSync(dbFile)) {
|
|
19
|
+
throw new KiroTokenStoreError(`9router database not found at ${dbFile}`);
|
|
20
|
+
}
|
|
21
|
+
const writeBack = options.writeBack !== false;
|
|
22
|
+
// When write-back is disabled, open read-only so we never touch a database the
|
|
23
|
+
// live 9router process owns (not even the journal_mode pragma, which is a write).
|
|
24
|
+
// SQLite can read a WAL database read-only as long as the -wal/-shm files exist.
|
|
25
|
+
const db = new BetterSqlite3(dbFile, { fileMustExist: true, readonly: !writeBack });
|
|
26
|
+
if (writeBack) {
|
|
27
|
+
// 9router runs in WAL mode; match it so our reads see committed writes and
|
|
28
|
+
// our write-backs cooperate with its connection instead of blocking it.
|
|
29
|
+
db.pragma("journal_mode = WAL");
|
|
30
|
+
db.pragma("busy_timeout = 4000");
|
|
31
|
+
}
|
|
32
|
+
return new KiroTokenStore(db, writeBack);
|
|
33
|
+
}
|
|
34
|
+
listAccounts() {
|
|
35
|
+
const rows = this.db
|
|
36
|
+
.prepare(`SELECT id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt
|
|
37
|
+
FROM providerConnections
|
|
38
|
+
WHERE provider = 'kiro'
|
|
39
|
+
ORDER BY priority IS NULL, priority, createdAt`)
|
|
40
|
+
.all();
|
|
41
|
+
return rows
|
|
42
|
+
.map((row) => mapConnectionRow(row))
|
|
43
|
+
.filter((account) => account !== undefined);
|
|
44
|
+
}
|
|
45
|
+
listAvailableAccounts() {
|
|
46
|
+
return this.listAccounts().filter((account) => account.isActive);
|
|
47
|
+
}
|
|
48
|
+
getAccount(id) {
|
|
49
|
+
const row = this.db
|
|
50
|
+
.prepare(`SELECT id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt
|
|
51
|
+
FROM providerConnections
|
|
52
|
+
WHERE provider = 'kiro' AND id = ?`)
|
|
53
|
+
.get(id);
|
|
54
|
+
return row ? mapConnectionRow(row) : undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Persists refreshed tokens back into 9router's database. Re-reads the current
|
|
58
|
+
* row inside a transaction so we merge onto whatever 9router last wrote rather
|
|
59
|
+
* than clobbering concurrent updates. No-op when write-back is disabled.
|
|
60
|
+
*/
|
|
61
|
+
updateTokens(id, update, now = new Date()) {
|
|
62
|
+
if (!this.writeBackEnabled) {
|
|
63
|
+
return this.getAccount(id);
|
|
64
|
+
}
|
|
65
|
+
const persist = this.db.transaction((accountId, patch) => {
|
|
66
|
+
const row = this.db
|
|
67
|
+
.prepare(`SELECT data FROM providerConnections WHERE provider = 'kiro' AND id = ?`)
|
|
68
|
+
.get(accountId);
|
|
69
|
+
if (!row) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const data = safeParseObject(row.data);
|
|
73
|
+
data.accessToken = patch.accessToken;
|
|
74
|
+
if (patch.refreshToken) {
|
|
75
|
+
data.refreshToken = patch.refreshToken;
|
|
76
|
+
}
|
|
77
|
+
data.expiresAt = patch.expiresAt;
|
|
78
|
+
if (typeof patch.expiresIn === "number") {
|
|
79
|
+
data.expiresIn = patch.expiresIn;
|
|
80
|
+
}
|
|
81
|
+
// Clear transient error markers 9router sets so a successful refresh is reflected.
|
|
82
|
+
data.lastError = null;
|
|
83
|
+
data.lastErrorAt = null;
|
|
84
|
+
this.db
|
|
85
|
+
.prepare(`UPDATE providerConnections SET data = ?, updatedAt = ? WHERE provider = 'kiro' AND id = ?`)
|
|
86
|
+
.run(JSON.stringify(data), now.toISOString(), accountId);
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
const ok = persist(id, update);
|
|
90
|
+
return ok ? this.getAccount(id) : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Updates account properties (name, priority, isActive). Only works when write-back is enabled.
|
|
94
|
+
*/
|
|
95
|
+
updateAccount(id, updates, now = new Date()) {
|
|
96
|
+
if (!this.writeBackEnabled) {
|
|
97
|
+
throw new KiroTokenStoreError("Cannot update account: write-back is disabled");
|
|
98
|
+
}
|
|
99
|
+
const updateTransaction = this.db.transaction((accountId, patch) => {
|
|
100
|
+
const row = this.db
|
|
101
|
+
.prepare(`SELECT name, priority, isActive, data FROM providerConnections WHERE provider = 'kiro' AND id = ?`)
|
|
102
|
+
.get(accountId);
|
|
103
|
+
if (!row) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const updateFields = [];
|
|
107
|
+
const updateValues = [];
|
|
108
|
+
if (patch.name !== undefined) {
|
|
109
|
+
updateFields.push("name = ?");
|
|
110
|
+
updateValues.push(patch.name);
|
|
111
|
+
}
|
|
112
|
+
if (patch.priority !== undefined) {
|
|
113
|
+
updateFields.push("priority = ?");
|
|
114
|
+
updateValues.push(patch.priority);
|
|
115
|
+
}
|
|
116
|
+
if (patch.isActive !== undefined) {
|
|
117
|
+
updateFields.push("isActive = ?");
|
|
118
|
+
updateValues.push(patch.isActive ? 1 : 0);
|
|
119
|
+
}
|
|
120
|
+
if (updateFields.length === 0) {
|
|
121
|
+
return true; // No updates needed
|
|
122
|
+
}
|
|
123
|
+
updateFields.push("updatedAt = ?");
|
|
124
|
+
updateValues.push(now.toISOString());
|
|
125
|
+
updateValues.push(accountId);
|
|
126
|
+
const sql = `UPDATE providerConnections SET ${updateFields.join(", ")} WHERE provider = 'kiro' AND id = ?`;
|
|
127
|
+
this.db.prepare(sql).run(...updateValues);
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
const ok = updateTransaction(id, updates);
|
|
131
|
+
return ok ? this.getAccount(id) : undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Deletes a Kiro account. Only works when write-back is enabled.
|
|
135
|
+
*/
|
|
136
|
+
deleteAccount(id) {
|
|
137
|
+
if (!this.writeBackEnabled) {
|
|
138
|
+
throw new KiroTokenStoreError("Cannot delete account: write-back is disabled");
|
|
139
|
+
}
|
|
140
|
+
const result = this.db
|
|
141
|
+
.prepare(`DELETE FROM providerConnections WHERE provider = 'kiro' AND id = ?`)
|
|
142
|
+
.run(id);
|
|
143
|
+
return result.changes > 0;
|
|
144
|
+
}
|
|
145
|
+
close() {
|
|
146
|
+
this.db.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export class KiroTokenStoreError extends Error {
|
|
150
|
+
}
|
|
151
|
+
function mapConnectionRow(row) {
|
|
152
|
+
const data = safeParseObject(row.data);
|
|
153
|
+
const accessToken = typeof data.accessToken === "string" ? data.accessToken : "";
|
|
154
|
+
const refreshToken = typeof data.refreshToken === "string" ? data.refreshToken : "";
|
|
155
|
+
if (!accessToken && !refreshToken) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const psdRaw = typeof data.providerSpecificData === "object" && data.providerSpecificData !== null
|
|
159
|
+
? data.providerSpecificData
|
|
160
|
+
: {};
|
|
161
|
+
return {
|
|
162
|
+
id: row.id,
|
|
163
|
+
name: row.name?.trim() ? row.name.trim() : row.id,
|
|
164
|
+
priority: typeof row.priority === "number" ? row.priority : Number.MAX_SAFE_INTEGER,
|
|
165
|
+
isActive: row.isActive !== 0,
|
|
166
|
+
accessToken,
|
|
167
|
+
refreshToken,
|
|
168
|
+
expiresAt: typeof data.expiresAt === "string" ? data.expiresAt : null,
|
|
169
|
+
expiresIn: typeof data.expiresIn === "number" ? data.expiresIn : null,
|
|
170
|
+
providerSpecificData: {
|
|
171
|
+
profileArn: optionalString(psdRaw.profileArn),
|
|
172
|
+
clientId: optionalString(psdRaw.clientId),
|
|
173
|
+
clientSecret: optionalString(psdRaw.clientSecret),
|
|
174
|
+
region: optionalString(psdRaw.region),
|
|
175
|
+
authMethod: optionalString(psdRaw.authMethod),
|
|
176
|
+
startUrl: optionalString(psdRaw.startUrl),
|
|
177
|
+
},
|
|
178
|
+
raw: data,
|
|
179
|
+
createdAt: row.createdAt,
|
|
180
|
+
updatedAt: row.updatedAt,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function safeParseObject(raw) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(raw);
|
|
186
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
187
|
+
? parsed
|
|
188
|
+
: {};
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function optionalString(value) {
|
|
195
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
7
|
+
import { KiroTokenStore, KiroTokenStoreError } from "./kiro-token-store.js";
|
|
8
|
+
function createDbFile(rows) {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-kiro-store-"));
|
|
10
|
+
const file = path.join(dir, "data.sqlite");
|
|
11
|
+
const db = new BetterSqlite3(file);
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE providerConnections (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
provider TEXT NOT NULL,
|
|
16
|
+
authType TEXT,
|
|
17
|
+
name TEXT,
|
|
18
|
+
email TEXT,
|
|
19
|
+
priority INTEGER,
|
|
20
|
+
isActive INTEGER,
|
|
21
|
+
data TEXT NOT NULL,
|
|
22
|
+
createdAt TEXT NOT NULL,
|
|
23
|
+
updatedAt TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
`);
|
|
26
|
+
const insert = db.prepare(`INSERT INTO providerConnections
|
|
27
|
+
(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt)
|
|
28
|
+
VALUES (@id, @provider, @authType, @name, @email, @priority, @isActive, @data, @createdAt, @updatedAt)`);
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
insert.run({
|
|
31
|
+
id: row.id,
|
|
32
|
+
provider: row.provider ?? "kiro",
|
|
33
|
+
authType: row.authType ?? "oauth",
|
|
34
|
+
name: row.name ?? null,
|
|
35
|
+
email: row.email ?? null,
|
|
36
|
+
priority: row.priority ?? null,
|
|
37
|
+
isActive: row.isActive ?? 1,
|
|
38
|
+
data: JSON.stringify(row.data),
|
|
39
|
+
createdAt: row.createdAt ?? "2026-05-01T00:00:00.000Z",
|
|
40
|
+
updatedAt: row.updatedAt ?? "2026-05-01T00:00:00.000Z",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
db.close();
|
|
44
|
+
return { dir, file };
|
|
45
|
+
}
|
|
46
|
+
test("open throws when the database file does not exist", () => {
|
|
47
|
+
const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-kiro-missing-"));
|
|
48
|
+
try {
|
|
49
|
+
assert.throws(() => KiroTokenStore.open(path.join(dir, "nope.sqlite")), (error) => error instanceof KiroTokenStoreError);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
test("listAccounts maps rows, filters non-kiro and tokenless rows, and orders by priority", () => {
|
|
56
|
+
const { dir, file } = createDbFile([
|
|
57
|
+
{
|
|
58
|
+
id: "acct-b",
|
|
59
|
+
priority: 5,
|
|
60
|
+
data: {
|
|
61
|
+
accessToken: "access-b",
|
|
62
|
+
refreshToken: "refresh-b",
|
|
63
|
+
expiresAt: "2026-06-01T00:00:00.000Z",
|
|
64
|
+
expiresIn: 3600,
|
|
65
|
+
providerSpecificData: {
|
|
66
|
+
profileArn: "arn:aws:codewhisperer:profile/b",
|
|
67
|
+
region: "us-west-2",
|
|
68
|
+
clientId: "client-b",
|
|
69
|
+
clientSecret: "secret-b",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "acct-a",
|
|
75
|
+
priority: 1,
|
|
76
|
+
data: { accessToken: "access-a", refreshToken: "refresh-a" },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "no-tokens",
|
|
80
|
+
data: { providerSpecificData: { region: "us-east-1" } },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "other-provider",
|
|
84
|
+
provider: "chatgpt",
|
|
85
|
+
data: { accessToken: "x", refreshToken: "y" },
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
try {
|
|
89
|
+
const store = KiroTokenStore.open(file);
|
|
90
|
+
const accounts = store.listAccounts();
|
|
91
|
+
assert.deepEqual(accounts.map((a) => a.id), ["acct-a", "acct-b"]);
|
|
92
|
+
const b = accounts.find((a) => a.id === "acct-b");
|
|
93
|
+
assert.equal(b?.accessToken, "access-b");
|
|
94
|
+
assert.equal(b?.providerSpecificData.profileArn, "arn:aws:codewhisperer:profile/b");
|
|
95
|
+
assert.equal(b?.providerSpecificData.region, "us-west-2");
|
|
96
|
+
assert.equal(b?.providerSpecificData.clientId, "client-b");
|
|
97
|
+
store.close();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test("listAvailableAccounts excludes inactive accounts", () => {
|
|
104
|
+
const { dir, file } = createDbFile([
|
|
105
|
+
{ id: "active", isActive: 1, data: { accessToken: "a", refreshToken: "r" } },
|
|
106
|
+
{ id: "inactive", isActive: 0, data: { accessToken: "a2", refreshToken: "r2" } },
|
|
107
|
+
]);
|
|
108
|
+
try {
|
|
109
|
+
const store = KiroTokenStore.open(file);
|
|
110
|
+
assert.deepEqual(store.listAvailableAccounts().map((a) => a.id), ["active"]);
|
|
111
|
+
store.close();
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
test("getAccount returns a single mapped account or undefined", () => {
|
|
118
|
+
const { dir, file } = createDbFile([
|
|
119
|
+
{ id: "acct-a", data: { accessToken: "a", refreshToken: "r" } },
|
|
120
|
+
]);
|
|
121
|
+
try {
|
|
122
|
+
const store = KiroTokenStore.open(file);
|
|
123
|
+
assert.equal(store.getAccount("acct-a")?.id, "acct-a");
|
|
124
|
+
assert.equal(store.getAccount("missing"), undefined);
|
|
125
|
+
store.close();
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
test("updateTokens writes back token fields, clears error markers, and preserves other keys", () => {
|
|
132
|
+
const { dir, file } = createDbFile([
|
|
133
|
+
{
|
|
134
|
+
id: "acct-a",
|
|
135
|
+
data: {
|
|
136
|
+
accessToken: "old-access",
|
|
137
|
+
refreshToken: "old-refresh",
|
|
138
|
+
expiresAt: "2026-05-01T00:00:00.000Z",
|
|
139
|
+
expiresIn: 3600,
|
|
140
|
+
lastError: "boom",
|
|
141
|
+
lastErrorAt: "2026-05-01T00:00:00.000Z",
|
|
142
|
+
providerSpecificData: { region: "us-east-1", profileArn: "arn:keep" },
|
|
143
|
+
someOtherKey: "keep-me",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
try {
|
|
148
|
+
const store = KiroTokenStore.open(file);
|
|
149
|
+
const now = new Date("2026-05-15T12:00:00.000Z");
|
|
150
|
+
const updated = store.updateTokens("acct-a", {
|
|
151
|
+
accessToken: "new-access",
|
|
152
|
+
refreshToken: "new-refresh",
|
|
153
|
+
expiresAt: "2026-05-15T13:00:00.000Z",
|
|
154
|
+
expiresIn: 3600,
|
|
155
|
+
}, now);
|
|
156
|
+
assert.equal(updated?.accessToken, "new-access");
|
|
157
|
+
assert.equal(updated?.refreshToken, "new-refresh");
|
|
158
|
+
assert.equal(updated?.expiresAt, "2026-05-15T13:00:00.000Z");
|
|
159
|
+
// Re-open from a fresh handle to confirm persistence and preserved keys.
|
|
160
|
+
const verifyDb = new BetterSqlite3(file, { readonly: true });
|
|
161
|
+
const row = verifyDb
|
|
162
|
+
.prepare(`SELECT data, updatedAt FROM providerConnections WHERE id = 'acct-a'`)
|
|
163
|
+
.get();
|
|
164
|
+
const data = JSON.parse(row.data);
|
|
165
|
+
assert.equal(data.accessToken, "new-access");
|
|
166
|
+
assert.equal(data.lastError, null);
|
|
167
|
+
assert.equal(data.lastErrorAt, null);
|
|
168
|
+
assert.equal(data.someOtherKey, "keep-me");
|
|
169
|
+
assert.deepEqual(data.providerSpecificData, {
|
|
170
|
+
region: "us-east-1",
|
|
171
|
+
profileArn: "arn:keep",
|
|
172
|
+
});
|
|
173
|
+
assert.equal(row.updatedAt, now.toISOString());
|
|
174
|
+
verifyDb.close();
|
|
175
|
+
store.close();
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test("updateTokens is a no-op when write-back is disabled", () => {
|
|
182
|
+
const { dir, file } = createDbFile([
|
|
183
|
+
{
|
|
184
|
+
id: "acct-a",
|
|
185
|
+
data: { accessToken: "old-access", refreshToken: "old-refresh" },
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
try {
|
|
189
|
+
const store = KiroTokenStore.open(file, { writeBack: false });
|
|
190
|
+
const result = store.updateTokens("acct-a", {
|
|
191
|
+
accessToken: "new-access",
|
|
192
|
+
expiresAt: "2026-05-15T13:00:00.000Z",
|
|
193
|
+
});
|
|
194
|
+
// Returns the current (unchanged) account.
|
|
195
|
+
assert.equal(result?.accessToken, "old-access");
|
|
196
|
+
const verifyDb = new BetterSqlite3(file, { readonly: true });
|
|
197
|
+
const row = verifyDb
|
|
198
|
+
.prepare(`SELECT data FROM providerConnections WHERE id = 'acct-a'`)
|
|
199
|
+
.get();
|
|
200
|
+
assert.equal(JSON.parse(row.data).accessToken, "old-access");
|
|
201
|
+
verifyDb.close();
|
|
202
|
+
store.close();
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
rmSync(dir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
export class KRouterUsageLimitError extends Error {
|
|
2
|
+
statusCode = 429;
|
|
3
|
+
code = "KROUTER_TOKEN_LIMIT_REACHED";
|
|
4
|
+
usage;
|
|
5
|
+
constructor(message, usage) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "KRouterUsageLimitError";
|
|
8
|
+
this.usage = usage;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function fetchKRouterUsage({ apiKey, requestId, logger, timeoutMs, url, onEvent, }) {
|
|
12
|
+
if (!apiKey) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({ apiKey }),
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
});
|
|
27
|
+
const rawText = await response.text();
|
|
28
|
+
const rawPayload = parseJsonSafely(rawText);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
logger.warn({
|
|
31
|
+
requestId,
|
|
32
|
+
usageCheckStatus: response.status,
|
|
33
|
+
usageCheckBody: rawText,
|
|
34
|
+
}, "krouter usage check request failed");
|
|
35
|
+
await onEvent?.({
|
|
36
|
+
event: "krouter_usage_check_failed",
|
|
37
|
+
requestId,
|
|
38
|
+
usageCheckStatus: response.status,
|
|
39
|
+
usageCheckBody: rawText,
|
|
40
|
+
});
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const usage = extractUsageSnapshot(rawPayload);
|
|
44
|
+
logger.info({
|
|
45
|
+
requestId,
|
|
46
|
+
usageCheckStatus: response.status,
|
|
47
|
+
usageCheckMs: Date.now() - startedAt,
|
|
48
|
+
usageAllowed: usage.allowed,
|
|
49
|
+
usageRemaining: usage.remaining,
|
|
50
|
+
usageLimit: usage.limit,
|
|
51
|
+
usageUsed: usage.used,
|
|
52
|
+
}, "krouter usage check completed");
|
|
53
|
+
await onEvent?.({
|
|
54
|
+
event: "krouter_usage_checked",
|
|
55
|
+
requestId,
|
|
56
|
+
usageCheckStatus: response.status,
|
|
57
|
+
usageCheckMs: Date.now() - startedAt,
|
|
58
|
+
usageAllowed: usage.allowed,
|
|
59
|
+
usageRemaining: usage.remaining,
|
|
60
|
+
usageLimit: usage.limit,
|
|
61
|
+
usageUsed: usage.used,
|
|
62
|
+
});
|
|
63
|
+
return usage;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logger.warn({
|
|
67
|
+
err: error,
|
|
68
|
+
requestId,
|
|
69
|
+
}, "krouter usage check skipped after request error");
|
|
70
|
+
await onEvent?.({
|
|
71
|
+
event: "krouter_usage_check_error",
|
|
72
|
+
requestId,
|
|
73
|
+
errorMessage: error instanceof Error ? error.message : "Unknown usage check error",
|
|
74
|
+
});
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function ensureKRouterUsageAvailable(args) {
|
|
82
|
+
const usage = await fetchKRouterUsage(args);
|
|
83
|
+
if (!usage) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (usage.allowed === false) {
|
|
87
|
+
throw new KRouterUsageLimitError("KRouter API key is not allowed to make more requests", usage);
|
|
88
|
+
}
|
|
89
|
+
if (usage.remaining !== undefined && usage.remaining <= 0) {
|
|
90
|
+
throw new KRouterUsageLimitError("KRouter token limit has been reached", usage);
|
|
91
|
+
}
|
|
92
|
+
return usage;
|
|
93
|
+
}
|
|
94
|
+
function extractUsageSnapshot(raw) {
|
|
95
|
+
const limit = firstFiniteNumber(raw, [
|
|
96
|
+
["dailyTokenLimit"],
|
|
97
|
+
["limit"],
|
|
98
|
+
["token_limit"],
|
|
99
|
+
["tokens_limit"],
|
|
100
|
+
["quota"],
|
|
101
|
+
["quota_limit"],
|
|
102
|
+
["data", "dailyTokenLimit"],
|
|
103
|
+
["data", "limit"],
|
|
104
|
+
["data", "token_limit"],
|
|
105
|
+
["data", "tokens_limit"],
|
|
106
|
+
["data", "quota"],
|
|
107
|
+
["result", "dailyTokenLimit"],
|
|
108
|
+
["result", "limit"],
|
|
109
|
+
["result", "token_limit"],
|
|
110
|
+
]);
|
|
111
|
+
const used = firstFiniteNumber(raw, [
|
|
112
|
+
["dailyTokensUsed"],
|
|
113
|
+
["used"],
|
|
114
|
+
["used_tokens"],
|
|
115
|
+
["tokens_used"],
|
|
116
|
+
["consumed_tokens"],
|
|
117
|
+
["data", "dailyTokensUsed"],
|
|
118
|
+
["data", "used"],
|
|
119
|
+
["data", "used_tokens"],
|
|
120
|
+
["data", "tokens_used"],
|
|
121
|
+
["result", "dailyTokensUsed"],
|
|
122
|
+
["result", "used"],
|
|
123
|
+
["result", "used_tokens"],
|
|
124
|
+
]);
|
|
125
|
+
const remaining = firstFiniteNumber(raw, [
|
|
126
|
+
["remaining_tokens"],
|
|
127
|
+
["remaining_token"],
|
|
128
|
+
["tokens_remaining"],
|
|
129
|
+
["token_remaining"],
|
|
130
|
+
["remaining"],
|
|
131
|
+
["quota_remaining"],
|
|
132
|
+
["remaining_quota"],
|
|
133
|
+
["dailyTokensRemaining"],
|
|
134
|
+
["data", "remaining_tokens"],
|
|
135
|
+
["data", "tokens_remaining"],
|
|
136
|
+
["data", "remaining"],
|
|
137
|
+
["data", "quota_remaining"],
|
|
138
|
+
["data", "dailyTokensRemaining"],
|
|
139
|
+
["result", "remaining_tokens"],
|
|
140
|
+
["result", "tokens_remaining"],
|
|
141
|
+
["result", "remaining"],
|
|
142
|
+
["result", "dailyTokensRemaining"],
|
|
143
|
+
]);
|
|
144
|
+
const allowed = firstBoolean(raw, [
|
|
145
|
+
["allowed"],
|
|
146
|
+
["isActive"],
|
|
147
|
+
["isExpired"],
|
|
148
|
+
["active"],
|
|
149
|
+
["valid"],
|
|
150
|
+
["enabled"],
|
|
151
|
+
["can_use"],
|
|
152
|
+
["data", "allowed"],
|
|
153
|
+
["data", "isActive"],
|
|
154
|
+
["data", "isExpired"],
|
|
155
|
+
["data", "active"],
|
|
156
|
+
["data", "valid"],
|
|
157
|
+
["result", "allowed"],
|
|
158
|
+
["result", "isActive"],
|
|
159
|
+
["result", "isExpired"],
|
|
160
|
+
["result", "active"],
|
|
161
|
+
]);
|
|
162
|
+
const normalizedAllowed = deriveAllowed(raw, allowed);
|
|
163
|
+
const normalizedRemaining = remaining ?? (limit !== undefined && used !== undefined ? Math.max(limit - used, 0) : undefined);
|
|
164
|
+
return {
|
|
165
|
+
allowed: normalizedAllowed,
|
|
166
|
+
remaining: normalizedRemaining,
|
|
167
|
+
limit,
|
|
168
|
+
used,
|
|
169
|
+
raw,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function deriveAllowed(raw, allowed) {
|
|
173
|
+
const isExpired = firstBoolean(raw, [["isExpired"], ["data", "isExpired"], ["result", "isExpired"]]);
|
|
174
|
+
const isActive = firstBoolean(raw, [["isActive"], ["data", "isActive"], ["result", "isActive"]]);
|
|
175
|
+
if (isExpired === true) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (isActive !== undefined) {
|
|
179
|
+
return isActive;
|
|
180
|
+
}
|
|
181
|
+
return allowed;
|
|
182
|
+
}
|
|
183
|
+
function parseJsonSafely(value) {
|
|
184
|
+
if (!value.trim()) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(value);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function firstFiniteNumber(payload, paths) {
|
|
195
|
+
for (const path of paths) {
|
|
196
|
+
const value = readPath(payload, path);
|
|
197
|
+
const parsed = toFiniteNumber(value);
|
|
198
|
+
if (parsed !== undefined) {
|
|
199
|
+
return parsed;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
function firstBoolean(payload, paths) {
|
|
205
|
+
for (const path of paths) {
|
|
206
|
+
const value = readPath(payload, path);
|
|
207
|
+
if (typeof value === "boolean") {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
if (typeof value === "string") {
|
|
211
|
+
const normalized = value.trim().toLowerCase();
|
|
212
|
+
if (normalized === "true") {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (normalized === "false") {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
function readPath(payload, path) {
|
|
223
|
+
let current = payload;
|
|
224
|
+
for (const key of path) {
|
|
225
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
current = current[key];
|
|
229
|
+
}
|
|
230
|
+
return current;
|
|
231
|
+
}
|
|
232
|
+
function toFiniteNumber(value) {
|
|
233
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
if (typeof value === "string" && value.trim()) {
|
|
237
|
+
const parsed = Number(value);
|
|
238
|
+
if (Number.isFinite(parsed)) {
|
|
239
|
+
return parsed;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|