@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
@@ -1,12 +1,6 @@
1
1
  /**
2
- * Monit 로그 ring buffer — 프로세스 생존 기간 동안 최근 로그를 구조화 레코드로 보관한다.
3
- *
4
- * 콘솔 sink 와 병렬로 등록되는 두 번째 logtape sink 가 이 버퍼를 채우고,
5
- * monit.frame 이 커서 기반으로 읽어 대시보드 Monit 탭에 제공한다.
6
- * 재시작하면 리셋된다 — 영속 기록은 request_log 의 몫.
7
- *
8
- * timestamp/processStartedAt 은 epoch ms 숫자로 유지한다. web 의 전역 dateReviver 가
9
- * ISO 문자열을 Date 로 바꿔버리므로, 숫자만이 재시작 감지의 동등성 비교를 보존한다.
2
+ * 재시작하면 초기화되는 Monit 로그 버퍼.
3
+ * timestamp/processStartedAt은 web의 dateReviver 변환을 피하고 동등성 비교를 유지하도록 epoch ms를 쓴다.
10
4
  */
11
5
  import { type LogRecord, type Sink } from "@logtape/logtape";
12
6
 
@@ -48,16 +42,14 @@ function renderValue(value: unknown): string {
48
42
 
49
43
  export class MonitLogBuffer {
50
44
  readonly processStartedAt = Date.now();
51
- private entries: MonitLogEntry[] = [];
52
- private nextSeq = 1;
45
+ entries: MonitLogEntry[] = [];
46
+ nextSeq = 1;
53
47
 
54
48
  constructor(
55
- private readonly capacity = DEFAULT_CAPACITY,
56
- private readonly maxTextLength = DEFAULT_MAX_TEXT_LENGTH,
49
+ readonly capacity = DEFAULT_CAPACITY,
50
+ readonly maxTextLength = DEFAULT_MAX_TEXT_LENGTH,
57
51
  ) {}
58
52
 
59
- // logtape sink 본체. logtape 가 sink 오류를 격리하지만, 버퍼 버그가
60
- // meta logger 소음을 만들지 않도록 방어적으로 한 번 더 감싼다.
61
53
  push(record: LogRecord): void {
62
54
  try {
63
55
  let text = flattenMessage(record.message);
@@ -79,31 +71,25 @@ export class MonitLogBuffer {
79
71
  }
80
72
  }
81
73
 
82
- get oldestSeq(): number {
83
- return this.entries[0]?.seq ?? this.nextSeq;
84
- }
85
-
86
- get latestSeq(): number {
87
- return this.nextSeq - 1;
88
- }
89
-
90
- // cursor 이후의 엔트리를 최대 limit 개 반환한다. cursor 미지정이면 tail.
74
+ // cursor 미지정이면 최근 엔트리부터 반환한다.
91
75
  after(cursor: number | undefined, limit: number): MonitLogChunk {
76
+ const latestSeq = this.nextSeq - 1;
77
+ const oldestSeq = this.entries[0]?.seq ?? this.nextSeq;
92
78
  if (cursor === undefined) {
93
79
  const entries = this.entries.slice(-limit);
94
80
  return {
95
81
  entries,
96
- nextCursor: entries.at(-1)?.seq ?? this.latestSeq,
82
+ nextCursor: entries.at(-1)?.seq ?? latestSeq,
97
83
  dropped: 0,
98
84
  };
99
85
  }
100
86
 
101
87
  // 이전 프로세스의 커서 등 미래 커서는 방어적으로 현재 tail 로 재동기화한다.
102
- if (cursor > this.latestSeq) {
103
- return { entries: [], nextCursor: this.latestSeq, dropped: 0 };
88
+ if (cursor > latestSeq) {
89
+ return { entries: [], nextCursor: latestSeq, dropped: 0 };
104
90
  }
105
91
 
106
- const dropped = Math.max(this.oldestSeq - cursor - 1, 0);
92
+ const dropped = Math.max(oldestSeq - cursor - 1, 0);
107
93
  const startIndex = this.entries.findIndex((entry) => entry.seq > cursor);
108
94
  const entries = startIndex === -1 ? [] : this.entries.slice(startIndex, startIndex + limit);
109
95
  return {
@@ -36,12 +36,12 @@ describe("MonitFrame.monitLogs", () => {
36
36
  const chunk = await MonitFrame.monitLogs();
37
37
  expect(chunk.processStartedAt).toBe(monitLogBuffer.processStartedAt);
38
38
  expect(chunk.dropped).toBe(0);
39
- expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
39
+ expect(chunk.nextCursor).toBe(monitLogBuffer.after(undefined, 1).nextCursor);
40
40
  expect(chunk.entries.at(-1)?.text).toBe("second line");
41
41
  });
42
42
 
43
43
  it("returns only entries newer than the cursor with no boundary duplicates", async () => {
44
- const baseline = monitLogBuffer.latestSeq;
44
+ const baseline = monitLogBuffer.after(undefined, 1).nextCursor;
45
45
  push("after baseline 1");
46
46
  push("after baseline 2");
47
47
 
@@ -50,7 +50,7 @@ describe("MonitFrame.monitLogs", () => {
50
50
  "after baseline 1",
51
51
  "after baseline 2",
52
52
  ]);
53
- expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
53
+ expect(chunk.nextCursor).toBe(monitLogBuffer.after(undefined, 1).nextCursor);
54
54
 
55
55
  const followUp = await MonitFrame.monitLogs(chunk.nextCursor);
56
56
  expect(followUp.entries).toEqual([]);
@@ -90,7 +90,7 @@ describe("MonitFrame.monitLogs", () => {
90
90
  properties: { accessToken: "forbidden-property-secret", refreshToken: "also-forbidden" },
91
91
  });
92
92
 
93
- const chunk = await MonitFrame.monitLogs(monitLogBuffer.latestSeq - 1);
93
+ const chunk = await MonitFrame.monitLogs(monitLogBuffer.after(undefined, 1).nextCursor - 1);
94
94
  const serialized = JSON.stringify(chunk);
95
95
  expect(serialized).not.toContain("forbidden-property-secret");
96
96
  expect(serialized).not.toContain("also-forbidden");
@@ -115,14 +115,13 @@ describe("MonitFrame.monitLogs", () => {
115
115
  it("piggybacks live vitals on every chunk, zeroed before dispatchers boot", async () => {
116
116
  QgridDispatcher.openaiDispatcher = {
117
117
  inFlight: 3,
118
- quotaByToken: [
118
+ getQuotaSnapshot: () => [
119
119
  { name: "openai/haze", usedPercent: 41, threshold: 80, blocked: false, resetsAt: null },
120
120
  { name: "openai/nk", usedPercent: null, threshold: null, blocked: false, resetsAt: null },
121
121
  ],
122
122
  } as never;
123
123
  QgridDispatcher.anthropicDispatcher = {
124
- tokenCount: 8,
125
- tokenNames: ["anthropic/haze", "anthropic/noa"],
124
+ tokenPool: new Map([[1, {name: "anthropic/noa"}], [2, {name: "anthropic/haze"}]]),
126
125
  inFlight: 2,
127
126
  } as never;
128
127
  try {
@@ -133,7 +132,7 @@ describe("MonitFrame.monitLogs", () => {
133
132
  { name: "openai/haze", usedPercent: 41, threshold: 80, blocked: false, resetsAt: null },
134
133
  { name: "openai/nk", usedPercent: null, threshold: null, blocked: false, resetsAt: null },
135
134
  ],
136
- anthropicTokenCount: 8,
135
+ anthropicTokenCount: 2,
137
136
  anthropicTokenNames: ["anthropic/haze", "anthropic/noa"],
138
137
  anthropicInFlight: 2,
139
138
  });
@@ -1,9 +1,4 @@
1
- /**
2
- * Monit — 대시보드 Monit 탭용 프로세스 로그 관측 API.
3
- *
4
- * 읽기 전용. ring buffer 를 얇게 직접 읽으며(범용 쿼리 기계 없이),
5
- * request log 를 남기지 않는다. 접근 경계는 다른 대시보드 API 와 동일하다.
6
- */
1
+ // 읽기 전용 모니터링 API. 조회 자체는 request log에 남기지 않는다.
7
2
  import { api, BaseFrameClass, DB } from "sonamu";
8
3
 
9
4
  import { resolveOpenAITransportKind } from "../../utils/providers/openai/openai-transport-config";
@@ -49,9 +44,7 @@ class MonitFrameClass extends BaseFrameClass {
49
44
  };
50
45
  }
51
46
 
52
- // 최근 1시간 provider 별 요청/오류/캐시 통계. vitals 와 달리 request_logs 를 타므로
53
- // 분리된 endpoint 로 두고 웹에서 더 느슨한 주기로 폴링한다. 읽기 전용이며 request log 를
54
- // 남기지 않는 monit 경계는 동일하다.
47
+ // DB를 조회하므로 메모리 기반 vitals보다 느린 주기로 폴링한다.
55
48
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
56
49
  async monitStats(): Promise<MonitStats> {
57
50
  const windowMinutes = 60;
@@ -60,14 +53,16 @@ class MonitFrameClass extends BaseFrameClass {
60
53
  }
61
54
  }
62
55
 
63
- // dispatcher 미초기화(부팅 직후) 시 0 으로 응답한다 — 가벼운 스냅샷이라 오류로 만들지 않는다.
64
56
  function currentVitals(): MonitVitals {
57
+ const anthropic = QgridDispatcher.anthropicDispatcher;
65
58
  return {
66
59
  openaiInFlight: QgridDispatcher.openaiDispatcher?.inFlight ?? 0,
67
- openaiQuotaByToken: QgridDispatcher.openaiDispatcher?.quotaByToken ?? [],
68
- anthropicTokenCount: QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0,
69
- anthropicTokenNames: QgridDispatcher.anthropicDispatcher?.tokenNames ?? [],
70
- anthropicInFlight: QgridDispatcher.anthropicDispatcher?.inFlight ?? 0,
60
+ openaiQuotaByToken: QgridDispatcher.openaiDispatcher?.getQuotaSnapshot() ?? [],
61
+ anthropicTokenCount: anthropic?.tokenPool.size ?? 0,
62
+ anthropicTokenNames: anthropic
63
+ ? [...anthropic.tokenPool.values()].map((token) => token.name).toSorted()
64
+ : [],
65
+ anthropicInFlight: anthropic?.inFlight ?? 0,
71
66
  };
72
67
  }
73
68
 
@@ -132,7 +132,7 @@ export class RefreshFailedError extends Error {
132
132
  }
133
133
 
134
134
  /** 재로그인 외 복구 불가한 인증 실패인지. 일시 오류(5xx·네트워크)는 제외된다. */
135
- get isAuthDead(): boolean {
135
+ isAuthDead(): boolean {
136
136
  return this.status === 400 || this.status === 401;
137
137
  }
138
138
  }
@@ -1,16 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import {
4
- beginServerRestart,
5
- resetServerRestartForTests,
6
- } from "../../utils/server-restart";
7
3
  import {
8
4
  afterQuery,
9
5
  beforeQuery,
10
- finishActiveNativeRunsForRestart,
11
6
  finishRunAborted,
12
7
  finishRunWithError,
13
- resetNativeRunRegistryForTests,
14
8
  } from "./qgrid-run-lifecycle";
15
9
  import { type QueryOutput } from "./qgrid.types";
16
10
 
@@ -65,11 +59,6 @@ function queryOutput(overrides: Partial<QueryOutput> = {}): QueryOutput {
65
59
 
66
60
  let lifecycleNow = Date.now();
67
61
 
68
- afterEach(() => {
69
- resetServerRestartForTests();
70
- resetNativeRunRegistryForTests();
71
- });
72
-
73
62
  describe("qgrid run lifecycle start", () => {
74
63
  beforeEach(() => {
75
64
  lifecycleNow += 60_001;
@@ -122,6 +111,13 @@ describe("qgrid run lifecycle start", () => {
122
111
  });
123
112
  });
124
113
 
114
+ it.each([false, true, undefined])("does not log ignored effort when thinking is %s", async (thinking) => {
115
+ await beforeQuery({ prompt: "hi", model: "openai/gpt-6-sol", thinking, effort: "high" });
116
+ expect(createRunMock).toHaveBeenCalledWith(expect.objectContaining({
117
+ effort: thinking === false ? undefined : "high",
118
+ }));
119
+ });
120
+
125
121
  it.each([false, true])("classifies automatic image output only when produced (%s)", async (hasImage) => {
126
122
  const args = { prompt: "chat", model: "openai/gpt-6-astra", imageGeneration: "auto" as const };
127
123
  const run = await beforeQuery(args);
@@ -214,107 +210,7 @@ describe("qgrid run lifecycle start", () => {
214
210
  expect(createRunMock).toHaveBeenCalledTimes(2);
215
211
  });
216
212
 
217
- it("rejects new native runs while a restart is pending", async () => {
218
- beginServerRestart();
219
-
220
- await expect(beforeQuery({ prompt: "hi" })).rejects.toMatchObject({ statusCode: 503 });
221
- expect(createRunMock).not.toHaveBeenCalled();
222
- });
223
-
224
- it("finishes a run prepared during the restart race before rejecting provider execution", async () => {
225
- let resolveCreate!: (requestLogId: number) => void;
226
- createRunMock.mockReturnValueOnce(
227
- new Promise<number>((resolve) => {
228
- resolveCreate = resolve;
229
- }),
230
- );
231
-
232
- const starting = beforeQuery({ prompt: "hi" });
233
- await vi.waitFor(() => expect(createRunMock).toHaveBeenCalledOnce());
234
- beginServerRestart();
235
- resolveCreate(22);
236
-
237
- await expect(starting).rejects.toMatchObject({ statusCode: 503 });
238
- expect(finishRunMock).toHaveBeenCalledWith(
239
- 22,
240
- expect.objectContaining({ status: "error", error_message: "server restarted" }),
241
- );
242
- });
243
-
244
- it("marks only active native runs as restarted and prevents a late success overwrite", async () => {
245
- const { requestLogId, stepIndex } = await beforeQuery({ prompt: "hi" });
246
213
 
247
- await finishActiveNativeRunsForRestart();
248
- expect(finishRunMock).toHaveBeenCalledWith(
249
- requestLogId,
250
- expect.objectContaining({ status: "error", error_message: "server restarted" }),
251
- );
252
-
253
- appendStepMock.mockClear();
254
- finishRunMock.mockClear();
255
- await expect(afterQuery(requestLogId, stepIndex, { prompt: "hi" }, queryOutput())).resolves.toEqual(
256
- {},
257
- );
258
- expect(appendStepMock).not.toHaveBeenCalled();
259
- expect(finishRunMock).not.toHaveBeenCalled();
260
- });
261
-
262
- it("waits for an in-flight finalizer and writes the restart error last", async () => {
263
- const { requestLogId, stepIndex } = await beforeQuery({ prompt: "hi" });
264
- let releaseAppend!: () => void;
265
- appendStepMock.mockReturnValueOnce(
266
- new Promise<void>((resolve) => {
267
- releaseAppend = resolve;
268
- }),
269
- );
270
-
271
- const finishing = afterQuery(requestLogId, stepIndex, { prompt: "hi" }, queryOutput());
272
- await vi.waitFor(() => expect(appendStepMock).toHaveBeenCalledOnce());
273
- const restarting = finishActiveNativeRunsForRestart();
274
-
275
- await Promise.resolve();
276
- expect(finishRunMock).not.toHaveBeenCalledWith(
277
- requestLogId,
278
- expect.objectContaining({ error_message: "server restarted" }),
279
- );
280
-
281
- releaseAppend();
282
- await Promise.all([finishing, restarting]);
283
-
284
- const terminalStatuses = finishRunMock.mock.calls
285
- .filter(([id]) => id === requestLogId)
286
- .map(([, payload]) => payload.status);
287
- expect(terminalStatuses.at(-1)).toBe("error");
288
- expect(finishRunMock).toHaveBeenLastCalledWith(
289
- requestLogId,
290
- expect.objectContaining({ status: "error", error_message: "server restarted" }),
291
- );
292
- });
293
-
294
- it("leaves tool-wait runs alone after their provider turn has completed", async () => {
295
- const { requestLogId, stepIndex } = await beforeQuery({ prompt: "use a tool" });
296
- await afterQuery(
297
- requestLogId,
298
- stepIndex,
299
- { prompt: "use a tool" },
300
- queryOutput({
301
- finishReason: "tool-calls",
302
- content: [
303
- {
304
- type: "tool-call",
305
- toolCallId: "call-1",
306
- toolName: "lookup",
307
- input: "{}",
308
- },
309
- ],
310
- }),
311
- );
312
- finishRunMock.mockClear();
313
-
314
- await finishActiveNativeRunsForRestart();
315
-
316
- expect(finishRunMock).not.toHaveBeenCalled();
317
- });
318
214
  });
319
215
 
320
216
  describe("qgrid run lifecycle TTFT", () => {
@@ -1,8 +1,5 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
- import { ServiceUnavailableException } from "sonamu";
3
2
 
4
- import { SD } from "../../i18n/sd.generated";
5
- import { isRestartPending } from "../../utils/server-restart";
6
3
  import { RequestLogModel } from "../request-log/request-log.model";
7
4
  import {
8
5
  estimateImageGenerationCostMicroUsd,
@@ -20,44 +17,6 @@ const STALE_RUN_THRESHOLD_MS = 30 * 60 * 1000;
20
17
  const STALE_CLEANUP_INTERVAL_MS = 60 * 1000;
21
18
  let staleCleanupInFlight: Promise<void> | undefined;
22
19
  let lastStaleCleanupStartedAt = 0;
23
- const activeNativeRunRefs = new Map<number, number>();
24
- const interruptedNativeRunIds = new Set<number>();
25
- const activeNativeFinalizers = new Map<number, Set<Promise<void>>>();
26
-
27
- function restartUnavailable(): ServiceUnavailableException {
28
- return new ServiceUnavailableException(SD("qgrid.restartPending")());
29
- }
30
-
31
- function registerNativeRun(requestLogId: number): void {
32
- activeNativeRunRefs.set(requestLogId, (activeNativeRunRefs.get(requestLogId) ?? 0) + 1);
33
- }
34
-
35
- function releaseNativeRun(requestLogId: number): void {
36
- const refs = activeNativeRunRefs.get(requestLogId);
37
- if (refs === undefined || refs <= 1) activeNativeRunRefs.delete(requestLogId);
38
- else activeNativeRunRefs.set(requestLogId, refs - 1);
39
- }
40
-
41
- function trackNativeFinalizer<T>(requestLogId: number, finalize: () => Promise<T>): Promise<T> {
42
- const operation = finalize();
43
- const settled = operation.then(
44
- () => undefined,
45
- () => undefined,
46
- );
47
- const finalizers = activeNativeFinalizers.get(requestLogId) ?? new Set<Promise<void>>();
48
- finalizers.add(settled);
49
- activeNativeFinalizers.set(requestLogId, finalizers);
50
- void settled.then(() => {
51
- finalizers.delete(settled);
52
- if (finalizers.size === 0) activeNativeFinalizers.delete(requestLogId);
53
- });
54
- return operation;
55
- }
56
-
57
- export function assertNativeRunAdmission(): void {
58
- if (isRestartPending()) throw restartUnavailable();
59
- }
60
-
61
20
  function withProviderPrefix(
62
21
  routeModel: string | undefined,
63
22
  model: string | undefined,
@@ -110,8 +69,6 @@ export async function beforeQuery(args: QueryInput): Promise<{
110
69
  requestLogId: number;
111
70
  stepIndex: number;
112
71
  }> {
113
- assertNativeRunAdmission();
114
-
115
72
  let requestLogId: number;
116
73
  let stepIndex = 0;
117
74
 
@@ -131,7 +88,7 @@ export async function beforeQuery(args: QueryInput): Promise<{
131
88
  user_prompt: args.prompt,
132
89
  system_prompt: args.system,
133
90
  requested_model_name: args.model,
134
- effort: args.effort,
91
+ effort: args.thinking === false ? undefined : args.effort,
135
92
  project_name: args.projectName,
136
93
  history: filterHistoryForStorage(args.history),
137
94
  tools: args.tools?.length ? args.tools : undefined,
@@ -140,14 +97,6 @@ export async function beforeQuery(args: QueryInput): Promise<{
140
97
  });
141
98
  }
142
99
 
143
- // DB 준비 중에 재시작이 시작됐다면 provider 실행으로 넘어가지 않는다.
144
- // 이 요청은 재시작 snapshot에 없었으므로 방금 준비한 run을 직접 마감한다.
145
- if (isRestartPending()) {
146
- await finishTerminalRun(requestLogId, "error", "server restarted");
147
- throw restartUnavailable();
148
- }
149
-
150
- registerNativeRun(requestLogId);
151
100
  return { requestLogId, stepIndex };
152
101
  }
153
102
 
@@ -157,102 +106,94 @@ export async function afterQuery(
157
106
  args: QueryInput,
158
107
  result: QueryOutput,
159
108
  ): Promise<RunLifecycleResult> {
160
- if (interruptedNativeRunIds.has(requestLogId)) return {};
109
+ // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
110
+ const requestedModelName = withProviderPrefix(
111
+ args.model,
112
+ args.model ?? result.requestedModel ?? result.model,
113
+ );
114
+ const servedModelName = withProviderPrefix(args.model, result.model);
115
+
116
+ // generate step 기록
117
+ await RequestLogModel.appendStep(requestLogId, {
118
+ step_index: stepIndex,
119
+ type: "generate",
120
+ model_name: servedModelName,
121
+ requested_model_name: requestedModelName,
122
+ fallback_count: result.modelFallbacks?.length ?? 0,
123
+ input_tokens: result.usage.input_tokens,
124
+ output_tokens: result.usage.output_tokens,
125
+ cache_read_tokens: result.usage.cache_read_input_tokens,
126
+ cache_creation_tokens: result.usage.cache_creation_input_tokens,
127
+ cache_creation_5m_tokens: result.usage.cache_creation_5m_input_tokens,
128
+ cache_creation_1h_tokens: result.usage.cache_creation_1h_input_tokens,
129
+ cost_usd: Math.round(result.costUsd * 1_000_000),
130
+ cost_source: result.costSource,
131
+ duration_ms: result.durationMs,
132
+ ttft_ms: result.ttftMs,
133
+ finish_reason: result.finishReason,
134
+ });
161
135
 
162
- return trackNativeFinalizer(requestLogId, async () => {
163
- try {
164
- // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
165
- const requestedModelName = withProviderPrefix(
166
- args.model,
167
- args.model ?? result.requestedModel ?? result.model,
168
- );
169
- const servedModelName = withProviderPrefix(args.model, result.model);
136
+ const imageParts = getImageParts(result);
137
+ const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
138
+ result,
139
+ args.imageGenerationOptions,
140
+ );
141
+ for (const step of buildImageGenerationToolSteps(args, imageParts, stepIndex)) {
142
+ await RequestLogModel.appendStep(requestLogId, step);
143
+ }
170
144
 
171
- // generate step 기록
145
+ if (result.finishReason === "tool-calls") {
146
+ // tool-call step을 즉시 기록하고, 다음 follow-up이 같은 row에 결과를 채운다.
147
+ const toolCalls = result.content.filter((c) => c.type === "tool-call");
148
+ for (let i = 0; i < toolCalls.length; i++) {
149
+ const tc = toolCalls[i]!;
150
+ if (tc.type !== "tool-call") continue;
172
151
  await RequestLogModel.appendStep(requestLogId, {
173
152
  step_index: stepIndex,
174
- type: "generate",
175
- model_name: servedModelName,
176
- requested_model_name: requestedModelName,
177
- fallback_count: result.modelFallbacks?.length ?? 0,
178
- input_tokens: result.usage.input_tokens,
179
- output_tokens: result.usage.output_tokens,
180
- cache_read_tokens: result.usage.cache_read_input_tokens,
181
- cache_creation_tokens: result.usage.cache_creation_input_tokens,
182
- cache_creation_5m_tokens: result.usage.cache_creation_5m_input_tokens,
183
- cache_creation_1h_tokens: result.usage.cache_creation_1h_input_tokens,
184
- cost_usd: Math.round(result.costUsd * 1_000_000),
185
- cost_source: result.costSource,
186
- duration_ms: result.durationMs,
187
- ttft_ms: result.ttftMs,
188
- finish_reason: result.finishReason,
153
+ type: "tool_call",
154
+ tool_call_index: imageParts.length + i,
155
+ tool_call_id: tc.toolCallId,
156
+ tool_name: tc.toolName,
157
+ tool_args: tc.input,
189
158
  });
159
+ }
190
160
 
191
- const imageParts = getImageParts(result);
192
- const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
193
- result,
194
- args.imageGenerationOptions,
195
- );
196
- for (const step of buildImageGenerationToolSteps(args, imageParts, stepIndex)) {
197
- await RequestLogModel.appendStep(requestLogId, step);
198
- }
199
-
200
- if (result.finishReason === "tool-calls") {
201
- // tool-call step을 즉시 기록하고, 다음 follow-up이 같은 row에 결과를 채운다.
202
- const toolCalls = result.content.filter((c) => c.type === "tool-call");
203
- for (let i = 0; i < toolCalls.length; i++) {
204
- const tc = toolCalls[i]!;
205
- if (tc.type !== "tool-call") continue;
206
- await RequestLogModel.appendStep(requestLogId, {
207
- step_index: stepIndex,
208
- type: "tool_call",
209
- tool_call_index: imageParts.length + i,
210
- tool_call_id: tc.toolCallId,
211
- tool_name: tc.toolName,
212
- tool_args: tc.input,
213
- });
214
- }
215
-
216
- return { runContext: { requestLogId } };
217
- }
218
-
219
- // finishReason === "stop" → run 종료 (전체 step usage 합산)
220
- const agg = await RequestLogModel.aggregateStepUsage(requestLogId);
221
- const responseText = formatResponseForLog(result);
222
- await RequestLogModel.finishRun(requestLogId, {
223
- status: "succeeded",
224
- response: responseText,
225
- // structured 요청만 판정한다. deti 배치의 broken JSON 류를 목록에서 바로
226
- // 구별하기 위한 기록이며, 비structured 요청은 null 로 남는다.
227
- response_json_ok: args.jsonSchema ? isParseableJson(responseText) : null,
228
- token_name: result.tokenName,
229
- input_tokens: agg.input_tokens,
230
- output_tokens: agg.output_tokens,
231
- cache_read_tokens: agg.cache_read_tokens,
232
- cache_creation_tokens: agg.cache_creation_tokens,
233
- cache_creation_5m_tokens: agg.cache_creation_5m_tokens,
234
- cache_creation_1h_tokens: agg.cache_creation_1h_tokens,
235
- duration_ms: agg.duration_ms,
236
- // multi-step run 부모의 모델은 최종 turn을 서빙한 모델이다.
237
- requested_model_name: requestedModelName,
238
- model_name: servedModelName,
239
- fallback_count: agg.fallback_count,
240
- cost_usd: agg.cost_usd,
241
- cost_source: agg.cost_source,
242
- image_cost_usd: imageCostMicroUsd,
243
- ...(imageParts.length > 0 ? { is_image_generation: true } : {}),
244
- image_cost_method:
245
- imageCostMicroUsd !== null
246
- ? imageGenerationCostMethod(args.imageGenerationOptions, result)
247
- : null,
248
- history: filterHistoryForStorage(args.history),
249
- });
161
+ return { runContext: { requestLogId } };
162
+ }
250
163
 
251
- return {};
252
- } finally {
253
- releaseNativeRun(requestLogId);
254
- }
164
+ // finishReason === "stop" → run 종료 (전체 step usage 합산)
165
+ const agg = await RequestLogModel.aggregateStepUsage(requestLogId);
166
+ const responseText = formatResponseForLog(result);
167
+ await RequestLogModel.finishRun(requestLogId, {
168
+ status: "succeeded",
169
+ response: responseText,
170
+ // structured 요청만 판정한다. deti 배치의 broken JSON 류를 목록에서 바로
171
+ // 구별하기 위한 기록이며, 비structured 요청은 null 로 남는다.
172
+ response_json_ok: args.jsonSchema ? isParseableJson(responseText) : null,
173
+ token_name: result.tokenName,
174
+ input_tokens: agg.input_tokens,
175
+ output_tokens: agg.output_tokens,
176
+ cache_read_tokens: agg.cache_read_tokens,
177
+ cache_creation_tokens: agg.cache_creation_tokens,
178
+ cache_creation_5m_tokens: agg.cache_creation_5m_tokens,
179
+ cache_creation_1h_tokens: agg.cache_creation_1h_tokens,
180
+ duration_ms: agg.duration_ms,
181
+ // multi-step run 부모의 모델은 최종 turn을 서빙한 모델이다.
182
+ requested_model_name: requestedModelName,
183
+ model_name: servedModelName,
184
+ fallback_count: agg.fallback_count,
185
+ cost_usd: agg.cost_usd,
186
+ cost_source: agg.cost_source,
187
+ image_cost_usd: imageCostMicroUsd,
188
+ ...(imageParts.length > 0 ? { is_image_generation: true } : {}),
189
+ image_cost_method:
190
+ imageCostMicroUsd !== null
191
+ ? imageGenerationCostMethod(args.imageGenerationOptions, result)
192
+ : null,
193
+ history: filterHistoryForStorage(args.history),
255
194
  });
195
+
196
+ return {};
256
197
  }
257
198
 
258
199
  export async function finishRunWithError(
@@ -260,48 +201,11 @@ export async function finishRunWithError(
260
201
  errorMessage: string,
261
202
  args?: QueryInput,
262
203
  ): Promise<void> {
263
- if (interruptedNativeRunIds.has(requestLogId)) return;
264
- return trackNativeFinalizer(requestLogId, async () => {
265
- try {
266
- await finishTerminalRun(requestLogId, "error", errorMessage, args);
267
- } finally {
268
- releaseNativeRun(requestLogId);
269
- }
270
- });
204
+ await finishTerminalRun(requestLogId, "error", errorMessage, args);
271
205
  }
272
206
 
273
207
  export async function finishRunAborted(requestLogId: number, args?: QueryInput): Promise<void> {
274
- if (interruptedNativeRunIds.has(requestLogId)) return;
275
- return trackNativeFinalizer(requestLogId, async () => {
276
- try {
277
- await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
278
- } finally {
279
- releaseNativeRun(requestLogId);
280
- }
281
- });
282
- }
283
-
284
- /** 현재 이 프로세스가 provider를 실행 중인 native run만 재시작 오류로 마감한다. */
285
- export async function finishActiveNativeRunsForRestart(): Promise<void> {
286
- const requestLogIds = [...activeNativeRunRefs.keys()];
287
- for (const requestLogId of requestLogIds) {
288
- interruptedNativeRunIds.add(requestLogId);
289
- activeNativeRunRefs.delete(requestLogId);
290
- }
291
-
292
- await Promise.all(
293
- requestLogIds.map(async (requestLogId) => {
294
- await Promise.all(activeNativeFinalizers.get(requestLogId) ?? []);
295
- await finishTerminalRun(requestLogId, "error", "server restarted");
296
- }),
297
- );
298
- }
299
-
300
- /** 프로세스 전역 registry를 격리해야 하는 단위 테스트 전용. */
301
- export function resetNativeRunRegistryForTests(): void {
302
- activeNativeRunRefs.clear();
303
- interruptedNativeRunIds.clear();
304
- activeNativeFinalizers.clear();
208
+ await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
305
209
  }
306
210
 
307
211
  async function finishTerminalRun(