@cartanova/qgrid-cli 2.10.1 → 2.10.3

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 (97) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/monit/log-buffer.js +7 -11
  4. package/bundle/dist/application/monit/monit.frame.js +6 -11
  5. package/bundle/dist/application/qgrid/oauth.js +2 -2
  6. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  7. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  8. package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
  9. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  10. package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
  11. package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
  12. package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
  13. package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
  14. package/bundle/dist/application/request-log/request-log.model.js +7 -5
  15. package/bundle/dist/application/setting/setting.model.js +1 -1
  16. package/bundle/dist/sonamu.config.js +6 -6
  17. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  19. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  21. package/bundle/dist/utils/providers/common/effort.js +1 -1
  22. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  23. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  24. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  25. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  26. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  27. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  28. package/bundle/src/application/monit/log-buffer.ts +13 -27
  29. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  30. package/bundle/src/application/monit/monit.frame.ts +9 -14
  31. package/bundle/src/application/qgrid/oauth.ts +1 -1
  32. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
  33. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  34. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  35. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  36. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
  37. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  38. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  39. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  40. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  41. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  42. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  43. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  44. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  45. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  46. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  47. package/bundle/src/application/setting/setting.model.ts +1 -1
  48. package/bundle/src/sonamu.config.ts +5 -5
  49. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  50. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  51. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  52. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  53. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  54. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  55. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  56. package/bundle/src/utils/providers/common/effort.ts +1 -1
  57. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  58. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  59. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  60. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  61. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  62. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  65. package/bundle/web-dist/client/assets/{index-Cq4_ExLO.js → index-BIC1sQxw.js} +3 -3
  66. package/bundle/web-dist/client/assets/logs-zvdujZWn.js +1 -0
  67. package/bundle/web-dist/client/assets/monit-CraVV3MQ.js +1 -0
  68. package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
  69. package/bundle/web-dist/client/assets/{routes-CXotE1OF.js → routes-B3ZBoJ60.js} +1 -1
  70. package/bundle/web-dist/client/assets/{settings-CfJDz4AC.js → settings-DwbzT5jo.js} +1 -1
  71. package/bundle/web-dist/client/assets/{show-gBpWK4z2.js → show-ItTExdAh.js} +1 -1
  72. package/bundle/web-dist/client/assets/{tokens-CbFJjIyC.js → tokens-BynKx8Vn.js} +1 -1
  73. package/bundle/web-dist/client/assets/{use-oauth-login-flow-jJo7GV3r.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
  74. package/bundle/web-dist/client/index.html +1 -1
  75. package/bundle/web-dist/server/assets/{logs-DMlOXcDS.js → logs-BSk9lOHe.js} +13 -3
  76. package/bundle/web-dist/server/assets/{monit-Dg1wZCdx.js → monit-D9W4ioUi.js} +2 -21
  77. package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
  78. package/bundle/web-dist/server/assets/{settings-BaIBt_Vs.js → settings-BFVuldch.js} +1 -1
  79. package/bundle/web-dist/server/assets/{show-CDxGr4rS.js → show-Cswh_tqs.js} +1 -1
  80. package/bundle/web-dist/server/assets/{tokens-DJlfeGcx.js → tokens-DiTUwUwr.js} +1 -1
  81. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  82. package/package.json +1 -1
  83. package/skills/qgrid/SKILL.md +1 -0
  84. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  86. package/skills/qgrid/references/decision-rationale.md +9 -0
  87. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  88. package/skills/qgrid/references/sonamu-api-web-flow.md +3 -1
  89. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  90. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  91. package/skills/qgrid/references/verification-and-debugging.md +1 -1
  92. package/bundle/web-dist/client/assets/logs-kg1KOMBk.js +0 -1
  93. package/bundle/web-dist/client/assets/monit-DmtHTmSM.js +0 -1
  94. /package/bundle/web-dist/client/assets/{plus-CknMNCP5.js → plus-B62CuqT8.js} +0 -0
  95. /package/bundle/web-dist/client/assets/{request-model-eygfdRv7.js → request-model-D4CZPmnG.js} +0 -0
  96. /package/bundle/web-dist/server/assets/{plus-DVobMrag.js → plus-CmvbWuDf.js} +0 -0
  97. /package/bundle/web-dist/server/assets/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
@@ -32,8 +32,7 @@ function subscriberWith(openaiDispatcher: Record<string, unknown>) {
32
32
  return new TokenSubscriber(
33
33
  {} as never,
34
34
  {
35
- removeCache: vi.fn(),
36
- upsertCache: vi.fn(),
35
+ tokens: new Map(),
37
36
  replaceCache: vi.fn(),
38
37
  openaiDispatcher,
39
38
  anthropicDispatcher: null,
@@ -82,8 +81,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
82
81
  const subscriber = new TokenSubscriber(
83
82
  {} as never,
84
83
  {
85
- removeCache: vi.fn(),
86
- upsertCache: vi.fn(),
84
+ tokens: new Map(),
87
85
  replaceCache: vi.fn(),
88
86
  openaiDispatcher: null,
89
87
  anthropicDispatcher,
@@ -112,15 +110,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
112
110
  {} as never,
113
111
  {
114
112
  tokens: new Map(),
115
- removeCache: vi.fn(),
116
- upsertCache: vi.fn(),
117
113
  replaceCache: vi.fn(),
118
114
  openaiDispatcher: null,
119
115
  anthropicDispatcher,
120
116
  } as never,
121
117
  );
122
118
  const onTokensChanged = vi.fn();
123
- subscriber.setTokenChangeHandler(onTokensChanged);
119
+ subscriber.tokenChangeHandler = onTokensChanged;
124
120
  findOneMock.mockResolvedValueOnce({
125
121
  ...openaiToken(true),
126
122
  provider: "anthropic",
@@ -143,15 +139,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
143
139
  {} as never,
144
140
  {
145
141
  tokens: new Map([[previous.id, previous]]),
146
- removeCache: vi.fn(),
147
- upsertCache: vi.fn(),
148
142
  replaceCache: vi.fn(),
149
143
  openaiDispatcher: null,
150
144
  anthropicDispatcher,
151
145
  } as never,
152
146
  );
153
147
  const onTokensChanged = vi.fn();
154
- subscriber.setTokenChangeHandler(onTokensChanged);
148
+ subscriber.tokenChangeHandler = onTokensChanged;
155
149
  findOneMock.mockResolvedValueOnce({
156
150
  ...previous,
157
151
  credentials: { accessToken: "new", refreshToken: "refresh" },
@@ -173,15 +167,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
173
167
  {} as never,
174
168
  {
175
169
  tokens: new Map([[previous.id, previous]]),
176
- removeCache: vi.fn(),
177
- upsertCache: vi.fn(),
178
170
  replaceCache: vi.fn(),
179
171
  openaiDispatcher: null,
180
172
  anthropicDispatcher: { onTokenRemoved: vi.fn() },
181
173
  } as never,
182
174
  );
183
175
  const onTokensChanged = vi.fn();
184
- subscriber.setTokenChangeHandler(onTokensChanged);
176
+ subscriber.tokenChangeHandler = onTokensChanged;
185
177
  findOneMock.mockResolvedValueOnce({ ...previous, active: false });
186
178
 
187
179
  await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: previous.id }));
@@ -198,8 +190,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
198
190
  const subscriber = new TokenSubscriber(
199
191
  {} as never,
200
192
  {
201
- removeCache: vi.fn(),
202
- upsertCache: vi.fn(),
193
+ tokens: new Map(),
203
194
  replaceCache: vi.fn(),
204
195
  openaiDispatcher: null,
205
196
  anthropicDispatcher,
@@ -243,8 +234,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
243
234
  const subscriber = new TokenSubscriber(
244
235
  {} as never,
245
236
  {
246
- removeCache: vi.fn(),
247
- upsertCache: vi.fn(),
237
+ tokens: new Map(),
248
238
  replaceCache: vi.fn(),
249
239
  openaiDispatcher,
250
240
  anthropicDispatcher,
@@ -290,7 +280,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
290
280
  };
291
281
  const subscriber = new TokenSubscriber({} as never, dispatcher as never);
292
282
  const onTokensChanged = vi.fn();
293
- subscriber.setTokenChangeHandler(onTokensChanged);
283
+ subscriber.tokenChangeHandler = onTokensChanged;
294
284
  findActiveMock
295
285
  .mockResolvedValueOnce([currentAnthropic])
296
286
  .mockResolvedValueOnce([
@@ -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
 
@@ -251,7 +251,7 @@ class SettingModelClass extends BaseModelClass<
251
251
  };
252
252
  }
253
253
 
254
- private runtimeInfo(): RuntimeInfoItem[] {
254
+ runtimeInfo(): RuntimeInfoItem[] {
255
255
  const env = process.env;
256
256
  const password = env.QGRID_DB_PASSWORD ?? env.SONAMU_DB_PASSWORD ?? "";
257
257
  return [
@@ -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
  }
@@ -58,12 +58,9 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
58
58
  ]);
59
59
 
60
60
  const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
61
- // Fable 5/5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5 는 thinking disabled 를 거부한다(Fable: CLI 카탈로그 `rejects_disabled_thinking`,
62
- // Opus 5.5: 공식 사양 "can't be turned off", 2026-09-22 출시. Sonnet 5.5: API `{type: "disabled"}` 400, CLI 카탈로그 `rejects_disabled_thinking`, 2026-09-28 출시.
63
- // Haiku 5.5: API 는 high 이하에서만 disabled 허용·xhigh/max 400, Claude Code 2.1.293 카탈로그는 `rejects_disabled_thinking`, 2026-10-07 출시). Opus 5 는
64
- // disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
65
- // 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
66
- // tool/XML 출력 오염도 피한다.
61
+ // Preserve these defaults when thinking is omitted. This is not an off-capability list:
62
+ // explicit false supports Opus 5, and native extra-body delivery supports Haiku 5.5
63
+ // (disabled) and Sonnet 5.5 (between_tools). See anthropic-thinking.ts.
67
64
  const ADAPTIVE_THINKING_MODELS = new Set([
68
65
  "claude-fable-5-1",
69
66
  "claude-fable-5",
@@ -123,6 +123,32 @@ describe("AnthropicDispatcher", () => {
123
123
  it("토큰 없으면 에러", async () => {
124
124
  const d = new AnthropicDispatcher();
125
125
  await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
126
+ expect(d.inFlight).toBe(0);
127
+ });
128
+
129
+ it.each([false, true])("releases the in-flight count after a session (fails=%s)", async (fails) => {
130
+ const d = new AnthropicDispatcher();
131
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
132
+ runClaudeSessionMock.mockImplementationOnce(async () => {
133
+ expect(d.inFlight).toBe(1);
134
+ if (fails) throw new Error("session failed");
135
+ return sessionResult();
136
+ });
137
+ if (fails) await expect(d.generate(baseReq())).rejects.toThrow("session failed");
138
+ else await expect(d.generate(baseReq())).resolves.toMatchObject({text: "hello"});
139
+ expect(d.inFlight).toBe(0);
140
+ });
141
+
142
+ it("rejects unsupported thinking:false before selecting a token or spawning", async () => {
143
+ const d = new AnthropicDispatcher();
144
+ const req = baseReq({model: "anthropic/claude-fable-5-1", thinking: false});
145
+ await expect(d.generate(req)).rejects.toMatchObject({statusCode: 400});
146
+ const cb = {onDelta: vi.fn(), onComplete: vi.fn(), onError: vi.fn()};
147
+ await d.generateStream(req, cb);
148
+ expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({statusCode: 400}));
149
+ expect(cb.onComplete).not.toHaveBeenCalled();
150
+ expect(runClaudeSessionMock).not.toHaveBeenCalled();
151
+ expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
126
152
  });
127
153
 
128
154
  it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {