@cartanova/qgrid-cli 2.10.2 → 2.10.4

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 (118) hide show
  1. package/bundle/dist/application/monit/log-buffer.js +7 -11
  2. package/bundle/dist/application/monit/monit.frame.js +6 -11
  3. package/bundle/dist/application/qgrid/oauth.js +2 -2
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +64 -141
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -6
  7. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  8. package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
  9. package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
  10. package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
  11. package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
  12. package/bundle/dist/application/request-log/request-log.model.js +7 -5
  13. package/bundle/dist/application/setting/setting.model.js +2 -64
  14. package/bundle/dist/application/setting/setting.store.js +3 -4
  15. package/bundle/dist/application/setting/setting.types.js +2 -12
  16. package/bundle/dist/i18n/en.js +1 -3
  17. package/bundle/dist/i18n/ko.js +1 -3
  18. package/bundle/dist/sonamu.config.js +6 -6
  19. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  20. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  21. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  22. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  23. package/bundle/dist/utils/providers/common/effort.js +1 -1
  24. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  25. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  26. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  27. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  28. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  29. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  30. package/bundle/src/application/monit/log-buffer.ts +13 -27
  31. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  32. package/bundle/src/application/monit/monit.frame.ts +9 -14
  33. package/bundle/src/application/qgrid/oauth.ts +1 -1
  34. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -111
  35. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +83 -179
  36. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  37. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  38. package/bundle/src/application/qgrid/qgrid.frame.test.ts +0 -38
  39. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -12
  40. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  41. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  42. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  43. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  44. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  45. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  46. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  47. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  48. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  49. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  50. package/bundle/src/application/setting/setting.model.test.ts +1 -101
  51. package/bundle/src/application/setting/setting.model.ts +1 -74
  52. package/bundle/src/application/setting/setting.store.ts +2 -3
  53. package/bundle/src/application/setting/setting.types.ts +0 -12
  54. package/bundle/src/application/sonamu.generated.http +2 -7
  55. package/bundle/src/i18n/en.ts +0 -3
  56. package/bundle/src/i18n/ko.ts +0 -3
  57. package/bundle/src/sonamu.config.ts +5 -5
  58. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  59. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  60. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  61. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  62. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  63. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  64. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  65. package/bundle/src/utils/providers/common/effort.ts +1 -1
  66. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  67. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  68. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  69. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  70. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  71. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  72. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  73. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  74. package/bundle/web-dist/client/assets/index-CvG4UB9v.css +1 -0
  75. package/bundle/web-dist/client/assets/{index-DtRTSTes.js → index-reW8VvJF.js} +4 -4
  76. package/bundle/web-dist/client/assets/{logs-BhVYDEzC.js → logs-DDIt97D1.js} +1 -1
  77. package/bundle/web-dist/client/assets/{monit-BxN4JWqK.js → monit-DkABv7PG.js} +1 -1
  78. package/bundle/web-dist/client/assets/{routes-CBCqRp2o.js → routes-BRmQ4M3-.js} +1 -1
  79. package/bundle/web-dist/client/assets/services.generated-jnPHYMER.js +51 -0
  80. package/bundle/web-dist/client/assets/settings-BHvtycEZ.js +1 -0
  81. package/bundle/web-dist/client/assets/{show-BzYft5N4.js → show-qeTIzgDZ.js} +1 -1
  82. package/bundle/web-dist/client/assets/{tokens-PMybtI8X.js → tokens-5Bep_G-w.js} +1 -1
  83. package/bundle/web-dist/client/assets/{use-oauth-login-flow-C51_d6-6.js → use-oauth-login-flow-Cx-QO25M.js} +1 -1
  84. package/bundle/web-dist/client/assets/{use-update-token-mutation-D-Y8OySK.js → use-update-token-mutation-DQCwCYfR.js} +1 -1
  85. package/bundle/web-dist/client/index.html +3 -3
  86. package/bundle/web-dist/server/assets/{lazyRouteComponent-Bj1cPq6x.js → lazyRouteComponent-CUR-kWzI.js} +2 -2
  87. package/bundle/web-dist/server/assets/{logs-BSk9lOHe.js → logs-CagNGTBj.js} +3 -3
  88. package/bundle/web-dist/server/assets/{monit-D9W4ioUi.js → monit-DSxs4Jnl.js} +3 -3
  89. package/bundle/web-dist/server/assets/{plus-CmvbWuDf.js → plus-Bvdj8Pwx.js} +1 -1
  90. package/bundle/web-dist/server/assets/{react-dom-BjR1Iaz8.js → react-dom-CyuShs9K.js} +1 -1
  91. package/bundle/web-dist/server/assets/{refresh-cw-B8U9BL9-.js → refresh-cw-3QAiSWBd.js} +1 -1
  92. package/bundle/web-dist/server/assets/{routes-C7lB2Q85.js → routes-i-sTqXMX.js} +4 -4
  93. package/bundle/web-dist/server/assets/{services.generated-BM4X3Ecj.js → services.generated-FPwsG9p3.js} +116 -131
  94. package/bundle/web-dist/server/assets/{settings-BFVuldch.js → settings-DeHNwoMv.js} +40 -251
  95. package/bundle/web-dist/server/assets/{show-Cswh_tqs.js → show-C4jk1Hrp.js} +2 -2
  96. package/bundle/web-dist/server/assets/{tokens-DiTUwUwr.js → tokens-odgS2-9q.js} +4 -4
  97. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DYKnG4A2.js → use-oauth-login-flow-DTQKdYBO.js} +1 -1
  98. package/bundle/web-dist/server/assets/{use-update-token-mutation-DqGMopII.js → use-update-token-mutation-xvXbPrCI.js} +1 -1
  99. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  100. package/package.json +1 -1
  101. package/skills/qgrid/SKILL.md +1 -0
  102. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  103. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  104. package/skills/qgrid/references/cli-env-and-server-boot.md +12 -9
  105. package/skills/qgrid/references/decision-rationale.md +9 -0
  106. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  107. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  108. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  109. package/skills/qgrid/references/verification-and-debugging.md +1 -1
  110. package/bundle/dist/utils/process-supervisor.js +0 -22
  111. package/bundle/dist/utils/server-restart.js +0 -61
  112. package/bundle/src/utils/process-supervisor.test.ts +0 -24
  113. package/bundle/src/utils/process-supervisor.ts +0 -17
  114. package/bundle/src/utils/server-restart.test.ts +0 -76
  115. package/bundle/src/utils/server-restart.ts +0 -68
  116. package/bundle/web-dist/client/assets/index-D_sagzsR.css +0 -1
  117. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +0 -51
  118. package/bundle/web-dist/client/assets/settings-hBTEC7Ki.js +0 -1
