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,228 @@
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 { BillingRepository } from "./billing.js";
7
+ function withRepository(fn) {
8
+ const dir = mkdtempSync(path.join(os.tmpdir(), "billing-repo-"));
9
+ try {
10
+ fn(BillingRepository.create(path.join(dir, "billing.sqlite")));
11
+ }
12
+ finally {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ }
16
+ test("BillingRepository seeds default plans", () => {
17
+ withRepository((repo) => {
18
+ const plans = repo.listPlans();
19
+ assert.equal(plans.some((plan) => plan.id === "basic"), true);
20
+ assert.equal(plans.length, 1);
21
+ assert.equal(plans[0]?.priceCents, 5_000);
22
+ assert.equal(plans[0]?.currency, "VND");
23
+ assert.equal(plans[0]?.monthlyTokenLimit, 10_000_000);
24
+ });
25
+ });
26
+ test("BillingRepository grants subscription and active entitlement", () => {
27
+ withRepository((repo) => {
28
+ const granted = repo.grantSubscription({
29
+ workspaceId: "workspace-1",
30
+ planId: "basic",
31
+ days: 30,
32
+ now: new Date("2026-04-27T00:00:00.000Z"),
33
+ });
34
+ assert.equal(granted.subscription.workspaceId, "workspace-1");
35
+ assert.equal(granted.subscription.planId, "basic");
36
+ assert.equal(granted.entitlement.workspaceId, "workspace-1");
37
+ assert.equal(granted.entitlement.status, "active");
38
+ assert.equal(repo.getActiveEntitlementForWorkspace("workspace-1", new Date("2026-04-28T00:00:00.000Z"))?.id, granted.entitlement.id);
39
+ });
40
+ });
41
+ test("BillingRepository keeps renewed entitlement pending until the current period ends", () => {
42
+ withRepository((repo) => {
43
+ const first = repo.grantSubscription({
44
+ workspaceId: "workspace-renew",
45
+ planId: "basic",
46
+ days: 30,
47
+ now: new Date("2026-04-01T00:00:00.000Z"),
48
+ });
49
+ const renewed = repo.grantSubscription({
50
+ workspaceId: "workspace-renew",
51
+ planId: "basic",
52
+ days: 30,
53
+ now: new Date("2026-04-15T00:00:00.000Z"),
54
+ });
55
+ assert.equal(renewed.subscription.currentPeriodStart, "2026-05-01T00:00:00.000Z");
56
+ assert.equal(renewed.entitlement.validFrom, "2026-05-01T00:00:00.000Z");
57
+ assert.equal(repo.getActiveEntitlementForWorkspace("workspace-renew", new Date("2026-04-20T00:00:00.000Z"))?.id, first.entitlement.id);
58
+ assert.equal(repo.getActiveEntitlementForWorkspace("workspace-renew", new Date("2026-05-02T00:00:00.000Z"))?.id, renewed.entitlement.id);
59
+ });
60
+ });
61
+ test("BillingRepository expires outdated entitlements and subscriptions", () => {
62
+ withRepository((repo) => {
63
+ repo.grantSubscription({
64
+ workspaceId: "workspace-1",
65
+ planId: "basic",
66
+ days: 1,
67
+ now: new Date("2026-04-27T00:00:00.000Z"),
68
+ });
69
+ const changes = repo.expireEntitlements(new Date("2026-04-29T00:00:00.000Z"));
70
+ assert.equal(changes, 1);
71
+ assert.equal(repo.getActiveEntitlementForWorkspace("workspace-1", new Date("2026-04-29T00:00:00.000Z")), undefined);
72
+ assert.equal(repo.getLatestSubscriptionForWorkspace("workspace-1")?.status, "expired");
73
+ });
74
+ });
75
+ test("BillingRepository tracks entitlement usage totals", () => {
76
+ withRepository((repo) => {
77
+ const granted = repo.grantSubscription({
78
+ workspaceId: "workspace-usage",
79
+ planId: "basic",
80
+ days: 30,
81
+ now: new Date("2026-04-27T00:00:00.000Z"),
82
+ });
83
+ repo.incrementEntitlementUsage({
84
+ entitlementId: granted.entitlement.id,
85
+ workspaceId: "workspace-usage",
86
+ customerApiKeyId: "key-1",
87
+ inputTokens: 10,
88
+ outputTokens: 5,
89
+ totalTokens: 15,
90
+ now: new Date("2026-04-27T01:00:00.000Z"),
91
+ });
92
+ const usage = repo.incrementEntitlementUsage({
93
+ entitlementId: granted.entitlement.id,
94
+ workspaceId: "workspace-usage",
95
+ inputTokens: 4,
96
+ outputTokens: 6,
97
+ totalTokens: 10,
98
+ now: new Date("2026-04-27T02:00:00.000Z"),
99
+ });
100
+ assert.equal(usage.inputTokens, 14);
101
+ assert.equal(usage.outputTokens, 11);
102
+ assert.equal(usage.totalTokens, 25);
103
+ assert.equal(repo.getEntitlementUsage(granted.entitlement.id)?.totalTokens, 25);
104
+ });
105
+ });
106
+ test("BillingRepository returns the latest entitlement for expired workspaces", () => {
107
+ withRepository((repo) => {
108
+ repo.grantSubscription({
109
+ workspaceId: "workspace-expired",
110
+ planId: "basic",
111
+ days: 1,
112
+ now: new Date("2026-04-27T00:00:00.000Z"),
113
+ });
114
+ repo.expireEntitlements(new Date("2026-04-29T00:00:00.000Z"));
115
+ const latest = repo.getLatestEntitlementForWorkspace("workspace-expired");
116
+ assert.ok(latest);
117
+ assert.equal(latest.status, "expired");
118
+ });
119
+ });
120
+ test("BillingRepository dedupes duplicate open renewal requests", () => {
121
+ withRepository((repo) => {
122
+ const first = repo.createRenewalRequest({
123
+ workspaceId: "workspace-1",
124
+ telegramUserId: "42",
125
+ requestedPlanId: "basic",
126
+ requestedDays: 30,
127
+ now: new Date("2026-04-27T00:00:00.000Z"),
128
+ });
129
+ const second = repo.createRenewalRequest({
130
+ workspaceId: "workspace-1",
131
+ telegramUserId: "42",
132
+ requestedPlanId: "basic",
133
+ requestedDays: 30,
134
+ now: new Date("2026-04-27T01:00:00.000Z"),
135
+ });
136
+ assert.equal(first.created, true);
137
+ assert.equal(second.created, false);
138
+ assert.equal(first.request.id, second.request.id);
139
+ assert.equal(repo.listRenewalRequests("open").length, 1);
140
+ });
141
+ });
142
+ test("BillingRepository keeps token top-up requests separate from renewals", () => {
143
+ withRepository((repo) => {
144
+ const renewal = repo.createRenewalRequest({
145
+ workspaceId: "workspace-kind",
146
+ telegramUserId: "42",
147
+ requestedPlanId: "basic",
148
+ requestedDays: 1,
149
+ });
150
+ const topup = repo.createRenewalRequest({
151
+ workspaceId: "workspace-kind",
152
+ telegramUserId: "42",
153
+ kind: "token_topup",
154
+ requestedTokenDelta: 1_000,
155
+ requestedTokenLotDays: 7,
156
+ priceVnd: 5_000,
157
+ });
158
+ const duplicateTopup = repo.createRenewalRequest({
159
+ workspaceId: "workspace-kind",
160
+ telegramUserId: "42",
161
+ kind: "token_topup",
162
+ requestedTokenDelta: 1_000,
163
+ requestedTokenLotDays: 7,
164
+ priceVnd: 5_000,
165
+ });
166
+ assert.equal(renewal.created, true);
167
+ assert.equal(topup.created, true);
168
+ assert.equal(duplicateTopup.created, false);
169
+ assert.equal(topup.request.kind, "token_topup");
170
+ assert.equal(topup.request.requestedTokenDelta, 1_000);
171
+ assert.equal(topup.request.requestedTokenLotDays, 7);
172
+ });
173
+ });
174
+ test("BillingRepository consumes active token lots by earliest expiry first", () => {
175
+ withRepository((repo) => {
176
+ const workspaceId = "workspace-lots";
177
+ const first = repo.createTokenTopUpLot({
178
+ workspaceId,
179
+ tokenDelta: 100,
180
+ days: 1,
181
+ now: new Date("2026-04-27T00:00:00.000Z"),
182
+ });
183
+ const second = repo.createTokenTopUpLot({
184
+ workspaceId,
185
+ tokenDelta: 200,
186
+ days: 2,
187
+ now: new Date("2026-04-27T00:00:00.000Z"),
188
+ });
189
+ repo.consumeWorkspaceUsage({
190
+ workspaceId,
191
+ customerApiKeyId: "key-1",
192
+ inputTokens: 60,
193
+ outputTokens: 90,
194
+ totalTokens: 150,
195
+ now: new Date("2026-04-27T01:00:00.000Z"),
196
+ });
197
+ assert.equal(repo.getEntitlementUsage(first.id)?.totalTokens, 100);
198
+ assert.equal(repo.getEntitlementUsage(second.id)?.totalTokens, 50);
199
+ assert.equal((repo.getEntitlementUsage(first.id)?.inputTokens ?? 0) + (repo.getEntitlementUsage(second.id)?.inputTokens ?? 0), 60);
200
+ assert.equal((repo.getEntitlementUsage(first.id)?.outputTokens ?? 0) + (repo.getEntitlementUsage(second.id)?.outputTokens ?? 0), 90);
201
+ assert.equal(repo.getUsableActiveEntitlementForWorkspace(workspaceId, new Date("2026-04-27T01:00:00.000Z"))?.id, second.id);
202
+ });
203
+ });
204
+ test("closing a renewal request does not alter entitlement", () => {
205
+ withRepository((repo) => {
206
+ const granted = repo.grantSubscription({
207
+ workspaceId: "workspace-close",
208
+ planId: "basic",
209
+ days: 30,
210
+ now: new Date("2026-04-27T00:00:00.000Z"),
211
+ });
212
+ const request = repo.createRenewalRequest({
213
+ workspaceId: "workspace-close",
214
+ telegramUserId: "99",
215
+ requestedPlanId: "basic",
216
+ requestedDays: 15,
217
+ now: new Date("2026-04-27T01:00:00.000Z"),
218
+ });
219
+ const closed = repo.closeRenewalRequest({
220
+ id: request.request.id,
221
+ resolution: "manual-close",
222
+ now: new Date("2026-04-27T02:00:00.000Z"),
223
+ });
224
+ assert.equal(closed?.status, "closed");
225
+ assert.equal(closed?.resolution, "manual-close");
226
+ assert.equal(repo.getActiveEntitlementForWorkspace("workspace-close", new Date("2026-04-27T02:00:00.000Z"))?.id, granted.entitlement.id);
227
+ });
228
+ });
@@ -0,0 +1,240 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import BetterSqlite3 from "better-sqlite3";
4
+ export class ChatGptOAuthStore {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ static create(dbFile) {
10
+ mkdirSync(path.dirname(dbFile), { recursive: true });
11
+ const db = new BetterSqlite3(dbFile);
12
+ ensureChatGptOAuthSchema(db);
13
+ return new ChatGptOAuthStore(db);
14
+ }
15
+ createSession(input) {
16
+ this.ensureSchema();
17
+ const now = input.now ?? new Date();
18
+ const expiresAt = new Date(now.getTime() + (input.ttlMs ?? 10 * 60 * 1000)).toISOString();
19
+ this.db
20
+ .prepare(`INSERT INTO chatgpt_oauth_sessions
21
+ (state, code_verifier, redirect_uri, status, error_message, created_at, expires_at)
22
+ VALUES (?, ?, ?, 'pending', NULL, ?, ?)`)
23
+ .run(input.state, input.codeVerifier, input.redirectUri, now.toISOString(), expiresAt);
24
+ return {
25
+ state: input.state,
26
+ codeVerifier: input.codeVerifier,
27
+ redirectUri: input.redirectUri,
28
+ status: "pending",
29
+ errorMessage: null,
30
+ createdAt: now.toISOString(),
31
+ expiresAt,
32
+ };
33
+ }
34
+ consumeSession(state, now = new Date()) {
35
+ this.ensureSchema();
36
+ const row = this.db
37
+ .prepare("SELECT * FROM chatgpt_oauth_sessions WHERE state = ?")
38
+ .get(state);
39
+ if (!row) {
40
+ throw new Error("Unknown or expired ChatGPT OAuth state");
41
+ }
42
+ const session = mapSessionRow(row);
43
+ if (session.status !== "pending") {
44
+ throw new Error("ChatGPT OAuth session is not pending");
45
+ }
46
+ if (Date.parse(session.expiresAt) <= now.getTime()) {
47
+ this.markSessionError(state, "OAuth session expired");
48
+ throw new Error("ChatGPT OAuth session expired");
49
+ }
50
+ this.db
51
+ .prepare("UPDATE chatgpt_oauth_sessions SET status = 'consumed' WHERE state = ?")
52
+ .run(state);
53
+ return session;
54
+ }
55
+ markSessionError(state, message) {
56
+ this.ensureSchema();
57
+ this.db
58
+ .prepare(`UPDATE chatgpt_oauth_sessions
59
+ SET status = 'error', error_message = ?
60
+ WHERE state = ? AND status = 'pending'`)
61
+ .run(message, state);
62
+ }
63
+ upsertAccount(bundle, now = new Date()) {
64
+ this.ensureSchema();
65
+ const id = buildAccountId(bundle);
66
+ const existing = this.getAccount(id);
67
+ const createdAt = existing?.createdAt ?? now.toISOString();
68
+ const updatedAt = now.toISOString();
69
+ this.db
70
+ .prepare(`INSERT INTO chatgpt_oauth_accounts
71
+ (id, email, account_id, id_token, access_token, refresh_token, expires_at,
72
+ last_refresh_at, disabled, created_at, updated_at)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
74
+ ON CONFLICT(id) DO UPDATE SET
75
+ email = excluded.email,
76
+ account_id = excluded.account_id,
77
+ id_token = excluded.id_token,
78
+ access_token = excluded.access_token,
79
+ refresh_token = excluded.refresh_token,
80
+ expires_at = excluded.expires_at,
81
+ last_refresh_at = excluded.last_refresh_at,
82
+ disabled = 0,
83
+ updated_at = excluded.updated_at`)
84
+ .run(id, bundle.email, bundle.accountId, bundle.idToken, bundle.accessToken, bundle.refreshToken, bundle.expiresAt, bundle.lastRefreshAt, createdAt, updatedAt);
85
+ return this.getAccountOrThrow(id);
86
+ }
87
+ updateTokens(id, bundle, now = new Date()) {
88
+ this.ensureSchema();
89
+ this.db
90
+ .prepare(`UPDATE chatgpt_oauth_accounts
91
+ SET email = ?, account_id = ?, id_token = ?, access_token = ?, refresh_token = ?,
92
+ expires_at = ?, last_refresh_at = ?, updated_at = ?
93
+ WHERE id = ?`)
94
+ .run(bundle.email, bundle.accountId, bundle.idToken, bundle.accessToken, bundle.refreshToken, bundle.expiresAt, bundle.lastRefreshAt, now.toISOString(), id);
95
+ return this.getAccountOrThrow(id);
96
+ }
97
+ listAccountsForUi() {
98
+ this.ensureSchema();
99
+ return this.listAccounts().map(redactAccount);
100
+ }
101
+ getRotationMode() {
102
+ this.ensureSchema();
103
+ const row = this.db
104
+ .prepare("SELECT value FROM chatgpt_oauth_settings WHERE key = 'rotation_mode'")
105
+ .get();
106
+ return parseRotationMode(row?.value);
107
+ }
108
+ setRotationMode(value) {
109
+ this.ensureSchema();
110
+ const mode = parseRotationMode(value);
111
+ this.db
112
+ .prepare(`INSERT INTO chatgpt_oauth_settings (key, value)
113
+ VALUES ('rotation_mode', ?)
114
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
115
+ .run(mode);
116
+ return mode;
117
+ }
118
+ listAvailableAccounts() {
119
+ return this.listAccounts().filter((account) => !account.disabled);
120
+ }
121
+ getAccount(id) {
122
+ this.ensureSchema();
123
+ const row = this.db
124
+ .prepare("SELECT * FROM chatgpt_oauth_accounts WHERE id = ?")
125
+ .get(id);
126
+ return row ? mapAccountRow(row) : undefined;
127
+ }
128
+ disableAccount(id) {
129
+ this.ensureSchema();
130
+ const result = this.db
131
+ .prepare("UPDATE chatgpt_oauth_accounts SET disabled = 1, updated_at = ? WHERE id = ?")
132
+ .run(new Date().toISOString(), id);
133
+ return result.changes > 0;
134
+ }
135
+ enableAccount(id) {
136
+ this.ensureSchema();
137
+ const result = this.db
138
+ .prepare("UPDATE chatgpt_oauth_accounts SET disabled = 0, updated_at = ? WHERE id = ?")
139
+ .run(new Date().toISOString(), id);
140
+ return result.changes > 0;
141
+ }
142
+ deleteAccount(id) {
143
+ this.ensureSchema();
144
+ const result = this.db.prepare("DELETE FROM chatgpt_oauth_accounts WHERE id = ?").run(id);
145
+ return result.changes > 0;
146
+ }
147
+ getAccountOrThrow(id) {
148
+ const account = this.getAccount(id);
149
+ if (!account) {
150
+ throw new Error(`ChatGPT OAuth account ${id} not found`);
151
+ }
152
+ return account;
153
+ }
154
+ listAccounts() {
155
+ this.ensureSchema();
156
+ const rows = this.db
157
+ .prepare("SELECT * FROM chatgpt_oauth_accounts ORDER BY email, account_id, id")
158
+ .all();
159
+ return rows.map(mapAccountRow);
160
+ }
161
+ ensureSchema() {
162
+ ensureChatGptOAuthSchema(this.db);
163
+ }
164
+ }
165
+ export function redactAccount(account) {
166
+ const { idToken: _idToken, accessToken: _accessToken, refreshToken: _refreshToken, ...view } = account;
167
+ return view;
168
+ }
169
+ function ensureChatGptOAuthSchema(db) {
170
+ db.exec(`
171
+ CREATE TABLE IF NOT EXISTS chatgpt_oauth_accounts (
172
+ id TEXT PRIMARY KEY,
173
+ email TEXT,
174
+ account_id TEXT,
175
+ id_token TEXT NOT NULL,
176
+ access_token TEXT NOT NULL,
177
+ refresh_token TEXT NOT NULL,
178
+ expires_at TEXT NOT NULL,
179
+ last_refresh_at TEXT,
180
+ disabled INTEGER NOT NULL DEFAULT 0,
181
+ created_at TEXT NOT NULL,
182
+ updated_at TEXT NOT NULL
183
+ );
184
+
185
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chatgpt_oauth_accounts_account_id
186
+ ON chatgpt_oauth_accounts(account_id)
187
+ WHERE account_id IS NOT NULL AND account_id != '';
188
+
189
+ CREATE TABLE IF NOT EXISTS chatgpt_oauth_sessions (
190
+ state TEXT PRIMARY KEY,
191
+ code_verifier TEXT NOT NULL,
192
+ redirect_uri TEXT NOT NULL,
193
+ status TEXT NOT NULL,
194
+ error_message TEXT,
195
+ created_at TEXT NOT NULL,
196
+ expires_at TEXT NOT NULL
197
+ );
198
+
199
+ CREATE TABLE IF NOT EXISTS chatgpt_oauth_settings (
200
+ key TEXT PRIMARY KEY,
201
+ value TEXT NOT NULL
202
+ );
203
+ `);
204
+ }
205
+ function parseRotationMode(value) {
206
+ return value === "random" || value === "first_available" ? value : "round_robin";
207
+ }
208
+ function buildAccountId(bundle) {
209
+ const stableId = bundle.accountId || bundle.email;
210
+ if (stableId) {
211
+ return `chatgpt-oauth:${stableId}`;
212
+ }
213
+ return `chatgpt-oauth:${bundle.accessToken.slice(0, 16)}`;
214
+ }
215
+ function mapAccountRow(row) {
216
+ return {
217
+ id: row.id,
218
+ email: row.email ?? "",
219
+ accountId: row.account_id ?? "",
220
+ idToken: row.id_token,
221
+ accessToken: row.access_token,
222
+ refreshToken: row.refresh_token,
223
+ expiresAt: row.expires_at,
224
+ lastRefreshAt: row.last_refresh_at,
225
+ disabled: row.disabled === 1,
226
+ createdAt: row.created_at,
227
+ updatedAt: row.updated_at,
228
+ };
229
+ }
230
+ function mapSessionRow(row) {
231
+ return {
232
+ state: row.state,
233
+ codeVerifier: row.code_verifier,
234
+ redirectUri: row.redirect_uri,
235
+ status: row.status,
236
+ errorMessage: row.error_message,
237
+ createdAt: row.created_at,
238
+ expiresAt: row.expires_at,
239
+ };
240
+ }
@@ -0,0 +1,88 @@
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 { ChatGptOAuthStore } from "./chatgpt-oauth-store.js";
7
+ test("stores ChatGPT OAuth accounts without exposing token fields in UI views", () => {
8
+ const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-oauth-"));
9
+ try {
10
+ const store = ChatGptOAuthStore.create(path.join(dir, "app.db"));
11
+ const account = store.upsertAccount({
12
+ idToken: "id-token",
13
+ accessToken: "access-token",
14
+ refreshToken: "refresh-token",
15
+ accountId: "acct_123",
16
+ email: "user@example.com",
17
+ expiresAt: "2026-04-27T01:00:00.000Z",
18
+ lastRefreshAt: "2026-04-27T00:00:00.000Z",
19
+ }, new Date("2026-04-27T00:00:00.000Z"));
20
+ assert.equal(account.id, "chatgpt-oauth:acct_123");
21
+ assert.equal(account.accessToken, "access-token");
22
+ const [view] = store.listAccountsForUi();
23
+ assert.equal(view.id, "chatgpt-oauth:acct_123");
24
+ assert.equal(view.email, "user@example.com");
25
+ assert.equal("accessToken" in view, false);
26
+ assert.equal("refreshToken" in view, false);
27
+ assert.equal("idToken" in view, false);
28
+ }
29
+ finally {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+ test("rejects expired OAuth sessions", () => {
34
+ const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-oauth-"));
35
+ try {
36
+ const store = ChatGptOAuthStore.create(path.join(dir, "app.db"));
37
+ store.createSession({
38
+ state: "state",
39
+ codeVerifier: "verifier",
40
+ redirectUri: "http://localhost/callback",
41
+ ttlMs: 1,
42
+ now: new Date("2026-04-27T00:00:00.000Z"),
43
+ });
44
+ assert.throws(() => store.consumeSession("state", new Date("2026-04-27T00:00:01.000Z")), /expired/);
45
+ }
46
+ finally {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ test("persists ChatGPT OAuth rotation mode", () => {
51
+ const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-oauth-"));
52
+ try {
53
+ const dbFile = path.join(dir, "app.db");
54
+ const store = ChatGptOAuthStore.create(dbFile);
55
+ assert.equal(store.getRotationMode(), "round_robin");
56
+ assert.equal(store.setRotationMode("random"), "random");
57
+ assert.equal(ChatGptOAuthStore.create(dbFile).getRotationMode(), "random");
58
+ assert.equal(store.setRotationMode("first_available"), "first_available");
59
+ assert.equal(store.setRotationMode("invalid"), "round_robin");
60
+ }
61
+ finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+ test("can disable and re-enable ChatGPT OAuth accounts", () => {
66
+ const dir = mkdtempSync(path.join(tmpdir(), "responses-proxy-oauth-"));
67
+ try {
68
+ const store = ChatGptOAuthStore.create(path.join(dir, "app.db"));
69
+ const account = store.upsertAccount({
70
+ idToken: "id-token",
71
+ accessToken: "access-token",
72
+ refreshToken: "refresh-token",
73
+ accountId: "acct_123",
74
+ email: "user@example.com",
75
+ expiresAt: "2026-04-27T01:00:00.000Z",
76
+ lastRefreshAt: "2026-04-27T00:00:00.000Z",
77
+ }, new Date("2026-04-27T00:00:00.000Z"));
78
+ assert.equal(store.disableAccount(account.id), true);
79
+ assert.equal(store.getAccount(account.id)?.disabled, true);
80
+ assert.equal(store.listAvailableAccounts().length, 0);
81
+ assert.equal(store.enableAccount(account.id), true);
82
+ assert.equal(store.getAccount(account.id)?.disabled, false);
83
+ assert.equal(store.listAvailableAccounts().length, 1);
84
+ }
85
+ finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
@@ -0,0 +1,118 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ export function generateChatGptPkceCodes() {
3
+ const codeVerifier = base64UrlEncode(randomBytes(96));
4
+ const codeChallenge = base64UrlEncode(createHash("sha256").update(codeVerifier).digest());
5
+ return { codeVerifier, codeChallenge };
6
+ }
7
+ export function generateChatGptOAuthState() {
8
+ return base64UrlEncode(randomBytes(32));
9
+ }
10
+ export function buildChatGptAuthUrl(config, state, pkce) {
11
+ const url = new URL(config.CHATGPT_OAUTH_AUTH_URL);
12
+ url.searchParams.set("client_id", config.CHATGPT_OAUTH_CLIENT_ID);
13
+ url.searchParams.set("response_type", "code");
14
+ url.searchParams.set("redirect_uri", config.CHATGPT_OAUTH_REDIRECT_URI);
15
+ url.searchParams.set("scope", "openid email profile offline_access");
16
+ url.searchParams.set("state", state);
17
+ url.searchParams.set("code_challenge", pkce.codeChallenge);
18
+ url.searchParams.set("code_challenge_method", "S256");
19
+ url.searchParams.set("prompt", "login");
20
+ url.searchParams.set("id_token_add_organizations", "true");
21
+ url.searchParams.set("codex_cli_simplified_flow", "true");
22
+ return url.toString();
23
+ }
24
+ export async function exchangeChatGptCodeForTokens(config, code, redirectUri, codeVerifier, fetchFn = fetch, now = new Date()) {
25
+ const body = new URLSearchParams({
26
+ grant_type: "authorization_code",
27
+ client_id: config.CHATGPT_OAUTH_CLIENT_ID,
28
+ code,
29
+ redirect_uri: redirectUri,
30
+ code_verifier: codeVerifier,
31
+ });
32
+ return requestTokenBundle(config.CHATGPT_OAUTH_TOKEN_URL, body, fetchFn, now);
33
+ }
34
+ export async function refreshChatGptTokens(config, refreshToken, fetchFn = fetch, now = new Date()) {
35
+ const body = new URLSearchParams({
36
+ grant_type: "refresh_token",
37
+ client_id: config.CHATGPT_OAUTH_CLIENT_ID,
38
+ refresh_token: refreshToken,
39
+ scope: "openid profile email",
40
+ });
41
+ return requestTokenBundle(config.CHATGPT_OAUTH_TOKEN_URL, body, fetchFn, now);
42
+ }
43
+ export function parseChatGptJwtClaims(idToken) {
44
+ const [, payload] = idToken.split(".");
45
+ if (!payload) {
46
+ return {};
47
+ }
48
+ try {
49
+ const decoded = JSON.parse(Buffer.from(base64UrlToBase64(payload), "base64").toString("utf8"));
50
+ if (!decoded || typeof decoded !== "object") {
51
+ return {};
52
+ }
53
+ const openAiAuth = readObject(decoded["https://api.openai.com/auth"]);
54
+ return {
55
+ email: readString(decoded.email),
56
+ accountId: readString(openAiAuth?.account_id) ??
57
+ readString(openAiAuth?.accountId) ??
58
+ readString(decoded.account_id) ??
59
+ readString(decoded.accountId) ??
60
+ readString(decoded.sub),
61
+ };
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ async function requestTokenBundle(tokenUrl, body, fetchFn, now) {
68
+ const response = await fetchFn(tokenUrl, {
69
+ method: "POST",
70
+ headers: {
71
+ Accept: "application/json",
72
+ "Content-Type": "application/x-www-form-urlencoded",
73
+ },
74
+ body,
75
+ });
76
+ const text = await response.text();
77
+ if (!response.ok) {
78
+ throw new Error(`ChatGPT OAuth token request failed with ${response.status}: ${text}`);
79
+ }
80
+ const payload = JSON.parse(text);
81
+ return normalizeChatGptTokenBundle(payload, now);
82
+ }
83
+ export function normalizeChatGptTokenBundle(payload, now = new Date()) {
84
+ const idToken = requireString(payload.id_token, "id_token");
85
+ const accessToken = requireString(payload.access_token, "access_token");
86
+ const refreshToken = requireString(payload.refresh_token, "refresh_token");
87
+ const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : 3600;
88
+ const claims = parseChatGptJwtClaims(idToken);
89
+ return {
90
+ idToken,
91
+ accessToken,
92
+ refreshToken,
93
+ accountId: claims.accountId ?? "",
94
+ email: claims.email ?? "",
95
+ expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
96
+ lastRefreshAt: now.toISOString(),
97
+ };
98
+ }
99
+ function requireString(value, field) {
100
+ if (typeof value !== "string" || !value.trim()) {
101
+ throw new Error(`ChatGPT OAuth token response missing ${field}`);
102
+ }
103
+ return value;
104
+ }
105
+ function readString(value) {
106
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
107
+ }
108
+ function readObject(value) {
109
+ return typeof value === "object" && value !== null && !Array.isArray(value)
110
+ ? value
111
+ : undefined;
112
+ }
113
+ function base64UrlEncode(bytes) {
114
+ return bytes.toString("base64url");
115
+ }
116
+ function base64UrlToBase64(value) {
117
+ return value.replace(/-/g, "+").replace(/_/g, "/");
118
+ }