@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.
Files changed (86) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +67 -30
  2. package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  4. package/bundle/dist/application/qgrid/token-window-keepalive.js +10 -4
  5. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  6. package/bundle/dist/application/setting/setting.constant.js +8 -4
  7. package/bundle/dist/application/setting/setting.model.js +4 -3
  8. package/bundle/dist/application/setting/setting.store.js +2 -2
  9. package/bundle/dist/application/setting/setting.types.js +4 -2
  10. package/bundle/dist/application/sonamu.generated.js +5 -3
  11. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  12. package/bundle/dist/application/token/token.model.js +7 -2
  13. package/bundle/dist/application/token/token.types.js +3 -2
  14. package/bundle/dist/i18n/sd.generated.js +2 -1
  15. package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
  16. package/bundle/dist/sonamu.config.js +3 -1
  17. package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
  18. package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
  19. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
  20. package/bundle/src/application/qgrid/qgrid.frame.test.ts +52 -0
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +74 -26
  22. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  23. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  24. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  25. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +23 -2
  26. package/bundle/src/application/qgrid/token-window-keepalive.ts +13 -3
  27. package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
  28. package/bundle/src/application/request-log/request-log.model.ts +3 -1
  29. package/bundle/src/application/setting/setting.constant.test.ts +2 -2
  30. package/bundle/src/application/setting/setting.constant.ts +8 -3
  31. package/bundle/src/application/setting/setting.model.ts +13 -2
  32. package/bundle/src/application/setting/setting.store.ts +2 -2
  33. package/bundle/src/application/setting/setting.types.ts +2 -0
  34. package/bundle/src/application/sonamu.generated.http +9 -4
  35. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  36. package/bundle/src/application/sonamu.generated.ts +3 -0
  37. package/bundle/src/application/token/token.entity.json +8 -1
  38. package/bundle/src/application/token/token.model.test.ts +29 -0
  39. package/bundle/src/application/token/token.model.ts +10 -1
  40. package/bundle/src/application/token/token.types.test.ts +10 -0
  41. package/bundle/src/application/token/token.types.ts +1 -0
  42. package/bundle/src/i18n/sd.generated.ts +1 -0
  43. package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -0
  44. package/bundle/src/sonamu.config.ts +2 -0
  45. package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
  46. package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
  47. package/bundle/src/utils/providers/openai/openai-quota.test.ts +31 -0
  48. package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
  49. package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
  50. package/bundle/web-dist/client/assets/{index-BbOyk01G.js → index-D59Q_9Z4.js} +4 -4
  51. package/bundle/web-dist/client/assets/{logs-B7Pc88bf.js → logs-BHVfI8ut.js} +1 -1
  52. package/bundle/web-dist/client/assets/{monit-CzMV4lZf.js → monit-EkLxH1oN.js} +1 -1
  53. package/bundle/web-dist/client/assets/{routes-COWTRxZo.js → routes-Kbvuclip.js} +2 -2
  54. package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
  55. package/bundle/web-dist/client/assets/settings-B4uTFP3J.js +1 -0
  56. package/bundle/web-dist/client/assets/{show-YvdybkKd.js → show-BjIdSPTd.js} +1 -1
  57. package/bundle/web-dist/client/assets/tokens-BbCSxbFu.js +1 -0
  58. package/bundle/web-dist/client/assets/use-oauth-login-flow-4OltfVAd.js +1 -0
  59. package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
  60. package/bundle/web-dist/client/index.html +3 -3
  61. package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
  62. package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
  63. package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
  64. package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
  65. package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
  66. package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-Ct3I9263.js} +7 -5
  67. package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
  68. package/bundle/web-dist/server/assets/{settings-CIYR7h3G.js → settings-JznSjwGQ.js} +79 -5
  69. package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
  70. package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-PR42jF1J.js} +6 -4
  71. package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-C-33Z5HA.js} +18 -17
  72. package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
  73. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  74. package/dist/cli.js +10 -11
  75. package/package.json +1 -1
  76. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -0
  77. package/skills/qgrid/references/cli-env-and-server-boot.md +2 -1
  78. package/skills/qgrid/references/openai-codex-runtime.md +12 -5
  79. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  80. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  81. package/skills/qgrid/references/token-auth-quota-lifecycle.md +27 -1
  82. package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
  83. package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +0 -1
  84. package/bundle/web-dist/client/assets/tokens-DynVDx0c.js +0 -1
  85. package/bundle/web-dist/client/assets/use-oauth-login-flow-OzmHElsE.js +0 -1
  86. /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
- // Anthropic's remote/manual flow uses its fixed console callback. Headers may select between two
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
- if (!new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(host)) {
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
- // 코드 붙여넣기 플로우(원격 접속): 콘솔 콜백이 표시한 `code#state` 를 받아 교환한다.
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 trimmed = pastedCode.trim();
579
- const separatorAt = trimmed.indexOf("#");
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 !== "anthropic") {
597
- throw new BadRequestException("login session is not for Anthropic" as LocalizedString);
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
- const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
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 redirectUri = `${trustedLoopbackBase()}/auth/callback`;
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: "redirect" };
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 경로는 고정 loopback 콜백의 직접 PKCE 라 항상 redirect 를 반환한다.
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<{ name: string; provider: string; active: boolean }> = {},
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("비활성 토큰과 Anthropic 이 아닌 토큰은 대상에서 제외한다", async () => {
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 = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
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.readSetting(KEEPALIVE_SETTING_KEY, KEEPALIVE_ENV_KEY) === "false") {
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((token) => token.active && token.provider === "anthropic");
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: params.json_schema !== null && params.json_schema !== undefined,
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.map((d) => d.envKey);
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: string;
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: "끄면 활성 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
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 { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.constant";
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 { settings, runtime: this.runtimeInfo(), supervisor: detectSupervisor() };
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: string): string | undefined {
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>;