@cartanova/qgrid-cli 2.7.6 → 2.7.9
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/bundle/dist/application/qgrid/qgrid.frame.js +67 -30
- package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
- package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
- package/bundle/dist/application/qgrid/token-window-keepalive.js +10 -4
- package/bundle/dist/application/request-log/request-log.model.js +2 -2
- package/bundle/dist/application/setting/setting.constant.js +8 -4
- package/bundle/dist/application/setting/setting.model.js +4 -3
- package/bundle/dist/application/setting/setting.store.js +2 -2
- package/bundle/dist/application/setting/setting.types.js +4 -2
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +7 -2
- package/bundle/dist/application/token/token.types.js +3 -2
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
- package/bundle/dist/sonamu.config.js +3 -1
- package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +52 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +74 -26
- package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +23 -2
- package/bundle/src/application/qgrid/token-window-keepalive.ts +13 -3
- package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
- package/bundle/src/application/request-log/request-log.model.ts +3 -1
- package/bundle/src/application/setting/setting.constant.test.ts +2 -2
- package/bundle/src/application/setting/setting.constant.ts +8 -3
- package/bundle/src/application/setting/setting.model.ts +13 -2
- package/bundle/src/application/setting/setting.store.ts +2 -2
- package/bundle/src/application/setting/setting.types.ts +2 -0
- package/bundle/src/application/sonamu.generated.http +9 -4
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +3 -0
- package/bundle/src/application/token/token.entity.json +8 -1
- package/bundle/src/application/token/token.model.test.ts +29 -0
- package/bundle/src/application/token/token.model.ts +10 -1
- package/bundle/src/application/token/token.types.test.ts +10 -0
- package/bundle/src/application/token/token.types.ts +1 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/sonamu.config.ts +2 -0
- package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
- package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +31 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
- package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
- package/bundle/web-dist/client/assets/{index-BbOyk01G.js → index-D59Q_9Z4.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-B7Pc88bf.js → logs-BHVfI8ut.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CzMV4lZf.js → monit-EkLxH1oN.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-COWTRxZo.js → routes-Kbvuclip.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
- package/bundle/web-dist/client/assets/settings-B4uTFP3J.js +1 -0
- package/bundle/web-dist/client/assets/{show-YvdybkKd.js → show-BjIdSPTd.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-BbCSxbFu.js +1 -0
- package/bundle/web-dist/client/assets/use-oauth-login-flow-4OltfVAd.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
- package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
- package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-Ct3I9263.js} +7 -5
- package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
- package/bundle/web-dist/server/assets/{settings-CIYR7h3G.js → settings-JznSjwGQ.js} +79 -5
- package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
- package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-PR42jF1J.js} +6 -4
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-C-33Z5HA.js} +18 -17
- package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +10 -10
- package/dist/cli.js +10 -11
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -0
- package/skills/qgrid/references/cli-env-and-server-boot.md +2 -1
- package/skills/qgrid/references/openai-codex-runtime.md +12 -5
- package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +27 -1
- package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
- package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +0 -1
- package/bundle/web-dist/client/assets/tokens-DynVDx0c.js +0 -1
- package/bundle/web-dist/client/assets/use-oauth-login-flow-OzmHElsE.js +0 -1
- /package/bundle/web-dist/client/assets/{plus-DqBd6gHk.js → plus-p4vtg6LN.js} +0 -0
|
@@ -41,7 +41,8 @@ Content-Type: application/json
|
|
|
41
41
|
"active": false,
|
|
42
42
|
"ord": 0,
|
|
43
43
|
"quota_threshold": null,
|
|
44
|
-
"weight": 1
|
|
44
|
+
"weight": 1,
|
|
45
|
+
"keepalive_enabled": false
|
|
45
46
|
}
|
|
46
47
|
]
|
|
47
48
|
}
|
|
@@ -437,7 +438,9 @@ Content-Type: application/json
|
|
|
437
438
|
"modelName": "MODELNAME",
|
|
438
439
|
"effort": "EFFORT",
|
|
439
440
|
"projectName": "PROJECTNAME",
|
|
440
|
-
"history": "HISTORY"
|
|
441
|
+
"history": "HISTORY",
|
|
442
|
+
"isStructured": false,
|
|
443
|
+
"jsonSchema": null
|
|
441
444
|
}
|
|
442
445
|
}
|
|
443
446
|
|
|
@@ -501,7 +504,8 @@ Content-Type: application/json
|
|
|
501
504
|
"costSource": "provider",
|
|
502
505
|
"totalDurationMs": 0,
|
|
503
506
|
"history": "HISTORY",
|
|
504
|
-
"errorMessage": "ERRORMESSAGE"
|
|
507
|
+
"errorMessage": "ERRORMESSAGE",
|
|
508
|
+
"responseJsonOk": false
|
|
505
509
|
}
|
|
506
510
|
}
|
|
507
511
|
|
|
@@ -566,7 +570,8 @@ Content-Type: application/json
|
|
|
566
570
|
"id": 0,
|
|
567
571
|
"name": "NAME",
|
|
568
572
|
"quotaThreshold": null,
|
|
569
|
-
"weight": 0
|
|
573
|
+
"weight": 0,
|
|
574
|
+
"keepaliveEnabled": false
|
|
570
575
|
}
|
|
571
576
|
|
|
572
577
|
###
|
|
@@ -299,6 +299,7 @@ export const TokenBaseSchema = z.object({
|
|
|
299
299
|
ord: z.int(),
|
|
300
300
|
quota_threshold: z.int().nullable(),
|
|
301
301
|
weight: z.int(),
|
|
302
|
+
keepalive_enabled: z.boolean(),
|
|
302
303
|
});
|
|
303
304
|
export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
304
305
|
readonly __hasDefault__: readonly [
|
|
@@ -307,6 +308,7 @@ export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
|
307
308
|
"ord",
|
|
308
309
|
"quota_threshold",
|
|
309
310
|
"weight",
|
|
311
|
+
"keepalive_enabled",
|
|
310
312
|
"id",
|
|
311
313
|
];
|
|
312
314
|
};
|
|
@@ -558,6 +560,7 @@ export const TokenSubsetA = z.object({
|
|
|
558
560
|
ord: z.int(),
|
|
559
561
|
quota_threshold: z.int().nullable(),
|
|
560
562
|
weight: z.int(),
|
|
563
|
+
keepalive_enabled: z.boolean(),
|
|
561
564
|
});
|
|
562
565
|
export type TokenSubsetA = z.infer<typeof TokenSubsetA>;
|
|
563
566
|
export type TokenSubsetMapping = {
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
"type": "integer",
|
|
27
27
|
"desc": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
28
28
|
"dbDefault": "1"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "keepalive_enabled",
|
|
32
|
+
"type": "boolean",
|
|
33
|
+
"desc": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
|
|
34
|
+
"dbDefault": "false"
|
|
29
35
|
}
|
|
30
36
|
],
|
|
31
37
|
"indexes": [
|
|
@@ -46,7 +52,8 @@
|
|
|
46
52
|
"active",
|
|
47
53
|
"ord",
|
|
48
54
|
"quota_threshold",
|
|
49
|
-
"weight"
|
|
55
|
+
"weight",
|
|
56
|
+
"keepalive_enabled"
|
|
50
57
|
]
|
|
51
58
|
},
|
|
52
59
|
"enums": {
|
|
@@ -111,6 +111,35 @@ describe("TokenModel.updateFields", () => {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
describe("TokenModel.replaceByAccount", () => {
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("re-login으로 row가 교체돼도 기존 keepalive 선택을 보존한다", async () => {
|
|
120
|
+
vi.spyOn(TokenModel, "findByAccountIdentifier").mockResolvedValue([
|
|
121
|
+
{
|
|
122
|
+
id: 1,
|
|
123
|
+
created_at: new Date(),
|
|
124
|
+
active: true,
|
|
125
|
+
ord: 0,
|
|
126
|
+
quota_threshold: 80,
|
|
127
|
+
weight: 1,
|
|
128
|
+
keepalive_enabled: true,
|
|
129
|
+
...baseToken,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
vi.spyOn(TokenModel, "del").mockResolvedValue(1);
|
|
133
|
+
const save = vi.spyOn(TokenModel, "save").mockResolvedValue([2]);
|
|
134
|
+
|
|
135
|
+
await TokenModel.replaceByAccount("anthropic", "acc-1", baseToken);
|
|
136
|
+
|
|
137
|
+
expect(save).toHaveBeenCalledWith([
|
|
138
|
+
expect.objectContaining({ keepalive_enabled: true }),
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
114
143
|
describe("TokenModel.findActiveByProviderAndName", () => {
|
|
115
144
|
afterEach(() => {
|
|
116
145
|
vi.restoreAllMocks();
|
|
@@ -23,6 +23,7 @@ export type TokenUpdateFields = {
|
|
|
23
23
|
name?: string;
|
|
24
24
|
quota_threshold?: number | null;
|
|
25
25
|
weight?: number;
|
|
26
|
+
keepalive_enabled?: boolean;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
function applyCreateDefaults(sp: TokenSaveParams): TokenSaveParams {
|
|
@@ -235,8 +236,12 @@ class TokenModelClass extends BaseModelClass<
|
|
|
235
236
|
accountId: string | undefined,
|
|
236
237
|
saveParams: TokenSaveParams,
|
|
237
238
|
): Promise<void> {
|
|
239
|
+
let keepaliveEnabled = saveParams.keepalive_enabled;
|
|
238
240
|
if (accountId) {
|
|
239
241
|
const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
|
|
242
|
+
if (keepaliveEnabled === undefined && oldEntries.length > 0) {
|
|
243
|
+
keepaliveEnabled = oldEntries.some((entry) => entry.keepalive_enabled);
|
|
244
|
+
}
|
|
240
245
|
if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
|
|
241
246
|
} else {
|
|
242
247
|
logger.warn(
|
|
@@ -244,7 +249,11 @@ class TokenModelClass extends BaseModelClass<
|
|
|
244
249
|
);
|
|
245
250
|
}
|
|
246
251
|
|
|
247
|
-
await this.save([
|
|
252
|
+
await this.save([
|
|
253
|
+
keepaliveEnabled === undefined
|
|
254
|
+
? saveParams
|
|
255
|
+
: { ...saveParams, keepalive_enabled: keepaliveEnabled },
|
|
256
|
+
]);
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
/**
|
|
@@ -42,4 +42,14 @@ describe("TokenSaveParams", () => {
|
|
|
42
42
|
expect(() => TokenSaveParams.parse({ ...baseToken, weight })).toThrow();
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("keeps per-token keepalive opt-in optional and accepts booleans", () => {
|
|
47
|
+
expect(TokenSaveParams.parse(baseToken)).not.toHaveProperty("keepalive_enabled");
|
|
48
|
+
expect(
|
|
49
|
+
TokenSaveParams.parse({ ...baseToken, keepalive_enabled: true }).keepalive_enabled,
|
|
50
|
+
).toBe(true);
|
|
51
|
+
expect(() =>
|
|
52
|
+
TokenSaveParams.parse({ ...baseToken, keepalive_enabled: "true" }),
|
|
53
|
+
).toThrow();
|
|
54
|
+
});
|
|
45
55
|
});
|
|
@@ -320,6 +320,7 @@ const entityLabels = {
|
|
|
320
320
|
"entity.Token.ord": "정렬 순서",
|
|
321
321
|
"entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
|
|
322
322
|
"entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
323
|
+
"entity.Token.keepalive_enabled": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
|
|
323
324
|
"enum.TokenOrderBy.id-desc": "ID최신순",
|
|
324
325
|
"enum.TokenOrderBy.ord-asc": "순서순",
|
|
325
326
|
"enum.TokenSearchField.id": "ID",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
5
|
+
// add
|
|
6
|
+
table.boolean("keepalive_enabled").notNullable().defaultTo(knex.raw("false"));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function down(knex: Knex): Promise<void> {
|
|
11
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
12
|
+
// rollback - add
|
|
13
|
+
table.dropColumns("keepalive_enabled");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
|
|
26
26
|
import { handleServerError } from "./server-error-handler";
|
|
27
27
|
import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
|
|
28
|
+
import { stopOpenAICallbackRelay } from "./utils/providers/openai/openai-callback-relay";
|
|
28
29
|
import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
|
|
29
30
|
|
|
30
31
|
const host = process.env.HOST ?? "localhost";
|
|
@@ -241,6 +242,7 @@ export default defineConfig({
|
|
|
241
242
|
const log = getLogger(["qgrid", "startup"]);
|
|
242
243
|
stopExpiredTokenReminder();
|
|
243
244
|
stopTokenWindowKeepalive();
|
|
245
|
+
stopOpenAICallbackRelay();
|
|
244
246
|
setSettingChangeHandler(null);
|
|
245
247
|
QgridDispatcher.subscriber?.setTokenChangeHandler(null);
|
|
246
248
|
if (QgridDispatcher.openaiDispatcher) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
OPENAI_CALLBACK_PATH,
|
|
8
|
+
OPENAI_CALLBACK_PORTS,
|
|
9
|
+
openAICallbackRedirectUri,
|
|
10
|
+
startOpenAICallbackRelay,
|
|
11
|
+
stopOpenAICallbackRelay,
|
|
12
|
+
} from "./openai-callback-relay";
|
|
13
|
+
|
|
14
|
+
const DASHBOARD = "http://localhost:44900";
|
|
15
|
+
|
|
16
|
+
function freePort(): Promise<number> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const probe = http.createServer();
|
|
19
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
20
|
+
const port = (probe.address() as AddressInfo).port;
|
|
21
|
+
probe.close(() => resolve(port));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function occupy(port: number): Promise<http.Server> {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const server = http.createServer();
|
|
29
|
+
server.listen(port, "127.0.0.1", () => resolve(server));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function get(url: string): Promise<{ status: number; location?: string }> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const request = http.get(url, (response) => {
|
|
36
|
+
response.resume();
|
|
37
|
+
resolve({
|
|
38
|
+
status: response.statusCode ?? 0,
|
|
39
|
+
...(response.headers.location ? { location: response.headers.location } : {}),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
request.on("error", reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("openai callback relay", () => {
|
|
47
|
+
const occupied: http.Server[] = [];
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
stopOpenAICallbackRelay();
|
|
51
|
+
await Promise.all(occupied.splice(0).map((s) => new Promise((r) => s.close(r))));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("pins the redirect URI to the ports OpenAI registered for the Codex CLI client", () => {
|
|
55
|
+
expect(OPENAI_CALLBACK_PORTS).toEqual([1455, 1457]);
|
|
56
|
+
expect(openAICallbackRedirectUri(1455)).toBe("http://localhost:1455/auth/callback");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("forwards only code and state to the qgrid callback route", async () => {
|
|
60
|
+
const port = await freePort();
|
|
61
|
+
const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
|
|
62
|
+
expect(redirectUri).toBe(`http://localhost:${port}${OPENAI_CALLBACK_PATH}`);
|
|
63
|
+
|
|
64
|
+
const result = await get(
|
|
65
|
+
`http://127.0.0.1:${port}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state&extra=x`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result.status).toBe(302);
|
|
69
|
+
expect(result.location).toBe(
|
|
70
|
+
`${DASHBOARD}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("answers 404 on any other path", async () => {
|
|
75
|
+
const port = await freePort();
|
|
76
|
+
await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
|
|
77
|
+
|
|
78
|
+
expect((await get(`http://127.0.0.1:${port}/callback?code=c&state=s`)).status).toBe(404);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("falls back to the next registered port when the first is taken", async () => {
|
|
82
|
+
const taken = await freePort();
|
|
83
|
+
const fallback = await freePort();
|
|
84
|
+
occupied.push(await occupy(taken));
|
|
85
|
+
|
|
86
|
+
const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [taken, fallback]);
|
|
87
|
+
|
|
88
|
+
expect(redirectUri).toBe(`http://localhost:${fallback}${OPENAI_CALLBACK_PATH}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reuses the running listener for a second login", async () => {
|
|
92
|
+
const port = await freePort();
|
|
93
|
+
const first = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
|
|
94
|
+
const second = await startOpenAICallbackRelay(DASHBOARD, 60_000, [await freePort()]);
|
|
95
|
+
|
|
96
|
+
expect(second).toBe(first);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("fails when every registered port is taken", async () => {
|
|
100
|
+
const a = await freePort();
|
|
101
|
+
const b = await freePort();
|
|
102
|
+
occupied.push(await occupy(a), await occupy(b));
|
|
103
|
+
|
|
104
|
+
await expect(startOpenAICallbackRelay(DASHBOARD, 60_000, [a, b])).rejects.toThrow(
|
|
105
|
+
/PORTS_UNAVAILABLE/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
|
|
3
|
+
import { getLogger } from "@logtape/logtape";
|
|
4
|
+
|
|
5
|
+
const logger = getLogger(["qgrid", "oauth"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenAI matches the Codex CLI client's redirect URIs by exact string, and only these two loopback
|
|
9
|
+
* callbacks are registered. Any other host, port, or path makes /oauth/authorize fail with
|
|
10
|
+
* `invalid_authorize_request` before the login screen renders, so qgrid cannot point the callback
|
|
11
|
+
* at its own configurable server port. Keep in sync with the Codex CLI allow-list (default 1455,
|
|
12
|
+
* fallback 1457).
|
|
13
|
+
*/
|
|
14
|
+
export const OPENAI_CALLBACK_PORTS = [1455, 1457] as const;
|
|
15
|
+
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
16
|
+
|
|
17
|
+
export function openAICallbackRedirectUri(port: number): string {
|
|
18
|
+
return `http://localhost:${port}${OPENAI_CALLBACK_PATH}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Relay = {
|
|
22
|
+
server: http.Server;
|
|
23
|
+
redirectUri: string;
|
|
24
|
+
dashboardBase: string;
|
|
25
|
+
expiryTimer: NodeJS.Timeout;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let relay: Relay | null = null;
|
|
29
|
+
|
|
30
|
+
function forward(request: http.IncomingMessage, response: http.ServerResponse): void {
|
|
31
|
+
const current = relay;
|
|
32
|
+
const incoming = new URL(request.url ?? "/", "http://localhost");
|
|
33
|
+
if (!current || incoming.pathname !== OPENAI_CALLBACK_PATH) {
|
|
34
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
35
|
+
response.end("not found");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Only the two OAuth result fields cross over, onto a fixed loopback base. The request never
|
|
40
|
+
// contributes scheme, authority, or path bytes to the redirect target.
|
|
41
|
+
const target = new URL(OPENAI_CALLBACK_PATH, current.dashboardBase);
|
|
42
|
+
for (const field of ["code", "state"] as const) {
|
|
43
|
+
const value = incoming.searchParams.get(field);
|
|
44
|
+
if (value) target.searchParams.set(field, value);
|
|
45
|
+
}
|
|
46
|
+
response.writeHead(302, { Location: target.toString(), "Cache-Control": "no-store" });
|
|
47
|
+
response.end();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function listenOn(port: number): Promise<http.Server | null> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const server = http.createServer(forward);
|
|
53
|
+
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
54
|
+
server.close();
|
|
55
|
+
if (error.code === "EADDRINUSE" || error.code === "EACCES") resolve(null);
|
|
56
|
+
else reject(error);
|
|
57
|
+
});
|
|
58
|
+
// Bind loopback only, like the Codex CLI: the relay must not be reachable off-host.
|
|
59
|
+
server.listen(port, "127.0.0.1", () => resolve(server));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Opens a short-lived loopback listener on a registered Codex callback port and forwards the
|
|
65
|
+
* authorization code to qgrid own callback route. Returns the redirect URI to put in the authorize
|
|
66
|
+
* request. Repeated logins reuse the running listener and refresh its lifetime.
|
|
67
|
+
*/
|
|
68
|
+
export async function startOpenAICallbackRelay(
|
|
69
|
+
dashboardBase: string,
|
|
70
|
+
ttlMs: number,
|
|
71
|
+
ports: readonly number[] = OPENAI_CALLBACK_PORTS,
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
if (relay) {
|
|
74
|
+
relay.dashboardBase = dashboardBase;
|
|
75
|
+
relay.expiryTimer.refresh();
|
|
76
|
+
return relay.redirectUri;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const port of ports) {
|
|
80
|
+
const server = await listenOn(port);
|
|
81
|
+
if (!server) continue;
|
|
82
|
+
|
|
83
|
+
const expiryTimer = setTimeout(() => stopOpenAICallbackRelay(), ttlMs);
|
|
84
|
+
expiryTimer.unref();
|
|
85
|
+
const address = server.address();
|
|
86
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
87
|
+
relay = {
|
|
88
|
+
server,
|
|
89
|
+
redirectUri: openAICallbackRedirectUri(boundPort),
|
|
90
|
+
dashboardBase,
|
|
91
|
+
expiryTimer,
|
|
92
|
+
};
|
|
93
|
+
logger.info(`openai oauth callback relay listening on 127.0.0.1:${boundPort}`);
|
|
94
|
+
return relay.redirectUri;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error(`OPENAI_CALLBACK_PORTS_UNAVAILABLE_${ports.join("_")}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function stopOpenAICallbackRelay(): void {
|
|
101
|
+
if (!relay) return;
|
|
102
|
+
clearTimeout(relay.expiryTimer);
|
|
103
|
+
relay.server.close();
|
|
104
|
+
relay = null;
|
|
105
|
+
}
|
|
@@ -147,6 +147,37 @@ describe("readOpenAIQuotaUsage", () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it("converts the current WHAM limit_window_seconds duration to minutes", async () => {
|
|
151
|
+
const fetchMock = vi.fn<typeof fetch>(async () =>
|
|
152
|
+
new Response(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
rate_limit: {
|
|
155
|
+
primary_window: {
|
|
156
|
+
used_percent: 21,
|
|
157
|
+
limit_window_seconds: 604_800,
|
|
158
|
+
reset_at: 123,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
{ status: 200 },
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
readOpenAIQuotaUsage({
|
|
168
|
+
credentials: { accessToken: "access", accountId: "acct" },
|
|
169
|
+
fetch: fetchMock,
|
|
170
|
+
}),
|
|
171
|
+
).resolves.toMatchObject({
|
|
172
|
+
kind: "ok",
|
|
173
|
+
raw: {
|
|
174
|
+
rateLimits: {
|
|
175
|
+
primary: { usedPercent: 21, windowDurationMins: 10_080, resetsAt: 123 },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
150
181
|
it("fails open for direct HTTP failures", async () => {
|
|
151
182
|
await expect(
|
|
152
183
|
readOpenAIQuotaUsage({
|
|
@@ -47,11 +47,17 @@ function windowFrom(value: unknown) {
|
|
|
47
47
|
const source = value as Record<string, unknown>;
|
|
48
48
|
const usedPercent = Number(source.usedPercent ?? source.used_percent);
|
|
49
49
|
if (!Number.isFinite(usedPercent)) return null;
|
|
50
|
-
const
|
|
50
|
+
const durationMins = source.windowDurationMins ?? source.window_minutes;
|
|
51
|
+
const durationSeconds = source.limitWindowSeconds ?? source.limit_window_seconds;
|
|
51
52
|
const resets = source.resetsAt ?? source.reset_at;
|
|
52
53
|
return {
|
|
53
54
|
usedPercent,
|
|
54
|
-
windowDurationMins:
|
|
55
|
+
windowDurationMins:
|
|
56
|
+
durationMins !== null && durationMins !== undefined
|
|
57
|
+
? Number(durationMins)
|
|
58
|
+
: durationSeconds !== null && durationSeconds !== undefined
|
|
59
|
+
? Number(durationSeconds) / 60
|
|
60
|
+
: null,
|
|
55
61
|
resetsAt: resets === null || resets === undefined ? null : Number(resets),
|
|
56
62
|
};
|
|
57
63
|
}
|