@@ -25,19 +25,15 @@ export class TokenSubscriber {
25
25
  attempt = 0;
26
26
  connectedAt: Date | null = null;
27
27
  lastReconcileAt: Date | null = null;
28
- private operationChain: Promise<void> = Promise.resolve();
29
- private tokenChangeHandler: (() => void) | null = null;
28
+ operationChain: Promise<void> = Promise.resolve();
29
+ tokenChangeHandler: (() => void) | null = null;
30
30
 
31
31
  constructor(
32
32
  public connConfig: ClientConfig,
33
33
  public dispatcher: QgridDispatcherClass,
34
34
  ) {}
35
35
 
36
- setTokenChangeHandler(handler: (() => void) | null): void {
37
- this.tokenChangeHandler = handler;
38
- }
39
-
40
- private notifyTokensChanged(): void {
36
+ notifyTokensChanged(): void {
41
37
  try {
42
38
  this.tokenChangeHandler?.();
43
39
  } catch (error) {
@@ -168,18 +164,18 @@ export class TokenSubscriber {
168
164
  return this.enqueueOperation(() => this.handleNotificationNow(payloadJson));
169
165
  }
170
166
 
171
- private async handleNotificationNow(payloadJson: string): Promise<void> {
167
+ async handleNotificationNow(payloadJson: string): Promise<void> {
172
168
  const payload = JSON.parse(payloadJson) as Payload;
173
169
  const previousRow = this.tokenChangeHandler
174
170
  ? this.dispatcher.tokens.get(payload.id)
175
171
  : undefined;
176
172
  const wasKeepaliveTarget = previousRow?.active === true && previousRow.provider === "anthropic";
177
173
  if (payload.op === "DELETE") {
178
- this.dispatcher.removeCache(payload.id);
174
+ this.dispatcher.tokens.delete(payload.id);
179
175
  await this.dispatcher.openaiDispatcher
180
176
  ?.onTokenRemoved(payload.id)
181
177
  .catch((e) => logger.warn(`openai token remove failed: ${(e as Error).message}`));
182
- // anthropic 토큰 이벤트는 동기(void) — provider 를 모르므로 무해하게 항상 제거 시도.
178
+ // 삭제된 토큰의 provider를 모를 수 있으므로 양쪽 풀에서 제거한다.
183
179
  this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
184
180
  logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
185
181
  if (previousRow === undefined || wasKeepaliveTarget) this.notifyTokensChanged();
@@ -187,7 +183,7 @@ export class TokenSubscriber {
187
183
  }
188
184
  const row = await TokenModel.findOne("A", { id: payload.id });
189
185
  if (!row) {
190
- this.dispatcher.removeCache(payload.id);
186
+ this.dispatcher.tokens.delete(payload.id);
191
187
  await this.dispatcher.openaiDispatcher
192
188
  ?.onTokenRemoved(payload.id)
193
189
  .catch((e) => logger.warn(`openai token remove failed: ${(e as Error).message}`));
@@ -197,7 +193,7 @@ export class TokenSubscriber {
197
193
  return;
198
194
  }
199
195
 
200
- this.dispatcher.upsertCache(payload.id, row);
196
+ this.dispatcher.tokens.set(payload.id, row);
201
197
 
202
198
  if (row.provider === "openai") {
203
199
  const creds = row.credentials as Record<string, unknown>;
@@ -229,9 +225,6 @@ export class TokenSubscriber {
229
225
  openaiDispatcher?.onTokenDeactivated(payload.id);
230
226
  }
231
227
  } else if (row.provider === "anthropic") {
232
- // anthropic 토큰 이벤트는 동기(void). worker 가 없고 풀(Map)만 관리하므로 단순:
233
- // INSERT → 추가, active 면 갱신(onTokenUpdated 가 identity 변경도 처리), inactive 면 풀에서 제거.
234
- // (별도 activate/deactivate 콜백이 없어 active 토글을 add/remove 로 매핑.)
235
228
  const creds = row.credentials as AnthropicCredentials;
236
229
  if (payload.op === "INSERT" && row.active) {
237
230
  this.dispatcher.anthropicDispatcher?.onTokenAdded(
@@ -250,7 +243,6 @@ export class TokenSubscriber {
250
243
  row.weight,
251
244
  );
252
245
  } else {
253
- // inactive 면 INSERT 든 UPDATE 든 풀에 넣지 않는다.
254
246
  this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
255
247
  }
256
248
  }
@@ -263,7 +255,7 @@ export class TokenSubscriber {
263
255
  return this.enqueueOperation(() => this.reconcileNow());
264
256
  }
265
257
 
266
- private async reconcileNow(): Promise<void> {
258
+ async reconcileNow(): Promise<void> {
267
259
  const previousAnthropicIds = this.tokenChangeHandler
268
260
  ? new Set(
269
261
  [...this.dispatcher.tokens.values()]
@@ -274,8 +266,6 @@ export class TokenSubscriber {
274
266
  await TokenModel.deactivateExpiredTokens();
275
267
  const rows = await TokenModel.findActive("A");
276
268
  this.dispatcher.replaceCache(rows);
277
- // NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
278
- // (rows 는 active 만 — inactive/삭제된 토큰은 여기 없으므로 replaceTokens 가 풀에서 제거한다.)
279
269
  const anthropicRows = rows
280
270
  .filter((r) => r.provider === "anthropic")
281
271
  .map((r) => ({
@@ -308,7 +298,7 @@ export class TokenSubscriber {
308
298
  }
309
299
  }
310
300
 
311
- private enqueueOperation(operation: () => Promise<void>): Promise<void> {
301
+ enqueueOperation(operation: () => Promise<void>): Promise<void> {
312
302
  const next = this.operationChain.then(operation, operation);
313
303
  this.operationChain = next.catch(() => {});
314
304
  return next;
@@ -1,7 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import {
4
- KEEPALIVE_MODEL,
5
4
  KEEPALIVE_PROJECT_NAME,
6
5
  POST_KEEPALIVE_USAGE_DELAY_MS,
7
6
  rescheduleTokenWindowKeepalive,
@@ -76,7 +75,8 @@ describe("token window keepalive", () => {
76
75
 
77
76
  expect(dispatch).toHaveBeenCalledWith({
78
77
  prompt: "Reply OK.",
79
- model: KEEPALIVE_MODEL,
78
+ model: "anthropic/claude-haiku-5-5",
79
+ thinking: false,
80
80
  effort: "low",
81
81
  projectName: KEEPALIVE_PROJECT_NAME,
82
82
  preferredTokenId: 1,
@@ -15,7 +15,7 @@ const RESET_GRACE_MS = 1_000;
15
15
  // usage API 성공 응답은 1분 캐시된다. keepalive 직후 즉시 재조회하면 이전 윈도우를
16
16
  // 다시 보므로 캐시가 지난 뒤 한 번 확인하고, 그때도 새 윈도우가 없으면 다음 5h까지 기다린다.
17
17
  export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
18
- export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
18
+ export const KEEPALIVE_MODEL = "anthropic/claude-haiku-5-5";
19
19
  export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
20
20
  export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
21
21
  export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
@@ -163,6 +163,7 @@ async function inspectToken(
163
163
  await deps.dispatch({
164
164
  prompt: "Reply OK.",
165
165
  model: KEEPALIVE_MODEL,
166
+ thinking: false,
166
167
  effort: "low",
167
168
  projectName: KEEPALIVE_PROJECT_NAME,
168
169
  preferredTokenId: token.id,
@@ -23,16 +23,16 @@ describe("buildToolCallSchema", () => {
23
23
  },
24
24
  ];
25
25
 
26
- it("documents that emulated tools must be requested through StructuredOutput", () => {
26
+ it("requests tools in the response body and ends the turn until the client returns results", () => {
27
27
  const schema = buildToolCallSchema(tools);
28
28
 
29
29
  expect(schema).toHaveProperty(
30
30
  "description",
31
- expect.stringContaining("Do not invoke listed client tools as native Claude Code tools"),
31
+ expect.stringContaining("then end your turn"),
32
32
  );
33
33
  expect(schema).toHaveProperty(
34
34
  ["properties", "result", "anyOf", 1, "description"],
35
- expect.stringContaining("Do not call these tools as native"),
35
+ expect.stringContaining("wait for the next request containing tool results"),
36
36
  );
37
37
  expect(schema).toHaveProperty(
38
38
  [
@@ -49,6 +49,9 @@ describe("buildToolCallSchema", () => {
49
49
  ],
50
50
  expect.stringContaining("getWeather"),
51
51
  );
52
+ expect(JSON.stringify(schema)).not.toContain("StructuredOutput");
53
+ expect(JSON.stringify(schema)).not.toContain("Claude Code");
54
+ expect(schema).toHaveProperty("description", expect.stringContaining("Do not invent tool results"));
52
55
  });
53
56
 
54
57
  it("makes degenerate combinations grammatically impossible", () => {
@@ -54,7 +54,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
54
54
  return {
55
55
  type: "object",
56
56
  description:
57
- "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set result.action to tool_call and include result.toolCalls. Use action answer only when no further client tool result is needed.",
57
+ "Return exactly one JSON object matching this schema as your final response, including when requesting a tool. This object is the response body, not a call to a built-in tool. To request client-side tools, set result.action to tool_call and include result.toolCalls, then end your turn. The client executes those tools and supplies their results in a later request. Do not invent tool results or emit an answer before receiving the required results.",
58
58
  properties: {
59
59
  result: {
60
60
  anyOf: [
@@ -75,7 +75,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
75
75
  {
76
76
  type: "object",
77
77
  description:
78
- "Request client-side tool execution through this structured output. Do not call these tools as native Claude Code tools.",
78
+ "Request client-side tool execution. Return this object as the complete response, then wait for the next request containing tool results.",
79
79
  properties: {
80
80
  action: { type: "string", enum: ["tool_call"] },
81
81
  answer: { type: "null" },
@@ -88,7 +88,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
88
88
  toolName: {
89
89
  type: "string",
90
90
  enum: tools.map((tool) => tool.name),
91
- description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
91
+ description: `Client-side tool name. The caller executes these tools after receiving your JSON response.\n${toolDescriptions}`,
92
92
  },
93
93
  args: {
94
94
  type: "string",
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { CALLER_SCHEMA_LIMITS } from "../../utils/providers/common/schema-validation";
4
4
  import { type QgridTool } from "./qgrid.types";
5
- import { applyToolCallEmulation, ToolCallEmulationError } from "./tool-emulation";
5
+ import { applyToolCallEmulation, isToolCallEnvelope, ToolCallEmulationError } from "./tool-emulation";
6
6
 
7
7
  const baseResult = {
8
8
  text: "",
@@ -84,6 +84,13 @@ describe("applyToolCallEmulation image parts", () => {
84
84
  });
85
85
 
86
86
  describe("applyToolCallEmulation envelope validation", () => {
87
+ it("ignores intermediate prose but never repairs malformed final JSON or accepts an unknown control tool", () => {
88
+ const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "notAllowed", args: "{}"}]}});
89
+ const allowed = [{name: "lookup", inputSchema: {type: "object"}}];
90
+ expect(isToolCallEnvelope("Checking the source.", allowed, "json", "commentary")).toBe(false);
91
+ expect(() => isToolCallEnvelope(tool, allowed, "json", "commentary")).toThrow("unknown emulated tool");
92
+ expect(() => isToolCallEnvelope(tool + tool, allowed, "json", "final_answer")).toThrow("response is not JSON");
93
+ });
87
94
  const tools: QgridTool[] = [
88
95
  {
89
96
  name: "lookup",
@@ -111,6 +111,35 @@ export function applyToolCallEmulation(
111
111
  );
112
112
  }
113
113
 
114
+ export function isToolCallEnvelope(
115
+ text: string,
116
+ tools: QgridTool[],
117
+ answerKind: AnswerKind,
118
+ phase?: string,
119
+ ): boolean {
120
+ if (phase === "commentary") {
121
+ // Commentary is not the structured answer. Only an explicit tool control
122
+ // object is eligible, and that object must still pass the strict parser.
123
+ let value: unknown;
124
+ try {
125
+ value = JSON.parse(text);
126
+ } catch {
127
+ return false;
128
+ }
129
+ if (
130
+ !value ||
131
+ typeof value !== "object" ||
132
+ !("result" in value) ||
133
+ !value.result ||
134
+ typeof value.result !== "object" ||
135
+ !("action" in value.result) ||
136
+ value.result.action !== "tool_call"
137
+ )
138
+ return false;
139
+ }
140
+ return parseEnvelope(text, tools, answerKind).action === "tool_call";
141
+ }
142
+
114
143
  function parseEnvelope(text: string, tools: QgridTool[], answerKind: AnswerKind) {
115
144
  if (Buffer.byteLength(text, "utf8") > STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes) {
116
145
  throw new ToolCallEmulationError(
@@ -257,7 +257,7 @@ class RequestLogModelClass extends BaseModelClass<
257
257
  * 화면에서 필터를 걸면 행 수와 비용이 함께 좁혀져야 하는데, 두 쿼리가 각자 필터를
258
258
  * 구현하면 한쪽에 축을 추가할 때 조용히 어긋난다.
259
259
  */
260
- private applyListFilters(
260
+ applyListFilters(
261
261
  qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
262
262
  params: RequestLogListParams,
263
263
  ): void {
@@ -378,7 +378,7 @@ class RequestLogModelClass extends BaseModelClass<
378
378
  string,
379
379
  { requests: number; errors: number; inputTokens: number; cacheReadTokens: number }
380
380
  >();
381
- for (const row of rows) {
381
+ rows.forEach((row) => {
382
382
  const model = row.requested_model_name ?? "";
383
383
  const provider = model.startsWith("openai/")
384
384
  ? "openai"
@@ -396,7 +396,7 @@ class RequestLogModelClass extends BaseModelClass<
396
396
  agg.inputTokens += row.input_tokens;
397
397
  agg.cacheReadTokens += row.cache_read_tokens;
398
398
  byProvider.set(provider, agg);
399
- }
399
+ });
400
400
  return [...byProvider.entries()]
401
401
  .map(([provider, agg]) => ({ provider, ...agg }))
402
402
  .toSorted((a, b) => a.provider.localeCompare(b.provider));
@@ -584,9 +584,9 @@ class RequestLogModelClass extends BaseModelClass<
584
584
  "is_image_generation",
585
585
  "response_json_ok",
586
586
  ] as const;
587
- for (const key of fields) {
587
+ fields.forEach((key) => {
588
588
  if (params[key] !== undefined) update[key] = params[key];
589
- }
589
+ });
590
590
  if (params.history !== undefined) update.history = params.history;
591
591
  update.ttft_ms = (await this.firstGenerateStepTtft(requestLogId)) ?? 0;
592
592
 
@@ -1,10 +1,6 @@
1
- import { EventEmitter } from "node:events";
2
1
 
3
- import { Sonamu } from "sonamu";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
3
 
6
- import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
7
- import { resetServerRestartForTests } from "../../utils/server-restart";
8
4
  import { SettingModel } from "./setting.model";
9
5
  import * as reminders from "../qgrid/expired-token-reminder";
10
6
  import { SlackNotificationError } from "../../utils/slack-notify";
@@ -26,99 +22,3 @@ describe("SettingModel.triggerExpiryReminder", () => {
26
22
  });
27
23
  });
28
24
  });
29
-
30
- function installContext(headers: Record<string, string>) {
31
- const raw = Object.assign(new EventEmitter(), {
32
- destroyed: false,
33
- writableEnded: false,
34
- });
35
- vi.spyOn(Sonamu, "getContext").mockReturnValue({
36
- transport: "http",
37
- headers,
38
- request: { protocol: "http" },
39
- reply: { raw },
40
- } as never);
41
- return raw;
42
- }
43
-
44
- describe("SettingModel.restartServer", () => {
45
- beforeEach(() => {
46
- vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
47
- });
48
-
49
- afterEach(() => {
50
- resetServerRestartForTests();
51
- resetNativeRunRegistryForTests();
52
- vi.unstubAllEnvs();
53
- vi.restoreAllMocks();
54
- });
55
-
56
- it("rejects restart when pm2 will not respawn the process", async () => {
57
- const raw = installContext({ host: "localhost:44900" });
58
- vi.stubEnv("pm_id", undefined);
59
-
60
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 400 });
61
- expect(raw.listenerCount("finish")).toBe(0);
62
- expect(process.exit).not.toHaveBeenCalled();
63
- });
64
-
65
- it("rejects a browser request whose Origin host does not match Host", async () => {
66
- installContext({ origin: "https://evil.example", host: "qgrid.example.com" });
67
- vi.stubEnv("pm_id", "0");
68
-
69
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
70
- expect(process.exit).not.toHaveBeenCalled();
71
- });
72
-
73
- it("rejects a malformed present Origin instead of treating it as an operational client", async () => {
74
- installContext({ origin: "null", host: "qgrid.example.com" });
75
- vi.stubEnv("pm_id", "0");
76
-
77
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
78
- expect(process.exit).not.toHaveBeenCalled();
79
- });
80
-
81
- it("accepts a matching Origin using the Caddy-forwarded host", async () => {
82
- const raw = installContext({
83
- origin: "https://qgrid.example.com",
84
- host: "127.0.0.1:44900",
85
- "x-forwarded-host": "qgrid.example.com",
86
- });
87
- vi.stubEnv("pm_id", "0");
88
-
89
- await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
90
- expect(process.exit).not.toHaveBeenCalled();
91
-
92
- raw.emit("finish");
93
- expect(process.exit).toHaveBeenCalledOnce();
94
- expect(process.exit).toHaveBeenCalledWith(0);
95
- });
96
-
97
- it("accepts an Origin-less operational request", async () => {
98
- const raw = installContext({ host: "qgrid.example.com" });
99
- vi.stubEnv("pm_id", "0");
100
-
101
- await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
102
- expect(process.exit).not.toHaveBeenCalled();
103
-
104
- raw.emit("finish");
105
- expect(process.exit).toHaveBeenCalledWith(0);
106
- });
107
-
108
- it("coordinates simultaneous calls through one process exit", async () => {
109
- const firstRaw = installContext({ host: "qgrid.example.com" });
110
- vi.stubEnv("pm_id", "0");
111
-
112
- const first = SettingModel.restartServer();
113
- const second = SettingModel.restartServer();
114
- await expect(Promise.all([first, second])).resolves.toEqual([
115
- { supervisor: "pm2" },
116
- { supervisor: "pm2" },
117
- ]);
118
-
119
- firstRaw.emit("finish");
120
- firstRaw.emit("close");
121
- expect(process.exit).toHaveBeenCalledOnce();
122
- expect(process.exit).toHaveBeenCalledWith(0);
123
- });
124
- });
@@ -1,19 +1,14 @@
1
- import { getLogger } from "@logtape/logtape";
2
1
  import {
3
2
  BaseModelClass,
4
3
  type ListResult,
5
4
  asArray,
6
5
  NotFoundException,
7
6
  BadRequestException,
8
- SoException,
9
- Sonamu,
10
7
  api,
11
8
  exhaustive,
12
9
  } from "sonamu";
13
10
 
14
11
  import { SD, type LocalizedString } from "../../i18n/sd.generated";
15
- import { detectSupervisor } from "../../utils/process-supervisor";
16
- import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
17
12
  import { SlackNotificationError } from "../../utils/slack-notify";
18
13
  import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
19
14
  import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
@@ -37,37 +32,8 @@ import {
37
32
  type SettingListParams,
38
33
  type SettingSaveParams,
39
34
  type SettingsResponse,
40
- type SupervisorKind,
41
35
  } from "./setting.types";
42
36
 
43
- const logger = getLogger(["qgrid", "setting"]);
44
-
45
- class ForbiddenException extends SoException {
46
- constructor() {
47
- super(403, SD("error.forbidden"));
48
- }
49
- }
50
-
51
- function firstHeaderValue(value: string | string[] | undefined): string | undefined {
52
- return (Array.isArray(value) ? value[0] : value)?.split(",")[0]?.trim();
53
- }
54
-
55
- /** Origin 없는 운영 CLI 요청은 통과시키되, 브라우저 교차 출처 POST 는 막는다. */
56
- function assertRestartOrigin(): void {
57
- const { headers } = Sonamu.getContext();
58
- const origin = firstHeaderValue(headers.origin);
59
- if (headers.origin === undefined) return;
60
- if (!origin) throw new ForbiddenException();
61
-
62
- const host = firstHeaderValue(headers["x-forwarded-host"]) ?? firstHeaderValue(headers.host);
63
- try {
64
- if (!host || new URL(origin).host !== host) throw new ForbiddenException();
65
- } catch (error) {
66
- if (error instanceof ForbiddenException) throw error;
67
- throw new ForbiddenException();
68
- }
69
- }
70
-
71
37
  /*
72
38
  Setting Model
73
39
  */
@@ -246,12 +212,11 @@ class SettingModelClass extends BaseModelClass<
246
212
  return {
247
213
  settings,
248
214
  runtime: this.runtimeInfo(),
249
- supervisor: detectSupervisor(),
250
215
  keepaliveRunnerEnabled: process.env[TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY] === "true",
251
216
  };
252
217
  }
253
218
 
254
- private runtimeInfo(): RuntimeInfoItem[] {
219
+ runtimeInfo(): RuntimeInfoItem[] {
255
220
  const env = process.env;
256
221
  const password = env.QGRID_DB_PASSWORD ?? env.SONAMU_DB_PASSWORD ?? "";
257
222
  return [
@@ -309,44 +274,6 @@ class SettingModelClass extends BaseModelClass<
309
274
  throw error;
310
275
  }
311
276
  }
312
-
313
- /**
314
- * 서버를 재시작한다 — 정확히는 스스로 종료하고, pm2 가 다시 띄우는 것에 맡긴다.
315
- *
316
- * `restart` 로 표시된 워커 설정은 dispatcher 생성자에서 한 번만 읽히므로 이 경로 말고는
317
- * 반영할 방법이 없었다. 지금까지는 SSH 로 `pm2 restart` 를 쳐야 했다.
318
- *
319
- * 부수 효과가 하나 더 있다: pm2 는 `ecosystem.config.cjs` 의 원래 명령으로 다시 띄우고,
320
- * 그 명령에 `--skip-update` 가 없으면 CLI 가 npm 최신 버전을 받아 올린다. 즉 재시작은
321
- * 배포이기도 하다 — 화면 문구가 이 사실을 함께 알린다.
322
- */
323
- @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
324
- async restartServer(): Promise<{ supervisor: SupervisorKind }> {
325
- assertRestartOrigin();
326
- const supervisor = detectSupervisor();
327
- if (!supervisor) {
328
- // 다시 띄워줄 주체가 없으면 종료가 곧 정지다. 살릴 수 없는데 죽이지 않는다.
329
- throw new BadRequestException(SD("setting.noSupervisor")());
330
- }
331
-
332
- const { reply } = Sonamu.getContext();
333
- const started = beginServerRestart();
334
- if (started) {
335
- logger.warn(`restart requested via dashboard, exiting for ${supervisor} to respawn`);
336
-
337
- try {
338
- // 정적 import로 qgrid.frame/token 그래프를 당겨오면 모듈 목 테스트가 오염된다.
339
- // 재시작이 실제로 확정된 시점에 active native run 정합성 처리만 로드한다.
340
- const { finishActiveNativeRunsForRestart } = await import("../qgrid/qgrid-run-lifecycle");
341
- await finishActiveNativeRunsForRestart();
342
- } finally {
343
- // 정합성 처리가 실패해도 응답이 flush 된 뒤에는 재시작을 끝까지 진행한다.
344
- armServerRestartExit(reply.raw);
345
- }
346
- }
347
-
348
- return { supervisor };
349
- }
350
277
  }
351
278
 
352
279
  export const SettingModel = new SettingModelClass();
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * 런타임 설정 조회 캐시.
3
3
  *
4
- * 값의 출처는 DB(`settings`) → env 순이다. env 를 남겨두는 이유는 전환 때문이다 — dev0 는
5
- * `ecosystem.config.json` 으로 값을 주입하는데, DB 만 보게 만들면 배포 직후 설정이 전부
6
- * 기본값으로 떨어진다.
4
+ * 값의 출처는 DB(`settings`) → env 순이다. 저장된 설정이 없으면 기동 시 전달된
5
+ * 환경변수를 사용하므로, 설정을 DB에 저장하기 전에도 기존 값이 유지된다.
7
6
  *
8
7
  * 설정은 요청 경로(permit 해석, 알림 전송)에서 읽히므로 매번 DB 를 때리지 않고 메모리에
9
8
  * 둔다. `immediate` 변경도 현재 API 프로세스에만 즉시 반영된다. 인스턴스가 여러 개면 다른
@@ -21,16 +21,6 @@ export type SettingApplies = z.infer<typeof SettingApplies>;
21
21
  export const SettingSource = z.enum(["db", "env", "default"]);
22
22
  export type SettingSource = z.infer<typeof SettingSource>;
23
23
 
24
- /**
25
- * 프로세스를 다시 띄워주는 도구. 재시작은 스스로 종료하고 이 도구에 맡기므로,
26
- * 없으면(null) 화면에서 재시작을 시도조차 하지 않는다.
27
- *
28
- * API 반환 타입에 쓰이므로 zod 로 둔다 — sonamu syncer 가 클라이언트 코드를 만들 때
29
- * 타입만 있는 선언은 모듈을 찾지 못한다.
30
- */
31
- export const SupervisorKind = z.enum(["pm2"]);
32
- export type SupervisorKind = z.infer<typeof SupervisorKind>;
33
-
34
24
  export const SettingItem = z.object({
35
25
  key: z.string(),
36
26
  group: z.string(),
@@ -59,8 +49,6 @@ export type RuntimeInfoItem = z.infer<typeof RuntimeInfoItem>;
59
49
  export const SettingsResponse = z.object({
60
50
  settings: SettingItem.array(),
61
51
  runtime: RuntimeInfoItem.array(),
62
- /** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
63
- supervisor: SupervisorKind.nullable(),
64
52
  /** 이 인스턴스가 keepalive 스케줄러를 소유하도록 명시적으로 허용됐는지. */
65
53
  keepaliveRunnerEnabled: z.boolean(),
66
54
  });
@@ -98,13 +98,6 @@ Content-Type: application/json
98
98
 
99
99
  ###
100
100
 
101
- POST {{baseUrl}}/api/setting/restartServer
102
- Content-Type: application/json
103
-
104
- {}
105
-
106
- ###
107
-
108
101
  GET {{baseUrl}}/api/requestLogStep/findById
109
102
  ?subset=A
110
103
  &id=0
@@ -297,6 +290,7 @@ Content-Type: application/json
297
290
  "inputSchema": "unknown"
298
291
  }
299
292
  ],
293
+ "thinking": true,
300
294
  "effort": "EFFORT",
301
295
  "verbosity": "low",
302
296
  "reasoningSummary": "auto",
@@ -361,6 +355,7 @@ Content-Type: application/json
361
355
  "inputSchema": "unknown"
362
356
  }
363
357
  ],
358
+ "thinking": true,
364
359
  "effort": "EFFORT",
365
360
  "verbosity": "low",
366
361
  "reasoningSummary": "auto",
@@ -49,12 +49,9 @@ export default {
49
49
  "error.serviceUnavailable": "Service unavailable",
50
50
  "setting.invalidValue": (reason: string) => `Invalid setting value: ${reason}`,
51
51
  "setting.unknownKey": (key: string) => `Unknown setting key ${key}`,
52
- "setting.noSupervisor": () =>
53
- "Restart is unavailable when running locally — nothing would bring the server back up",
54
52
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher failed to initialize`,
55
53
  "qgrid.dispatcherStarting": (provider: string) =>
56
54
  `${provider} dispatcher is starting up. Please retry shortly`,
57
- "qgrid.restartPending": () => "Server restart is in progress. Please retry shortly",
58
55
  "error.unauthorized": "Unauthorized",
59
56
  notFound: (name: string, id: number) => `${name} ID ${id} not found`,
60
57
  "validation.email": "Invalid email format",
@@ -48,12 +48,9 @@ export default {
48
48
  "error.serviceUnavailable": "서비스를 사용할 수 없습니다",
49
49
  "setting.invalidValue": (reason: string) => `설정 값이 올바르지 않습니다: ${reason}`,
50
50
  "setting.unknownKey": (key: string) => `알 수 없는 설정 키 ${key}`,
51
- "setting.noSupervisor": () =>
52
- "로컬 실행 환경에서는 재시작할 수 없습니다 — 종료하면 다시 켜줄 모니터링 도구가 없습니다",
53
51
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher 초기화에 실패했습니다`,
54
52
  "qgrid.dispatcherStarting": (provider: string) =>
55
53
  `${provider} dispatcher 기동 중입니다. 잠시 후 다시 시도해주세요`,
56
- "qgrid.restartPending": () => "서버 재시작 중입니다. 잠시 후 다시 시도해주세요",
57
54
  "error.unauthorized": "인증이 필요합니다",
58
55
  "error.unknownSearchField": (field: string) => `구현되지 않은 검색 필드 ${field}`,
59
56
  notFound: (name: string, id: number) => `존재하지 않는 ${name} ID ${id}`,
@@ -195,14 +195,14 @@ export default defineConfig({
195
195
  log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
196
196
  }
197
197
 
198
- subscriber.setTokenChangeHandler(() => {
198
+ subscriber.tokenChangeHandler = () => {
199
199
  void rescheduleTokenWindowKeepalive();
200
- });
200
+ };
201
201
  startExpiredTokenReminder();
202
202
  void startTokenWindowKeepalive();
203
203
 
204
- const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
205
- const openaiCount = QgridDispatcher.openaiDispatcher?.tokenCount ?? 0;
204
+ const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenPool.size ?? 0;
205
+ const openaiCount = QgridDispatcher.openaiDispatcher?.countActiveTokens() ?? 0;
206
206
 
207
207
  log.info(`listening on http://${host}:${port}`);
208
208
  log.info(`anthropic: ${anthropicCount} tokens ready`);
@@ -217,7 +217,7 @@ export default defineConfig({
217
217
  stopTokenWindowKeepalive();
218
218
  stopOpenAICallbackRelay();
219
219
  setSettingChangeHandler(null);
220
- QgridDispatcher.subscriber?.setTokenChangeHandler(null);
220
+ if (QgridDispatcher.subscriber) QgridDispatcher.subscriber.tokenChangeHandler = null;
221
221
  if (QgridDispatcher.openaiDispatcher) {
222
222
  await QgridDispatcher.openaiDispatcher.stop();
223
223
  }