@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
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
updateFieldsMock,
|
|
15
15
|
requestLogSaveMock,
|
|
16
16
|
requestLogCreateRunMock,
|
|
17
|
+
requestLogFinishRunMock,
|
|
17
18
|
appendStepMock,
|
|
18
19
|
dispatcherQueryMock,
|
|
19
20
|
dispatcherQueryStreamMock,
|
|
@@ -36,6 +37,7 @@ const {
|
|
|
36
37
|
updateFieldsMock: vi.fn(),
|
|
37
38
|
requestLogSaveMock: vi.fn(),
|
|
38
39
|
requestLogCreateRunMock: vi.fn(),
|
|
40
|
+
requestLogFinishRunMock: vi.fn(),
|
|
39
41
|
appendStepMock: vi.fn(),
|
|
40
42
|
dispatcherQueryMock: vi.fn(),
|
|
41
43
|
dispatcherQueryStreamMock: vi.fn(),
|
|
@@ -53,6 +55,7 @@ vi.mock("../request-log/request-log.model", () => ({
|
|
|
53
55
|
RequestLogModel: {
|
|
54
56
|
save: requestLogSaveMock,
|
|
55
57
|
createRun: requestLogCreateRunMock,
|
|
58
|
+
finishRun: requestLogFinishRunMock,
|
|
56
59
|
appendStep: appendStepMock,
|
|
57
60
|
},
|
|
58
61
|
}));
|
|
@@ -111,6 +114,7 @@ const tokenEntry = {
|
|
|
111
114
|
ord: 0,
|
|
112
115
|
quota_threshold: null,
|
|
113
116
|
weight: 1,
|
|
117
|
+
keepalive_enabled: false,
|
|
114
118
|
};
|
|
115
119
|
|
|
116
120
|
function deeplyNestedInputSchema(depth: number): unknown {
|
|
@@ -164,6 +168,14 @@ describe("QgridFrame.updateToken", () => {
|
|
|
164
168
|
expect(updateFieldsMock).toHaveBeenCalledWith(1, { weight: 4 });
|
|
165
169
|
});
|
|
166
170
|
|
|
171
|
+
it("updates only the supplied keepalive switch", async () => {
|
|
172
|
+
await expect(QgridFrame.updateToken(1, undefined, undefined, undefined, true)).resolves.toEqual({
|
|
173
|
+
updated: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(updateFieldsMock).toHaveBeenCalledWith(1, { keepalive_enabled: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
167
179
|
it("reports a missing token from the atomic update result", async () => {
|
|
168
180
|
updateFieldsMock.mockResolvedValueOnce(0);
|
|
169
181
|
|
|
@@ -881,6 +893,46 @@ describe("QgridFrame raw lifecycle API", () => {
|
|
|
881
893
|
expect.not.objectContaining({ model_name: expect.anything() }),
|
|
882
894
|
);
|
|
883
895
|
});
|
|
896
|
+
|
|
897
|
+
it("passes optional structured metadata through the external createRun API", async () => {
|
|
898
|
+
requestLogCreateRunMock.mockReset().mockResolvedValue(82);
|
|
899
|
+
const jsonSchema = JSON.stringify({
|
|
900
|
+
type: "object",
|
|
901
|
+
properties: { answer: { type: "string" } },
|
|
902
|
+
required: ["answer"],
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
await QgridFrame.createRun({
|
|
906
|
+
userPrompt: "hi",
|
|
907
|
+
modelName: "google/gemini-3.5-flash-lite",
|
|
908
|
+
isStructured: true,
|
|
909
|
+
jsonSchema,
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
expect(requestLogCreateRunMock).toHaveBeenCalledWith(
|
|
913
|
+
expect.objectContaining({
|
|
914
|
+
is_structured: true,
|
|
915
|
+
json_schema: jsonSchema,
|
|
916
|
+
}),
|
|
917
|
+
);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("passes optional response JSON validity through the external finishRun API", async () => {
|
|
921
|
+
requestLogFinishRunMock.mockReset().mockResolvedValue(undefined);
|
|
922
|
+
|
|
923
|
+
await QgridFrame.finishRun({
|
|
924
|
+
requestLogId: 82,
|
|
925
|
+
status: "succeeded",
|
|
926
|
+
response: '{"answer":"ok"}',
|
|
927
|
+
tokenName: "external",
|
|
928
|
+
responseJsonOk: true,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
expect(requestLogFinishRunMock).toHaveBeenCalledWith(
|
|
932
|
+
82,
|
|
933
|
+
expect.objectContaining({ response_json_ok: true }),
|
|
934
|
+
);
|
|
935
|
+
});
|
|
884
936
|
});
|
|
885
937
|
|
|
886
938
|
describe("QgridFrame.queryStream request logging", () => {
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
getRefreshToken,
|
|
13
13
|
} from "../../utils/providers/common/credentials";
|
|
14
14
|
import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
|
|
15
|
+
import {
|
|
16
|
+
OPENAI_CALLBACK_PORTS,
|
|
17
|
+
openAICallbackRedirectUri,
|
|
18
|
+
startOpenAICallbackRelay,
|
|
19
|
+
} from "../../utils/providers/openai/openai-callback-relay";
|
|
15
20
|
import {
|
|
16
21
|
buildOpenAIAuthUrl,
|
|
17
22
|
exchangeOpenAICode,
|
|
@@ -70,6 +75,7 @@ type PendingOAuth = {
|
|
|
70
75
|
};
|
|
71
76
|
const OAUTH_STATE_PREFIX = "oauth:state:";
|
|
72
77
|
const OAUTH_STATE_TTL = "5m";
|
|
78
|
+
const OAUTH_STATE_TTL_MS = 5 * 60_000;
|
|
73
79
|
|
|
74
80
|
function unixSecondsToIso(seconds: number | null | undefined): string | null {
|
|
75
81
|
return typeof seconds === "number" ? new Date(seconds * 1000).toISOString() : null;
|
|
@@ -101,9 +107,7 @@ async function deleteOAuthState(state: string): Promise<void> {
|
|
|
101
107
|
|
|
102
108
|
const trustedLoopbackBase = () => `http://localhost:${process.env.PORT ?? "44900"}`;
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
// allowlisted URIs, but never contribute authority or path bytes to either URI.
|
|
106
|
-
function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
|
|
110
|
+
function isRemoteOAuthRequest(): boolean {
|
|
107
111
|
try {
|
|
108
112
|
const { headers } = Sonamu.getContext();
|
|
109
113
|
const origin = typeof headers.origin === "string" ? headers.origin : undefined;
|
|
@@ -112,13 +116,35 @@ function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code
|
|
|
112
116
|
const host = origin
|
|
113
117
|
? new URL(origin).hostname
|
|
114
118
|
: new URL(`http://${rawHost ?? "localhost"}`).hostname;
|
|
115
|
-
|
|
116
|
-
return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
|
|
117
|
-
}
|
|
119
|
+
return !new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(host);
|
|
118
120
|
} catch {}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Anthropic's remote/manual flow uses its fixed console callback. Headers may select between two
|
|
125
|
+
// allowlisted URIs, but never contribute authority or path bytes to either URI.
|
|
126
|
+
function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
|
|
127
|
+
if (isRemoteOAuthRequest()) {
|
|
128
|
+
return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
|
|
129
|
+
}
|
|
119
130
|
return { redirectUri: `${trustedLoopbackBase()}/callback`, mode: "redirect" };
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function parsePastedOAuthResult(input: string): { code: string; state: string } | null {
|
|
134
|
+
const trimmed = input.trim();
|
|
135
|
+
try {
|
|
136
|
+
const callback = new URL(trimmed);
|
|
137
|
+
const code = callback.searchParams.get("code")?.trim() ?? "";
|
|
138
|
+
const state = callback.searchParams.get("state")?.trim() ?? "";
|
|
139
|
+
if (code && state) return { code, state };
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
const separatorAt = trimmed.indexOf("#");
|
|
143
|
+
const code = separatorAt > 0 ? trimmed.slice(0, separatorAt).trim() : "";
|
|
144
|
+
const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1).trim() : "";
|
|
145
|
+
return code && state ? { code, state } : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
function rejectImageGenerationStream(args: QueryInput): void {
|
|
123
149
|
if (!args.imageGeneration) return;
|
|
124
150
|
throw new BadRequestException(
|
|
@@ -401,6 +427,8 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
401
427
|
project_name: input.projectName,
|
|
402
428
|
system_prompt: input.systemPrompt,
|
|
403
429
|
history: input.history ? JSON.parse(input.history) : undefined,
|
|
430
|
+
is_structured: input.isStructured,
|
|
431
|
+
json_schema: input.jsonSchema,
|
|
404
432
|
});
|
|
405
433
|
return { requestLogId };
|
|
406
434
|
}
|
|
@@ -457,6 +485,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
457
485
|
duration_ms: input.totalDurationMs,
|
|
458
486
|
history: input.history ? JSON.parse(input.history) : undefined,
|
|
459
487
|
error_message: input.errorMessage,
|
|
488
|
+
response_json_ok: input.responseJsonOk,
|
|
460
489
|
});
|
|
461
490
|
return { ok: true };
|
|
462
491
|
}
|
|
@@ -505,6 +534,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
505
534
|
name?: string,
|
|
506
535
|
quotaThreshold?: number | null,
|
|
507
536
|
weight?: number,
|
|
537
|
+
keepaliveEnabled?: boolean,
|
|
508
538
|
): Promise<{ updated: boolean }> {
|
|
509
539
|
if (
|
|
510
540
|
quotaThreshold !== undefined &&
|
|
@@ -525,6 +555,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
525
555
|
if (name !== undefined) patch.name = name;
|
|
526
556
|
if (quotaThreshold !== undefined) patch.quota_threshold = quotaThreshold;
|
|
527
557
|
if (weight !== undefined) patch.weight = weight;
|
|
558
|
+
if (keepaliveEnabled !== undefined) patch.keepalive_enabled = keepaliveEnabled;
|
|
528
559
|
if (Object.keys(patch).length === 0) return { updated: false };
|
|
529
560
|
|
|
530
561
|
const updated = await TokenModel.updateFields(id, patch);
|
|
@@ -572,18 +603,16 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
572
603
|
return { authUrl, mode };
|
|
573
604
|
}
|
|
574
605
|
|
|
575
|
-
//
|
|
606
|
+
// 원격 접속의 수동 완료: Anthropic `code#state` 또는 OpenAI callback URL을 받아 교환한다.
|
|
576
607
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
577
608
|
async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
const code = separatorAt > 0 ? trimmed.slice(0, separatorAt) : "";
|
|
581
|
-
const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1) : "";
|
|
582
|
-
if (!code || !state) {
|
|
609
|
+
const result = parsePastedOAuthResult(pastedCode);
|
|
610
|
+
if (!result) {
|
|
583
611
|
throw new BadRequestException(
|
|
584
|
-
"expected the code shown after login, in code#state form" as LocalizedString,
|
|
612
|
+
"expected the callback URL or code shown after login, in code#state form" as LocalizedString,
|
|
585
613
|
);
|
|
586
614
|
}
|
|
615
|
+
const { code, state } = result;
|
|
587
616
|
|
|
588
617
|
const pending = await getOAuthState(state);
|
|
589
618
|
if (!pending) {
|
|
@@ -593,11 +622,12 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
593
622
|
}
|
|
594
623
|
await deleteOAuthState(state);
|
|
595
624
|
|
|
596
|
-
if (pending.provider
|
|
597
|
-
|
|
625
|
+
if (pending.provider === "openai") {
|
|
626
|
+
await this.completeOpenAILogin(code, pending);
|
|
627
|
+
} else {
|
|
628
|
+
await this.completeAnthropicLogin(code, state, pending);
|
|
598
629
|
}
|
|
599
630
|
|
|
600
|
-
await this.completeAnthropicLogin(code, state, pending);
|
|
601
631
|
return { added: true, name: pending.name };
|
|
602
632
|
}
|
|
603
633
|
|
|
@@ -628,6 +658,17 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
628
658
|
notifyTokenAdded(pending.name, "anthropic");
|
|
629
659
|
}
|
|
630
660
|
|
|
661
|
+
private async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
|
|
662
|
+
const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
|
|
663
|
+
await TokenModel.replaceByAccount("openai", creds.accountId, {
|
|
664
|
+
provider: "openai",
|
|
665
|
+
credentials: creds,
|
|
666
|
+
name: pending.name,
|
|
667
|
+
});
|
|
668
|
+
logger.info(`OpenAI token saved for ${pending.name}`);
|
|
669
|
+
notifyTokenAdded(pending.name, "openai");
|
|
670
|
+
}
|
|
671
|
+
|
|
631
672
|
async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
|
|
632
673
|
const pending = await getOAuthState(state);
|
|
633
674
|
if (!pending) {
|
|
@@ -638,14 +679,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
638
679
|
|
|
639
680
|
try {
|
|
640
681
|
if (pending.provider === "openai") {
|
|
641
|
-
|
|
642
|
-
await TokenModel.replaceByAccount("openai", creds.accountId, {
|
|
643
|
-
provider: "openai",
|
|
644
|
-
credentials: creds,
|
|
645
|
-
name: pending.name,
|
|
646
|
-
});
|
|
647
|
-
logger.info(`OpenAI token saved for ${pending.name}`);
|
|
648
|
-
notifyTokenAdded(pending.name, "openai");
|
|
682
|
+
await this.completeOpenAILogin(code, pending);
|
|
649
683
|
} else {
|
|
650
684
|
await this.completeAnthropicLogin(code, state, pending);
|
|
651
685
|
}
|
|
@@ -662,10 +696,24 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
662
696
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
663
697
|
async oauthStartOpenAI(name: string): Promise<OAuthStartResult> {
|
|
664
698
|
const { codeVerifier, codeChallenge, state } = generateOpenAIPKCE();
|
|
665
|
-
const
|
|
699
|
+
const mode = isRemoteOAuthRequest() ? "code" : "redirect";
|
|
700
|
+
let redirectUri: string;
|
|
701
|
+
if (mode === "code") {
|
|
702
|
+
// OpenAI accepts only the Codex CLI's registered loopback URI. On a remote dashboard the
|
|
703
|
+
// browser cannot reach the server's loopback, so the user pastes the resulting callback URL.
|
|
704
|
+
redirectUri = openAICallbackRedirectUri(OPENAI_CALLBACK_PORTS[0]);
|
|
705
|
+
} else {
|
|
706
|
+
try {
|
|
707
|
+
redirectUri = await startOpenAICallbackRelay(trustedLoopbackBase(), OAUTH_STATE_TTL_MS);
|
|
708
|
+
} catch {
|
|
709
|
+
throw new BadRequestException(
|
|
710
|
+
`OpenAI login needs local port ${OPENAI_CALLBACK_PORTS.join(" or ")} — close any running \`codex login\` and retry` as LocalizedString,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
666
714
|
const authUrl = buildOpenAIAuthUrl(codeChallenge, state, redirectUri);
|
|
667
715
|
await setOAuthState(state, { codeVerifier, name, redirectUri, provider: "openai" });
|
|
668
|
-
return { authUrl, mode
|
|
716
|
+
return { authUrl, mode };
|
|
669
717
|
}
|
|
670
718
|
|
|
671
719
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -214,6 +214,8 @@ export const CreateRunInput = z.object({
|
|
|
214
214
|
effort: z.string().optional(),
|
|
215
215
|
projectName: z.string().optional(),
|
|
216
216
|
history: z.string().optional(),
|
|
217
|
+
isStructured: z.boolean().optional(),
|
|
218
|
+
jsonSchema: z.string().nullable().optional(),
|
|
217
219
|
});
|
|
218
220
|
export type CreateRunInput = z.infer<typeof CreateRunInput>;
|
|
219
221
|
|
|
@@ -266,6 +268,7 @@ export const FinishRunInput = z.object({
|
|
|
266
268
|
totalDurationMs: z.number().optional(),
|
|
267
269
|
history: z.string().optional(),
|
|
268
270
|
errorMessage: z.string().optional(),
|
|
271
|
+
responseJsonOk: z.boolean().optional(),
|
|
269
272
|
});
|
|
270
273
|
export type FinishRunInput = z.infer<typeof FinishRunInput>;
|
|
271
274
|
|
|
@@ -284,7 +287,7 @@ export type TokenStats = z.infer<typeof TokenStats>;
|
|
|
284
287
|
export const OAuthStartResult = z.object({
|
|
285
288
|
authUrl: z.string(),
|
|
286
289
|
// redirect: 브라우저 자동 복귀(루프백 접속). code: 콘솔 콜백 + 코드 붙여넣기(원격 접속).
|
|
287
|
-
// OpenAI 경로는
|
|
290
|
+
// OpenAI 원격 경로는 등록된 loopback callback URL 전체를 붙여넣는 code 모드를 쓴다.
|
|
288
291
|
mode: z.enum(["redirect", "code"]),
|
|
289
292
|
});
|
|
290
293
|
export type OAuthStartResult = z.infer<typeof OAuthStartResult>;
|
|
@@ -6,4 +6,10 @@ describe("tokens trigger setup", () => {
|
|
|
6
6
|
it("leaves weight notifications to the versioned migration trigger", () => {
|
|
7
7
|
expect(TOKENS_TRIGGER_SETUP_SQL).not.toContain("OLD.weight IS DISTINCT FROM NEW.weight");
|
|
8
8
|
});
|
|
9
|
+
|
|
10
|
+
it("notifies subscribers when per-token keepalive membership changes", () => {
|
|
11
|
+
expect(TOKENS_TRIGGER_SETUP_SQL).toContain(
|
|
12
|
+
"OLD.keepalive_enabled IS DISTINCT FROM NEW.keepalive_enabled",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
9
15
|
});
|
|
@@ -40,7 +40,8 @@ export const TOKENS_TRIGGER_SETUP_SQL = `
|
|
|
40
40
|
OLD.credentials IS DISTINCT FROM NEW.credentials OR
|
|
41
41
|
OLD.provider IS DISTINCT FROM NEW.provider OR
|
|
42
42
|
OLD.name IS DISTINCT FROM NEW.name OR
|
|
43
|
-
OLD.quota_threshold IS DISTINCT FROM NEW.quota_threshold
|
|
43
|
+
OLD.quota_threshold IS DISTINCT FROM NEW.quota_threshold OR
|
|
44
|
+
OLD.keepalive_enabled IS DISTINCT FROM NEW.keepalive_enabled
|
|
44
45
|
)
|
|
45
46
|
EXECUTE FUNCTION public.tokens_notify();
|
|
46
47
|
`;
|
|
@@ -15,13 +15,19 @@ const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000;
|
|
|
15
15
|
|
|
16
16
|
function token(
|
|
17
17
|
id: number,
|
|
18
|
-
overrides: Partial<{
|
|
18
|
+
overrides: Partial<{
|
|
19
|
+
name: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
active: boolean;
|
|
22
|
+
keepalive_enabled: boolean;
|
|
23
|
+
}> = {},
|
|
19
24
|
) {
|
|
20
25
|
return {
|
|
21
26
|
id,
|
|
22
27
|
name: `anthropic/token-${id}`,
|
|
23
28
|
provider: "anthropic",
|
|
24
29
|
active: true,
|
|
30
|
+
keepalive_enabled: true,
|
|
25
31
|
...overrides,
|
|
26
32
|
};
|
|
27
33
|
}
|
|
@@ -40,6 +46,7 @@ function deps(overrides: Partial<TokenWindowKeepaliveDeps> = {}): TokenWindowKee
|
|
|
40
46
|
readUsage: vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString())),
|
|
41
47
|
dispatch: vi.fn(async () => undefined),
|
|
42
48
|
readSetting: () => "true",
|
|
49
|
+
isRunnerEnabled: () => true,
|
|
43
50
|
now: () => Date.now(),
|
|
44
51
|
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
45
52
|
clearTimer: (handle) => clearTimeout(handle),
|
|
@@ -108,7 +115,7 @@ describe("token window keepalive", () => {
|
|
|
108
115
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
109
116
|
});
|
|
110
117
|
|
|
111
|
-
it("
|
|
118
|
+
it("비활성·비Anthropic·keepalive off 토큰은 대상에서 제외한다", async () => {
|
|
112
119
|
const readUsage = vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
113
120
|
await startTokenWindowKeepalive(
|
|
114
121
|
deps({
|
|
@@ -116,6 +123,7 @@ describe("token window keepalive", () => {
|
|
|
116
123
|
token(1),
|
|
117
124
|
token(2, { provider: "openai" }),
|
|
118
125
|
token(3, { active: false }),
|
|
126
|
+
token(4, { keepalive_enabled: false }),
|
|
119
127
|
]),
|
|
120
128
|
readUsage,
|
|
121
129
|
}),
|
|
@@ -211,6 +219,19 @@ describe("token window keepalive", () => {
|
|
|
211
219
|
expect(vi.getTimerCount()).toBe(0);
|
|
212
220
|
});
|
|
213
221
|
|
|
222
|
+
it("이 인스턴스가 runner로 지정되지 않으면 전역 스위치와 무관하게 시작하지 않는다", async () => {
|
|
223
|
+
const testDeps = deps({
|
|
224
|
+
isRunnerEnabled: () => false,
|
|
225
|
+
readSetting: vi.fn(() => "true"),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await startTokenWindowKeepalive(testDeps);
|
|
229
|
+
|
|
230
|
+
expect(testDeps.findTokens).not.toHaveBeenCalled();
|
|
231
|
+
expect(testDeps.readSetting).not.toHaveBeenCalled();
|
|
232
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
214
235
|
it("실행 중 스위치를 끄고 재예약하면 기존 타이머를 정리한다", async () => {
|
|
215
236
|
const testDeps = deps();
|
|
216
237
|
await startTokenWindowKeepalive(testDeps);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
|
|
3
|
+
import { TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY } from "../setting/setting.constant";
|
|
3
4
|
import { getSetting } from "../setting/setting.store";
|
|
4
5
|
import { TokenModel } from "../token/token.model";
|
|
5
6
|
import { type InternalQueryInput } from "./qgrid.dispatcher";
|
|
@@ -17,13 +18,14 @@ export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
|
|
|
17
18
|
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
|
|
18
19
|
export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
|
|
19
20
|
export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
|
|
20
|
-
export const KEEPALIVE_ENV_KEY =
|
|
21
|
+
export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
|
|
21
22
|
|
|
22
23
|
type KeepaliveToken = {
|
|
23
24
|
id: number;
|
|
24
25
|
name: string;
|
|
25
26
|
provider: string;
|
|
26
27
|
active: boolean;
|
|
28
|
+
keepalive_enabled: boolean;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
@@ -33,6 +35,7 @@ export type TokenWindowKeepaliveDeps = {
|
|
|
33
35
|
readUsage: (tokenId: number) => Promise<UsageResponse>;
|
|
34
36
|
dispatch: (input: InternalQueryInput) => Promise<unknown>;
|
|
35
37
|
readSetting: typeof getSetting;
|
|
38
|
+
isRunnerEnabled: () => boolean;
|
|
36
39
|
now: () => number;
|
|
37
40
|
setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
|
38
41
|
clearTimer: (handle: TimerHandle) => void;
|
|
@@ -43,6 +46,7 @@ const defaultDeps: TokenWindowKeepaliveDeps = {
|
|
|
43
46
|
readUsage: (tokenId) => QgridFrame.usage(tokenId),
|
|
44
47
|
dispatch: (input) => QgridFrame.query(input),
|
|
45
48
|
readSetting: getSetting,
|
|
49
|
+
isRunnerEnabled: () => process.env[KEEPALIVE_ENV_KEY] === "true",
|
|
46
50
|
now: () => Date.now(),
|
|
47
51
|
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
48
52
|
clearTimer: (handle) => clearTimeout(handle),
|
|
@@ -191,7 +195,11 @@ export async function startTokenWindowKeepalive(
|
|
|
191
195
|
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
192
196
|
): Promise<void> {
|
|
193
197
|
clearScheduledTimers();
|
|
194
|
-
if (deps.
|
|
198
|
+
if (!deps.isRunnerEnabled()) {
|
|
199
|
+
logger.info("token window keepalive runner disabled for this instance");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (deps.readSetting(KEEPALIVE_SETTING_KEY) === "false") {
|
|
195
203
|
logger.info("token window keepalive disabled");
|
|
196
204
|
return;
|
|
197
205
|
}
|
|
@@ -206,7 +214,9 @@ export async function startTokenWindowKeepalive(
|
|
|
206
214
|
}
|
|
207
215
|
if (generation !== expectedGeneration) return;
|
|
208
216
|
|
|
209
|
-
const targets = tokens.filter(
|
|
217
|
+
const targets = tokens.filter(
|
|
218
|
+
(token) => token.active && token.provider === "anthropic" && token.keepalive_enabled,
|
|
219
|
+
);
|
|
210
220
|
const targetIds = new Set(targets.map((token) => token.id));
|
|
211
221
|
for (const tokenId of fireGuards.keys()) {
|
|
212
222
|
if (!targetIds.has(tokenId)) fireGuards.delete(tokenId);
|
|
@@ -279,6 +279,27 @@ describe("RequestLogModel run lifecycle", () => {
|
|
|
279
279
|
vi.restoreAllMocks();
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("keeps legacy external runs unstructured when metadata is omitted", async () => {
|
|
283
|
+
const ubRegister = vi.fn();
|
|
284
|
+
const transaction = vi.fn(
|
|
285
|
+
async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => Promise<number>) =>
|
|
286
|
+
cb({ ubUpsert: vi.fn(async () => [16]) }),
|
|
287
|
+
);
|
|
288
|
+
vi.spyOn(
|
|
289
|
+
RequestLogModel as unknown as {
|
|
290
|
+
getPuri: () => { ubRegister: typeof ubRegister; transaction: typeof transaction };
|
|
291
|
+
},
|
|
292
|
+
"getPuri",
|
|
293
|
+
).mockReturnValue({ ubRegister, transaction });
|
|
294
|
+
|
|
295
|
+
await RequestLogModel.createRun({ user_prompt: "plain text" });
|
|
296
|
+
|
|
297
|
+
expect(ubRegister).toHaveBeenCalledWith(
|
|
298
|
+
"request_logs",
|
|
299
|
+
expect.objectContaining({ is_structured: false, json_schema: null }),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
282
303
|
it("stores the requested model and attached tools but no serving model when a run starts", async () => {
|
|
283
304
|
const ubRegister = vi.fn();
|
|
284
305
|
const transaction = vi.fn(
|
|
@@ -296,6 +317,8 @@ describe("RequestLogModel run lifecycle", () => {
|
|
|
296
317
|
RequestLogModel.createRun({
|
|
297
318
|
user_prompt: "hi",
|
|
298
319
|
requested_model_name: "openai/gpt-5-codex",
|
|
320
|
+
is_structured: true,
|
|
321
|
+
json_schema: '{"type":"object"}',
|
|
299
322
|
tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
|
|
300
323
|
}),
|
|
301
324
|
).resolves.toBe(17);
|
|
@@ -306,6 +329,8 @@ describe("RequestLogModel run lifecycle", () => {
|
|
|
306
329
|
status: "running",
|
|
307
330
|
requested_model_name: "openai/gpt-5-codex",
|
|
308
331
|
model_name: null,
|
|
332
|
+
is_structured: true,
|
|
333
|
+
json_schema: '{"type":"object"}',
|
|
309
334
|
tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
|
|
310
335
|
}),
|
|
311
336
|
);
|
|
@@ -483,6 +483,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
483
483
|
history?: unknown;
|
|
484
484
|
tools?: ToolDefinitions;
|
|
485
485
|
is_image_generation?: boolean;
|
|
486
|
+
is_structured?: boolean;
|
|
486
487
|
json_schema?: string | null;
|
|
487
488
|
}): Promise<number> {
|
|
488
489
|
const wdb = this.getPuri("w");
|
|
@@ -505,7 +506,8 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
505
506
|
// 이미지 turn 식별(R13). run 경로(tools+image 조합)도 auto 경로와 동일하게 마킹.
|
|
506
507
|
is_image_generation: params.is_image_generation ?? false,
|
|
507
508
|
json_schema: params.json_schema ?? null,
|
|
508
|
-
is_structured:
|
|
509
|
+
is_structured:
|
|
510
|
+
params.is_structured ?? (params.json_schema !== null && params.json_schema !== undefined),
|
|
509
511
|
...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
|
|
510
512
|
...(params.tools !== undefined ? { tools: params.tools } : {}),
|
|
511
513
|
});
|
|
@@ -76,11 +76,11 @@ describe("SETTING_DEFS", () => {
|
|
|
76
76
|
it("token window keepalive 비상 스위치를 즉시 적용 설정으로 등록한다", () => {
|
|
77
77
|
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")).toMatchObject({
|
|
78
78
|
group: "qgrid",
|
|
79
|
-
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
80
79
|
kind: "boolean",
|
|
81
80
|
applies: "immediate",
|
|
82
81
|
fallback: "true",
|
|
83
82
|
});
|
|
83
|
+
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")?.envKey).toBeUndefined();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it("키가 중복되지 않는다", () => {
|
|
@@ -90,7 +90,7 @@ describe("SETTING_DEFS", () => {
|
|
|
90
90
|
|
|
91
91
|
it("env 키도 중복되지 않는다", () => {
|
|
92
92
|
// 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
|
|
93
|
-
const envKeys = SETTING_DEFS.
|
|
93
|
+
const envKeys = SETTING_DEFS.flatMap((d) => (d.envKey ? [d.envKey] : []));
|
|
94
94
|
expect(new Set(envKeys).size).toBe(envKeys.length);
|
|
95
95
|
});
|
|
96
96
|
|
|
@@ -22,7 +22,7 @@ export type SettingGroup = "qgrid" | "slack" | "slackConnection";
|
|
|
22
22
|
export type SettingDef = {
|
|
23
23
|
key: string;
|
|
24
24
|
group: SettingGroup;
|
|
25
|
-
envKey
|
|
25
|
+
envKey?: string;
|
|
26
26
|
label: string;
|
|
27
27
|
kind: SettingKind;
|
|
28
28
|
/** 적용 시점. 부팅 시 한 번만 읽히는 값은 재시작이 필요하다. */
|
|
@@ -39,16 +39,21 @@ export type SettingDef = {
|
|
|
39
39
|
help?: string;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* keepalive 스케줄러를 실제로 소유할 인스턴스만 켠다. Settings DB 값과 일부러 분리한다 —
|
|
44
|
+
* 같은 DB를 보는 개발자 로컬 인스턴스가 전역 설정을 읽고 함께 발사하면 중복 요청이 된다.
|
|
45
|
+
*/
|
|
46
|
+
export const TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
|
|
47
|
+
|
|
42
48
|
export const SETTING_DEFS: SettingDef[] = [
|
|
43
49
|
{
|
|
44
50
|
key: "qgrid.tokenWindowKeepaliveEnabled",
|
|
45
51
|
fallback: "true",
|
|
46
52
|
group: "qgrid",
|
|
47
|
-
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
48
53
|
label: "토큰 윈도우 keepalive",
|
|
49
54
|
kind: "boolean",
|
|
50
55
|
applies: "immediate",
|
|
51
|
-
help: "끄면
|
|
56
|
+
help: "끄면 이 서버에서 선택한 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
|
|
52
57
|
},
|
|
53
58
|
{
|
|
54
59
|
key: "slack.enabled",
|
|
@@ -16,7 +16,13 @@ import { detectSupervisor } from "../../utils/process-supervisor";
|
|
|
16
16
|
import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
|
|
17
17
|
import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
|
|
18
18
|
import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
findSettingDef,
|
|
21
|
+
maskSecret,
|
|
22
|
+
SETTING_DEFS,
|
|
23
|
+
TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY,
|
|
24
|
+
validateSettingValue,
|
|
25
|
+
} from "./setting.constant";
|
|
20
26
|
import {
|
|
21
27
|
getSetting,
|
|
22
28
|
isStored,
|
|
@@ -252,7 +258,12 @@ class SettingModelClass extends BaseModelClass<
|
|
|
252
258
|
};
|
|
253
259
|
});
|
|
254
260
|
|
|
255
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
settings,
|
|
263
|
+
runtime: this.runtimeInfo(),
|
|
264
|
+
supervisor: detectSupervisor(),
|
|
265
|
+
keepaliveRunnerEnabled: process.env[TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY] === "true",
|
|
266
|
+
};
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
private runtimeInfo(): RuntimeInfoItem[] {
|
|
@@ -54,8 +54,8 @@ export async function loadSettings(persistence: SettingPersistence = SettingMode
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** DB → env 순. 둘 다 없으면 undefined 라 호출부의 기본값으로 떨어진다. */
|
|
57
|
-
export function getSetting(key: string, envKey
|
|
58
|
-
return cache?.get(key) ?? process.env[envKey];
|
|
57
|
+
export function getSetting(key: string, envKey?: string): string | undefined {
|
|
58
|
+
return cache?.get(key) ?? (envKey ? process.env[envKey] : undefined);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** 저장된 값인지 — 화면에서 출처를 구분해 보여준다. */
|
|
@@ -61,5 +61,7 @@ export const SettingsResponse = z.object({
|
|
|
61
61
|
runtime: RuntimeInfoItem.array(),
|
|
62
62
|
/** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
|
|
63
63
|
supervisor: SupervisorKind.nullable(),
|
|
64
|
+
/** 이 인스턴스가 keepalive 스케줄러를 소유하도록 명시적으로 허용됐는지. */
|
|
65
|
+
keepaliveRunnerEnabled: z.boolean(),
|
|
64
66
|
});
|
|
65
67
|
export type SettingsResponse = z.infer<typeof SettingsResponse>;
|