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,265 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { buildApplyClientKeyboard, renderAdminScreen } from "../admin-actions.js";
3
+ import { getProxyErrorMessage, replyWithProxyError } from "../actions.js";
4
+ import { answerCallbackQuerySafely } from "../callbacks.js";
5
+ import { maskApiKey } from "../format.js";
6
+ import { ProxyClientError } from "../proxy-client.js";
7
+ import { buildTelegramSessionScope } from "../sessions.js";
8
+ export function registerApplyCommand(bot, deps, sessions) {
9
+ bot.command("apply", async (ctx) => {
10
+ const args = ctx.match?.toString().trim() || "";
11
+ if (!args) {
12
+ await ctx.reply("Choose a client to configure.", {
13
+ reply_markup: buildApplyClientKeyboard(),
14
+ });
15
+ return;
16
+ }
17
+ const [clientRaw, model, routeApiKey] = args.split(/\s+/g);
18
+ const client = clientRaw === "hermes" || clientRaw === "codex" ? clientRaw : null;
19
+ if (!client || !model) {
20
+ await ctx.reply("Usage: /apply <hermes|codex> <model> [routeApiKey]");
21
+ return;
22
+ }
23
+ try {
24
+ const result = await deps.proxyClient.applyClientConfig({
25
+ client,
26
+ model,
27
+ routeApiKey,
28
+ });
29
+ await ctx.reply([
30
+ `Applied config for ${client}`,
31
+ `model: ${model}`,
32
+ `routeApiKey: ${maskApiKey(routeApiKey ?? result?.status?.routeApiKey ?? null)}`,
33
+ `changed: ${String(result?.changed ?? false)}`,
34
+ ].join("\n"));
35
+ }
36
+ catch (error) {
37
+ await replyWithProxyError(ctx, error);
38
+ }
39
+ });
40
+ const applyActions = {
41
+ client: async (ctx) => {
42
+ await showProviderPicker(ctx, deps, ctx.match[1]);
43
+ },
44
+ start: async (ctx) => {
45
+ await showModelPicker(ctx, deps, sessions, {
46
+ client: ctx.match[1],
47
+ providerId: ctx.match[2],
48
+ });
49
+ },
50
+ provider: async (ctx) => {
51
+ await showModelPicker(ctx, deps, sessions, {
52
+ client: ctx.match[1],
53
+ providerId: ctx.match[2],
54
+ });
55
+ },
56
+ model: async (ctx) => {
57
+ const client = ctx.match[1];
58
+ const providerId = ctx.match[2];
59
+ const modelIndex = Number(ctx.match[3]);
60
+ const chatId = ctx.chat?.id?.toString();
61
+ const userId = ctx.from?.id?.toString();
62
+ const session = chatId && userId ? sessions.get(buildTelegramSessionScope(chatId, userId)) : undefined;
63
+ if (session?.kind !== "awaiting_apply_model_input" || session.providerId !== providerId) {
64
+ await showModelPicker(ctx, deps, sessions, { client, providerId });
65
+ return;
66
+ }
67
+ const model = session.models[modelIndex];
68
+ if (!model) {
69
+ await renderAdminScreen(ctx, {
70
+ text: "Model selection expired. Please start /apply again.",
71
+ loop: "apply",
72
+ });
73
+ return;
74
+ }
75
+ if (chatId && userId) {
76
+ sessions.clear(buildTelegramSessionScope(chatId, userId));
77
+ }
78
+ await applySelection(ctx, deps, { client, providerId, providerName: session.providerName, model });
79
+ },
80
+ "model-text": async (ctx) => {
81
+ const chatId = ctx.chat?.id?.toString();
82
+ const userId = ctx.from?.id?.toString();
83
+ if (!chatId || !userId) {
84
+ return;
85
+ }
86
+ const client = ctx.match[1];
87
+ const providerId = ctx.match[2];
88
+ const providerName = await maybeReadProviderName(deps, providerId);
89
+ sessions.set(buildTelegramSessionScope(chatId, userId), {
90
+ kind: "awaiting_apply_model_input",
91
+ client,
92
+ providerId,
93
+ providerName,
94
+ models: [],
95
+ });
96
+ await renderAdminScreen(ctx, {
97
+ text: "Send the model name to apply for this provider.",
98
+ loop: "apply",
99
+ });
100
+ },
101
+ };
102
+ bot.callbackQuery(/^v1:apply:client:(hermes|codex)$/, async (ctx) => {
103
+ await answerCallbackQuerySafely(ctx);
104
+ await applyActions.client(ctx);
105
+ });
106
+ bot.callbackQuery(/^v1:apply:start:(hermes|codex):(.+)$/, async (ctx) => {
107
+ await answerCallbackQuerySafely(ctx);
108
+ await applyActions.start(ctx);
109
+ });
110
+ bot.callbackQuery(/^v1:apply:provider:(hermes|codex):(.+)$/, async (ctx) => {
111
+ await answerCallbackQuerySafely(ctx);
112
+ await applyActions.provider(ctx);
113
+ });
114
+ bot.callbackQuery(/^v1:apply:model:(hermes|codex):([^:]+):(\d+)$/, async (ctx) => {
115
+ await answerCallbackQuerySafely(ctx);
116
+ await applyActions.model(ctx);
117
+ });
118
+ bot.callbackQuery(/^v1:apply:model-text:(hermes|codex):(.+)$/, async (ctx) => {
119
+ await answerCallbackQuerySafely(ctx);
120
+ await applyActions["model-text"](ctx);
121
+ });
122
+ bot.on("message:text", async (ctx, next) => {
123
+ const chatId = ctx.chat?.id?.toString();
124
+ const userId = ctx.from?.id?.toString();
125
+ if (!chatId || !userId) {
126
+ await next();
127
+ return;
128
+ }
129
+ const scope = buildTelegramSessionScope(chatId, userId);
130
+ const session = sessions.get(scope);
131
+ if (session?.kind !== "awaiting_apply_model_input") {
132
+ await next();
133
+ return;
134
+ }
135
+ if (ctx.message.text.startsWith("/")) {
136
+ await next();
137
+ return;
138
+ }
139
+ sessions.clear(scope);
140
+ await applySelection(ctx, deps, {
141
+ client: session.client,
142
+ providerId: session.providerId,
143
+ providerName: session.providerName,
144
+ model: ctx.message.text.trim(),
145
+ });
146
+ });
147
+ }
148
+ async function showProviderPicker(ctx, deps, client) {
149
+ try {
150
+ const payload = await deps.proxyClient.getClientConfigs();
151
+ const keyboard = new InlineKeyboard();
152
+ for (const provider of payload?.providerOptions ?? []) {
153
+ keyboard.text(provider.name, `v1:apply:provider:${client}:${provider.id}`).row();
154
+ }
155
+ await renderAdminScreen(ctx, {
156
+ text: `Choose provider for ${client}.`,
157
+ loop: "apply",
158
+ primaryKeyboard: keyboard,
159
+ });
160
+ }
161
+ catch (error) {
162
+ await renderAdminScreen(ctx, {
163
+ text: getProxyErrorMessage(error),
164
+ loop: "apply",
165
+ });
166
+ }
167
+ }
168
+ async function showModelPicker(ctx, deps, sessions, input) {
169
+ try {
170
+ const payload = await deps.proxyClient.getProviderModels(input.providerId);
171
+ const models = Array.isArray(payload?.models)
172
+ ? payload.models.filter((item) => typeof item === "string")
173
+ : [];
174
+ const providerName = await maybeReadProviderName(deps, input.providerId);
175
+ const chatId = ctx.chat?.id?.toString();
176
+ const userId = ctx.from?.id?.toString();
177
+ if (chatId && userId) {
178
+ sessions.set(buildTelegramSessionScope(chatId, userId), {
179
+ kind: "awaiting_apply_model_input",
180
+ client: input.client,
181
+ providerId: input.providerId,
182
+ providerName,
183
+ models,
184
+ });
185
+ }
186
+ const keyboard = new InlineKeyboard();
187
+ for (const [index, model] of models.slice(0, 8).entries()) {
188
+ keyboard.text(model.slice(0, 32), `v1:apply:model:${input.client}:${input.providerId}:${index}`).row();
189
+ }
190
+ keyboard.text("Type model manually", `v1:apply:model-text:${input.client}:${input.providerId}`);
191
+ await renderAdminScreen(ctx, {
192
+ text: [
193
+ `Provider: ${providerName ?? input.providerId}`,
194
+ `Choose model for ${input.client}, or type one manually.`,
195
+ ].join("\n"),
196
+ loop: "apply",
197
+ primaryKeyboard: keyboard,
198
+ });
199
+ }
200
+ catch (error) {
201
+ await renderAdminScreen(ctx, {
202
+ text: getProxyErrorMessage(error),
203
+ loop: "apply",
204
+ });
205
+ }
206
+ }
207
+ async function applySelection(ctx, deps, input) {
208
+ try {
209
+ await deps.proxyClient.setProviderRoute({
210
+ client: input.client,
211
+ providerId: input.providerId,
212
+ });
213
+ const result = await deps.proxyClient.applyClientConfig({
214
+ client: input.client,
215
+ model: input.model,
216
+ });
217
+ await renderAdminScreen(ctx, {
218
+ text: [
219
+ `Applied config for ${input.client}`,
220
+ `provider: ${input.providerName ?? input.providerId}`,
221
+ `model: ${input.model}`,
222
+ `baseUrl: ${result?.proxyBaseUrl ?? "n/a"}`,
223
+ `routeApiKey: ${maskApiKey(result?.status?.routeApiKey ?? null)}`,
224
+ `changed: ${String(result?.changed ?? false)}`,
225
+ ].join("\n"),
226
+ loop: "apply",
227
+ });
228
+ }
229
+ catch (error) {
230
+ if (error instanceof ProxyClientError && error.body?.error?.code === "MODEL_REQUIRED") {
231
+ await renderAdminScreen(ctx, {
232
+ text: "Model is required. Please choose a model or type one manually.",
233
+ loop: "apply",
234
+ });
235
+ return;
236
+ }
237
+ if (error instanceof ProxyClientError && error.body?.error?.code === "CLIENT_API_KEY_NOT_FOUND") {
238
+ await renderAdminScreen(ctx, {
239
+ text: "Selected client API key is no longer valid. Refreshing client status.",
240
+ loop: "apply",
241
+ });
242
+ return;
243
+ }
244
+ if (error instanceof ProxyClientError && error.body?.error?.code === "QUICK_APPLY_HOST_PATH_UNAVAILABLE") {
245
+ await renderAdminScreen(ctx, {
246
+ text: "Quick Apply cannot patch the host config path from this runtime.",
247
+ loop: "apply",
248
+ });
249
+ return;
250
+ }
251
+ await renderAdminScreen(ctx, {
252
+ text: getProxyErrorMessage(error),
253
+ loop: "apply",
254
+ });
255
+ }
256
+ }
257
+ async function maybeReadProviderName(deps, providerId) {
258
+ try {
259
+ const payload = await deps.proxyClient.getProviderDetails(providerId);
260
+ return typeof payload?.provider?.name === "string" ? payload.provider.name : undefined;
261
+ }
262
+ catch {
263
+ return undefined;
264
+ }
265
+ }
@@ -0,0 +1,13 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { sendClients } from "../actions.js";
3
+ export function registerClientsCommand(bot, deps) {
4
+ bot.command("clients", async (ctx) => {
5
+ await sendClients(ctx, deps);
6
+ await ctx.reply("Quick Apply:", {
7
+ reply_markup: new InlineKeyboard()
8
+ .text("Apply to Hermes", "v1:apply:client:hermes")
9
+ .row()
10
+ .text("Apply to Codex", "v1:apply:client:codex"),
11
+ });
12
+ });
13
+ }
@@ -0,0 +1,271 @@
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 { AuditLogRepository } from "../../audit-log.js";
7
+ import { BillingRepository } from "../../billing.js";
8
+ import { CustomerKeyRepository } from "../../customer-keys.js";
9
+ import { BotIdentityRepository } from "../bot-identity-repository.js";
10
+ import { registerCustomerActionCallbacks } from "../customer-actions.js";
11
+ import { registerMeCommand } from "./me.js";
12
+ import { registerQuotaCommand } from "./quota.js";
13
+ import { registerUsageCommand } from "./usage.js";
14
+ import { CustomerWorkspaceRepository } from "../customer-workspace-repository.js";
15
+ function createBotHarness() {
16
+ const handlers = new Map();
17
+ const callbackHandlers = [];
18
+ return {
19
+ bot: {
20
+ command(name, handler) {
21
+ handlers.set(name, handler);
22
+ },
23
+ callbackQuery(pattern, handler) {
24
+ callbackHandlers.push({ pattern, handler });
25
+ },
26
+ },
27
+ handler(name) {
28
+ const handler = handlers.get(name);
29
+ assert.ok(handler);
30
+ return handler;
31
+ },
32
+ callbackHandler(data) {
33
+ for (const entry of callbackHandlers) {
34
+ if (typeof entry.pattern === "string") {
35
+ if (entry.pattern === data) {
36
+ return { handler: entry.handler, match: [data] };
37
+ }
38
+ continue;
39
+ }
40
+ const match = data.match(entry.pattern);
41
+ if (match) {
42
+ return { handler: entry.handler, match };
43
+ }
44
+ }
45
+ assert.fail(`No callback handler for ${data}`);
46
+ },
47
+ };
48
+ }
49
+ function createContext(input) {
50
+ const replies = [];
51
+ const replyOptions = [];
52
+ const answeredCallbacks = [];
53
+ return {
54
+ from: { id: input.userId, is_bot: false, first_name: "User" },
55
+ chat: input.chatType === "private"
56
+ ? { id: input.chatId, type: "private", first_name: "User" }
57
+ : { id: input.chatId, type: "group", title: "Ops" },
58
+ message: {
59
+ message_id: 1,
60
+ date: 0,
61
+ chat: input.chatType === "private"
62
+ ? { id: input.chatId, type: "private", first_name: "User" }
63
+ : { id: input.chatId, type: "group", title: "Ops" },
64
+ text: `/${input.command}`,
65
+ },
66
+ replies,
67
+ replyOptions,
68
+ answeredCallbacks,
69
+ reply(text, options) {
70
+ replies.push(text);
71
+ replyOptions.push(options);
72
+ return Promise.resolve({});
73
+ },
74
+ answerCallbackQuery(payload) {
75
+ answeredCallbacks.push(payload ?? {});
76
+ return Promise.resolve(true);
77
+ },
78
+ };
79
+ }
80
+ async function withRepos(fn) {
81
+ const dir = mkdtempSync(path.join(os.tmpdir(), "customer-billing-command-"));
82
+ try {
83
+ const dbFile = path.join(dir, "bot.sqlite");
84
+ await fn({
85
+ identities: BotIdentityRepository.create(dbFile),
86
+ workspaces: CustomerWorkspaceRepository.create(dbFile),
87
+ customerKeys: CustomerKeyRepository.create(dbFile),
88
+ billing: BillingRepository.create(dbFile),
89
+ auditLog: AuditLogRepository.create(dbFile),
90
+ });
91
+ }
92
+ finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ }
96
+ test("usage command shows zero usage for a new entitlement", async () => {
97
+ await withRepos(async ({ workspaces, customerKeys, billing }) => {
98
+ const workspace = workspaces.ensureDefaultWorkspace({
99
+ ownerTelegramUserId: "42",
100
+ defaultClientRoute: "customers",
101
+ status: "active",
102
+ });
103
+ customerKeys.createKey({
104
+ workspaceId: workspace.id,
105
+ telegramUserId: "42",
106
+ clientRoute: "customers",
107
+ });
108
+ billing.grantSubscription({
109
+ workspaceId: workspace.id,
110
+ planId: "basic",
111
+ days: 30,
112
+ now: new Date("2026-04-27T00:00:00.000Z"),
113
+ });
114
+ const harness = createBotHarness();
115
+ registerUsageCommand(harness.bot, workspaces, customerKeys, billing);
116
+ const ctx = createContext({ userId: 42, chatId: 42, chatType: "private", command: "usage" });
117
+ await harness.handler("usage")(ctx);
118
+ assert.equal(ctx.replies.length, 1);
119
+ assert.equal(ctx.replies[0].includes("Tokens"), true);
120
+ assert.equal(ctx.replies[0].includes("• Used: 0"), true);
121
+ assert.equal(ctx.replies[0].includes("• Remaining: 10,000,000"), true);
122
+ });
123
+ });
124
+ test("me command shows the full customer key only in private chat", async () => {
125
+ await withRepos(async ({ identities, workspaces, customerKeys, auditLog }) => {
126
+ identities.upsertUser({
127
+ telegramUserId: "45",
128
+ defaultRole: "customer",
129
+ defaultStatus: "active",
130
+ });
131
+ const workspace = workspaces.ensureDefaultWorkspace({
132
+ ownerTelegramUserId: "45",
133
+ defaultClientRoute: "customers",
134
+ status: "active",
135
+ });
136
+ const created = customerKeys.createKey({
137
+ workspaceId: workspace.id,
138
+ telegramUserId: "45",
139
+ clientRoute: "customers",
140
+ });
141
+ const harness = createBotHarness();
142
+ registerMeCommand(harness.bot, identities, workspaces, customerKeys, auditLog);
143
+ const privateCtx = createContext({ userId: 45, chatId: 45, chatType: "private", command: "me" });
144
+ await harness.handler("me")(privateCtx);
145
+ assert.equal(privateCtx.replies[0].includes(`api_key: ${created.apiKey}`), true);
146
+ assert.equal(auditLog.listEvents({ event: "api_key.revealed", limit: 1 })[0]?.metadata.apiKey, "[redacted]");
147
+ const groupCtx = createContext({ userId: 45, chatId: -45, chatType: "group", command: "me" });
148
+ await harness.handler("me")(groupCtx);
149
+ assert.equal(groupCtx.replies[0].includes("api_key:"), false);
150
+ assert.equal(groupCtx.replies[0].includes("• Preview:"), true);
151
+ });
152
+ });
153
+ test("quota command shows expired entitlement details", async () => {
154
+ await withRepos(async ({ workspaces, customerKeys, billing }) => {
155
+ const workspace = workspaces.ensureDefaultWorkspace({
156
+ ownerTelegramUserId: "43",
157
+ defaultClientRoute: "customers",
158
+ status: "active",
159
+ });
160
+ customerKeys.createKey({
161
+ workspaceId: workspace.id,
162
+ telegramUserId: "43",
163
+ clientRoute: "customers",
164
+ });
165
+ const granted = billing.grantSubscription({
166
+ workspaceId: workspace.id,
167
+ planId: "basic",
168
+ days: 1,
169
+ now: new Date("2026-04-27T00:00:00.000Z"),
170
+ });
171
+ billing.incrementEntitlementUsage({
172
+ entitlementId: granted.entitlement.id,
173
+ workspaceId: workspace.id,
174
+ totalTokens: 11,
175
+ inputTokens: 5,
176
+ outputTokens: 6,
177
+ now: new Date("2026-04-27T01:00:00.000Z"),
178
+ });
179
+ billing.expireEntitlements(new Date("2026-04-29T00:00:00.000Z"));
180
+ const harness = createBotHarness();
181
+ registerQuotaCommand(harness.bot, workspaces, customerKeys, billing);
182
+ const ctx = createContext({ userId: 43, chatId: 43, chatType: "private", command: "quota" });
183
+ await harness.handler("quota")(ctx);
184
+ assert.equal(ctx.replies.length, 1);
185
+ assert.equal(ctx.replies[0].includes("Entitlement\n"), true);
186
+ assert.equal(ctx.replies[0].includes("• Status: expired"), true);
187
+ assert.equal(ctx.replies[0].includes("• Used: 11"), true);
188
+ assert.equal(ctx.replies[0].includes("• Remaining: 0"), true);
189
+ });
190
+ });
191
+ test("usage command refuses to show customer details in group chat", async () => {
192
+ await withRepos(async ({ workspaces, customerKeys, billing }) => {
193
+ const workspace = workspaces.ensureDefaultWorkspace({
194
+ ownerTelegramUserId: "44",
195
+ defaultClientRoute: "customers",
196
+ status: "active",
197
+ });
198
+ customerKeys.createKey({
199
+ workspaceId: workspace.id,
200
+ telegramUserId: "44",
201
+ clientRoute: "customers",
202
+ });
203
+ billing.grantSubscription({
204
+ workspaceId: workspace.id,
205
+ planId: "basic",
206
+ days: 30,
207
+ now: new Date("2026-04-27T00:00:00.000Z"),
208
+ });
209
+ const harness = createBotHarness();
210
+ registerUsageCommand(harness.bot, workspaces, customerKeys, billing);
211
+ const ctx = createContext({ userId: 44, chatId: -1001, chatType: "group", command: "usage" });
212
+ await harness.handler("usage")(ctx);
213
+ assert.equal(ctx.replies[0], "For safety, open a private chat with this bot and run /usage there.");
214
+ });
215
+ });
216
+ test("customer action buttons load key, usage, quota, and dashboard", async () => {
217
+ await withRepos(async ({ workspaces, customerKeys, billing, auditLog }) => {
218
+ const workspace = workspaces.ensureDefaultWorkspace({
219
+ ownerTelegramUserId: "46",
220
+ defaultClientRoute: "customers",
221
+ status: "active",
222
+ });
223
+ const created = customerKeys.createKey({
224
+ workspaceId: workspace.id,
225
+ telegramUserId: "46",
226
+ clientRoute: "customers",
227
+ });
228
+ const granted = billing.grantSubscription({
229
+ workspaceId: workspace.id,
230
+ planId: "basic",
231
+ days: 30,
232
+ now: new Date("2026-04-27T00:00:00.000Z"),
233
+ });
234
+ billing.incrementEntitlementUsage({
235
+ entitlementId: granted.entitlement.id,
236
+ workspaceId: workspace.id,
237
+ totalTokens: 13,
238
+ inputTokens: 5,
239
+ outputTokens: 8,
240
+ now: new Date("2026-04-27T01:00:00.000Z"),
241
+ });
242
+ const harness = createBotHarness();
243
+ registerCustomerActionCallbacks(harness.bot, workspaces, customerKeys, billing, auditLog);
244
+ const keyFound = harness.callbackHandler("v1:customer:key");
245
+ const keyCtx = createContext({ userId: 46, chatId: 46, chatType: "private", command: "callback" });
246
+ keyCtx.match = keyFound.match;
247
+ await keyFound.handler(keyCtx);
248
+ assert.equal(keyCtx.answeredCallbacks[0]?.text, "Loaded");
249
+ assert.equal(keyCtx.replies[0].includes(`api_key: ${created.apiKey}`), true);
250
+ assert.ok(keyCtx.replyOptions[0]);
251
+ assert.equal(auditLog.listEvents({ event: "api_key.revealed", limit: 1 })[0]?.metadata.apiKey, "[redacted]");
252
+ const usageFound = harness.callbackHandler("v1:customer:usage");
253
+ const usageCtx = createContext({ userId: 46, chatId: 46, chatType: "private", command: "callback" });
254
+ usageCtx.match = usageFound.match;
255
+ await usageFound.handler(usageCtx);
256
+ assert.equal(usageCtx.replies[0].includes("📊 Usage"), true);
257
+ assert.equal(usageCtx.replies[0].includes("• Used: 13"), true);
258
+ const quotaFound = harness.callbackHandler("v1:customer:quota");
259
+ const quotaCtx = createContext({ userId: 46, chatId: 46, chatType: "private", command: "callback" });
260
+ quotaCtx.match = quotaFound.match;
261
+ await quotaFound.handler(quotaCtx);
262
+ assert.equal(quotaCtx.replies[0].includes("🧾 Quota"), true);
263
+ assert.equal(quotaCtx.replies[0].includes("• Remaining: 9,999,987"), true);
264
+ const dashboardFound = harness.callbackHandler("v1:customer:dashboard");
265
+ const dashboardCtx = createContext({ userId: 46, chatId: 46, chatType: "private", command: "callback" });
266
+ dashboardCtx.match = dashboardFound.match;
267
+ await dashboardFound.handler(dashboardCtx);
268
+ assert.equal(dashboardCtx.answeredCallbacks[0]?.text, "Refreshed");
269
+ assert.equal(dashboardCtx.replies[0].includes("🏠 Dashboard"), true);
270
+ });
271
+ });
@@ -0,0 +1,138 @@
1
+ import { isAdmin } from "../auth.js";
2
+ import { maskApiKey } from "../format.js";
3
+ import { grantCustomerAccess } from "../grants.js";
4
+ import { replyWithProxyError } from "../actions.js";
5
+ import { formatDateTime, formatField, formatSection } from "../message-format.js";
6
+ import { sendCustomerCodexSetup } from "../codex-config-delivery.js";
7
+ function formatGrantUsage(billing) {
8
+ const planIds = billing.listPlans().map((plan) => plan.id);
9
+ return [
10
+ "Usage: /grant <telegramUserId> <planId> <days>",
11
+ planIds.length > 0 ? `Available planIds: ${planIds.join(", ")}` : undefined,
12
+ ]
13
+ .filter(Boolean)
14
+ .join("\n");
15
+ }
16
+ export function registerGrantCommand(bot, deps, identities, workspaces, customerKeys, billing, auditLog) {
17
+ bot.command("grant", async (ctx) => {
18
+ if (!isAdmin(ctx, deps.config)) {
19
+ await ctx.reply("Only admins can grant customer access.");
20
+ return;
21
+ }
22
+ const args = (ctx.match?.toString() || "").trim().split(/\s+/g).filter(Boolean);
23
+ const [telegramUserId, planId, daysRaw] = args;
24
+ const days = Number(daysRaw);
25
+ if (!/^\d+$/.test(telegramUserId ?? "") || !planId || !Number.isInteger(days) || days <= 0) {
26
+ await ctx.reply(formatGrantUsage(billing));
27
+ return;
28
+ }
29
+ if (!billing.getPlan(planId)) {
30
+ await ctx.reply([
31
+ `Unknown planId: ${planId}`,
32
+ formatGrantUsage(billing),
33
+ ].join("\n"));
34
+ return;
35
+ }
36
+ try {
37
+ const result = await grantCustomerAccess({
38
+ telegramUserId,
39
+ planId,
40
+ days,
41
+ defaultClientRoute: deps.config.defaultCustomerRoute,
42
+ identities,
43
+ workspaces,
44
+ customerKeys,
45
+ billing,
46
+ proxyClient: deps.proxyClient,
47
+ auditLog,
48
+ actor: { type: "admin", id: ctx.from?.id?.toString() },
49
+ });
50
+ const canShowApiKeyToAdmin = !!result.apiKey && ctx.chat?.type === "private";
51
+ if (result.apiKey && canShowApiKeyToAdmin) {
52
+ auditLog.record({
53
+ event: "api_key.revealed",
54
+ actor: { type: "admin", id: ctx.from?.id?.toString() },
55
+ subjectType: "customer_api_key",
56
+ subjectId: result.keyId,
57
+ metadata: {
58
+ telegramUserId,
59
+ workspaceId: result.workspaceId,
60
+ keyPreview: result.keyPreview,
61
+ audience: "admin_private_chat",
62
+ apiKey: result.apiKey,
63
+ },
64
+ });
65
+ }
66
+ await ctx.reply([
67
+ "Customer access granted",
68
+ formatSection("Customer", [
69
+ formatField("Telegram user ID", telegramUserId),
70
+ formatField("Plan", planId),
71
+ formatField("Client route", result.clientRoute),
72
+ formatField("Mode", result.mode),
73
+ ]),
74
+ formatSection("Workspace", [
75
+ formatField("Workspace ID", result.workspaceId),
76
+ formatField("Subscription ends at", formatDateTime(result.subscriptionEndsAt)),
77
+ ]),
78
+ formatSection("Key", [
79
+ formatField("Preview", result.apiKey ? maskApiKey(result.apiKey) : result.keyPreview),
80
+ canShowApiKeyToAdmin ? `api_key: ${result.apiKey}` : undefined,
81
+ result.apiKey && !canShowApiKeyToAdmin
82
+ ? "Full key: shown only in private admin chat"
83
+ : undefined,
84
+ ]),
85
+ ]
86
+ .filter(Boolean)
87
+ .join("\n\n"));
88
+ const customerNotified = result.apiKey
89
+ ? await sendCustomerCodexSetup(ctx, {
90
+ telegramUserId,
91
+ baseUrl: deps.config.publicResponsesBaseUrl,
92
+ apiKey: result.apiKey,
93
+ model: deps.config.defaultModel,
94
+ title: "Your access is active",
95
+ details: [
96
+ formatField("Plan ID", planId),
97
+ formatField("Client route", result.clientRoute),
98
+ formatField("Subscription ends at", formatDateTime(result.subscriptionEndsAt)),
99
+ ],
100
+ })
101
+ : false;
102
+ if (result.apiKey && customerNotified) {
103
+ auditLog.record({
104
+ event: "api_key.revealed",
105
+ actor: { type: "bot", id: "grant" },
106
+ subjectType: "customer_api_key",
107
+ subjectId: result.keyId,
108
+ metadata: {
109
+ telegramUserId,
110
+ workspaceId: result.workspaceId,
111
+ keyPreview: result.keyPreview,
112
+ audience: "customer_private_chat",
113
+ apiKey: result.apiKey,
114
+ },
115
+ });
116
+ }
117
+ if (!customerNotified) {
118
+ let fallbackDelivered = false;
119
+ try {
120
+ await ctx.api.sendMessage(Number(telegramUserId), [
121
+ "Your access is active",
122
+ "Run /apikey in this private chat to receive your Codex config files.",
123
+ ].join("\n\n"));
124
+ fallbackDelivered = true;
125
+ }
126
+ catch {
127
+ // Admin receives the manual follow-up notice below.
128
+ }
129
+ if (!fallbackDelivered) {
130
+ await ctx.reply("Customer notification could not be delivered yet. They may need to /start the bot first.");
131
+ }
132
+ }
133
+ }
134
+ catch (error) {
135
+ await replyWithProxyError(ctx, error);
136
+ }
137
+ });
138
+ }