@cartanova/qgrid-cli 2.2.1 → 2.3.2

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 (35) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +2 -6
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
  3. package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
  4. package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
  5. package/bundle/dist/application/request-log/request-log.model.js +54 -8
  6. package/bundle/dist/sonamu.config.js +11 -2
  7. package/bundle/dist/testing/global.js +5 -2
  8. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
  9. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
  10. package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
  11. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
  12. package/bundle/dist/utils/providers/common/model-cost.js +35 -18
  13. package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
  14. package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
  15. package/bundle/src/application/qgrid/conv-routing.ts +3 -2
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +110 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
  18. package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
  19. package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
  20. package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
  21. package/bundle/src/application/request-log/request-log.model.ts +98 -13
  22. package/bundle/src/sonamu.config.ts +13 -2
  23. package/bundle/src/testing/global.ts +16 -2
  24. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
  25. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
  26. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
  27. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
  28. package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
  29. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
  30. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
  31. package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
  32. package/bundle/src/utils/providers/common/model-cost.ts +107 -19
  33. package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
  34. package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
  35. package/package.json +1 -1
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ type GenerateRequest,
5
+ type GenerateResult,
6
+ } from "../../utils/providers/common/provider-dispatcher";
7
+ import { buildStrictOutputSchema, QgridDispatcherClass } from "./qgrid.dispatcher";
8
+
9
+ describe("QgridDispatcherClass", () => {
10
+ it("jsonSchema 를 required + additionalProperties:false 로 strictify 한다", () => {
11
+ const schema = buildStrictOutputSchema({
12
+ jsonSchema: JSON.stringify({
13
+ type: "object",
14
+ properties: {
15
+ contents: {
16
+ type: "array",
17
+ items: {
18
+ type: "object",
19
+ properties: { text: { type: "string" } },
20
+ },
21
+ },
22
+ },
23
+ }),
24
+ }) as {
25
+ required?: string[];
26
+ additionalProperties?: boolean;
27
+ properties?: {
28
+ contents?: {
29
+ items?: { required?: string[]; additionalProperties?: boolean };
30
+ };
31
+ };
32
+ };
33
+
34
+ expect(schema.required).toEqual(["contents"]);
35
+ expect(schema.additionalProperties).toBe(false);
36
+ const contentsArray = (
37
+ schema.properties?.contents as {
38
+ anyOf?: Array<{ items?: { required?: string[]; additionalProperties?: boolean } }>;
39
+ }
40
+ ).anyOf?.[0];
41
+ expect(contentsArray?.items?.required).toEqual(["text"]);
42
+ expect(contentsArray?.items?.additionalProperties).toBe(false);
43
+ });
44
+
45
+ it("Anthropic provider 경로에 strictified outputSchema 를 전달한다", async () => {
46
+ const dispatcher = new QgridDispatcherClass();
47
+ const generate = vi.fn(
48
+ async (_req: GenerateRequest): Promise<GenerateResult> => ({
49
+ text: '{"contents":[{"text":"ok"}]}',
50
+ tokenName: "anthropic/test",
51
+ usage: {
52
+ totalTokens: 10,
53
+ inputTokens: 5,
54
+ cachedInputTokens: 0,
55
+ outputTokens: 5,
56
+ reasoningOutputTokens: 0,
57
+ },
58
+ durationMs: 12,
59
+ costUsd: 0.01,
60
+ model: "claude-sonnet-4-6",
61
+ threadCoord: { workerId: 1, threadId: "sess-1", epoch: 0 },
62
+ }),
63
+ );
64
+ dispatcher.anthropicDispatcher = { generate } as never;
65
+
66
+ await dispatcher.query({
67
+ prompt: "hi",
68
+ model: "anthropic/claude-sonnet-4-6",
69
+ jsonSchema: JSON.stringify({
70
+ type: "object",
71
+ properties: { contents: { type: "array", items: { type: "object" } } },
72
+ }),
73
+ });
74
+
75
+ const outputSchema = generate.mock.calls[0]![0].outputSchema as {
76
+ required?: string[];
77
+ additionalProperties?: boolean;
78
+ };
79
+ expect(outputSchema.required).toEqual(["contents"]);
80
+ expect(outputSchema.additionalProperties).toBe(false);
81
+ });
82
+
83
+ it("provider 가 산출한 costUsd 를 가격표 fallback 보다 우선한다", async () => {
84
+ const dispatcher = new QgridDispatcherClass();
85
+ const generate = vi.fn(
86
+ async (_req: GenerateRequest): Promise<GenerateResult> => ({
87
+ text: "ok",
88
+ tokenName: "anthropic/test",
89
+ usage: {
90
+ totalTokens: 1_110,
91
+ inputTokens: 1_000,
92
+ cachedInputTokens: 0,
93
+ cacheCreationInputTokens: 600,
94
+ outputTokens: 110,
95
+ reasoningOutputTokens: 0,
96
+ },
97
+ durationMs: 12,
98
+ costUsd: 0.123456,
99
+ model: "claude-sonnet-4-6",
100
+ threadCoord: { workerId: 1, threadId: "sess-1", epoch: 0 },
101
+ }),
102
+ );
103
+ dispatcher.anthropicDispatcher = { generate } as never;
104
+
105
+ const result = await dispatcher.query({ prompt: "hi", model: "claude-sonnet-4-6" });
106
+
107
+ expect(result.costUsd).toBe(0.123456);
108
+ expect(result.usage.cache_creation_input_tokens).toBe(600);
109
+ });
110
+ });
@@ -17,12 +17,15 @@ import { mkdirSync, writeFileSync } from "node:fs";
17
17
  import { getLogger } from "@logtape/logtape";
18
18
 
19
19
  import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
20
+ import { canonicalAnthropicModel } from "../../utils/providers/anthropic/anthropic-constants";
21
+ import { type AnthropicDispatcher } from "../../utils/providers/anthropic/anthropic-dispatcher";
20
22
  import {
21
23
  getAccessToken,
22
24
  getExpiresAt,
23
25
  getRefreshToken,
24
26
  } from "../../utils/providers/common/credentials";
25
27
  import { calculateCostUsd } from "../../utils/providers/common/model-cost";
28
+ import { type GenerateResult } from "../../utils/providers/common/provider-dispatcher";
26
29
  import { strictify } from "../../utils/providers/common/strictifier";
27
30
  import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
28
31
  import { type TokenSubsetA } from "../sonamu.generated";
@@ -55,6 +58,20 @@ const QGRID_CLAUDE_SETTINGS = {
55
58
  // 토큰 만료 임박 임계값 — token.expiredAt이 1분 안에 만료된다면 query 시 체크하고 refresh.
56
59
  const REFRESH_SAFETY_MS = 60_000;
57
60
 
61
+ export function buildStrictOutputSchema(
62
+ input: Pick<QueryInput, "tools" | "jsonSchema">,
63
+ ): JsonValue | undefined {
64
+ const outputSchema = input.tools?.length
65
+ ? buildToolCallSchema(input.tools)
66
+ : input.jsonSchema
67
+ ? (JSON.parse(input.jsonSchema) as JsonValue)
68
+ : undefined;
69
+
70
+ return outputSchema
71
+ ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
72
+ : undefined;
73
+ }
74
+
58
75
  export class QgridDispatcherClass {
59
76
  tokens = new Map<number, TokenSubsetA>();
60
77
 
@@ -65,6 +82,7 @@ export class QgridDispatcherClass {
65
82
  // sonamu.config onStart 에서 처리하는 변수
66
83
  subscriber: TokenSubscriber | null = null;
67
84
  openaiDispatcher: OpenAIDispatcher | null = null;
85
+ anthropicDispatcher: AnthropicDispatcher | null = null;
68
86
 
69
87
  constructor() {
70
88
  const settingsJson = JSON.stringify(QGRID_CLAUDE_SETTINGS, null, 2);
@@ -119,11 +137,7 @@ export class QgridDispatcherClass {
119
137
  throw new ProcessError("tools and jsonSchema cannot be used together");
120
138
  }
121
139
 
122
- const outputSchema = input.tools?.length
123
- ? buildToolCallSchema(input.tools)
124
- : input.jsonSchema
125
- ? (JSON.parse(input.jsonSchema) as JsonValue)
126
- : undefined;
140
+ const outputSchema = buildStrictOutputSchema(input);
127
141
 
128
142
  // provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
129
143
  if (input.model?.includes("/")) {
@@ -135,9 +149,7 @@ export class QgridDispatcherClass {
135
149
  const result = await this.openaiDispatcher.generate({
136
150
  model: model,
137
151
  systemPrompt: input.system,
138
- outputSchema: outputSchema
139
- ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
140
- : undefined,
152
+ outputSchema,
141
153
  effort: input.effort,
142
154
  verbosity: input.verbosity,
143
155
  reasoningSummary: input.reasoningSummary,
@@ -149,36 +161,35 @@ export class QgridDispatcherClass {
149
161
  });
150
162
 
151
163
  const issuedCoord = issueConvContext(result.threadCoord, decision);
152
- return applyToolCallEmulation(
153
- {
154
- text: result.text,
155
- tokenName: result.tokenName,
156
- model: result.model,
157
- usage: {
158
- input_tokens: result.usage.inputTokens,
159
- output_tokens: result.usage.outputTokens,
160
- cache_creation_input_tokens: 0,
161
- cache_read_input_tokens: result.usage.cachedInputTokens,
162
- },
163
- durationMs: result.durationMs,
164
- costUsd: calculateCostUsd(result.model, {
165
- inputTokens: result.usage.inputTokens,
166
- outputTokens: result.usage.outputTokens,
167
- cachedInputTokens: result.usage.cachedInputTokens,
168
- }),
169
- },
170
- input.tools,
171
- issuedCoord,
172
- );
164
+ return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
173
165
  }
174
166
  }
175
167
 
176
- const executionInput =
177
- outputSchema && input.tools?.length
178
- ? { ...input, jsonSchema: JSON.stringify(outputSchema) }
179
- : input;
168
+ // Anthropic — AnthropicDispatcher(멀티턴 session resume) 경로. OpenAI 경로와 동형.
169
+ if (this.anthropicDispatcher) {
170
+ const decision = decideConvRouting(input);
171
+ const result = await this.anthropicDispatcher.generate({
172
+ // model 미지정이면 AnthropicDispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용 — "sonnet" 별칭을
173
+ // 강제로 끼워 dispatcher default 를 죽이지 않는다. prefix 정규화도 dispatcher 내부에서.
174
+ model: input.model,
175
+ systemPrompt: input.system,
176
+ outputSchema,
177
+ effort: input.effort,
178
+ coldInput: decision.coldInput,
179
+ coldHistory: decision.coldHistory,
180
+ reuse: decision.reuse,
181
+ reuseInput: decision.reuseInput,
182
+ });
183
+
184
+ const issuedCoord = issueConvContext(result.threadCoord, decision);
185
+ return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
186
+ }
187
+
188
+ // 폴백: AnthropicDispatcher 미초기화 시 기존 stateless claude -p 경로(멀티턴 없음).
189
+ const executionInput = outputSchema
190
+ ? { ...input, jsonSchema: JSON.stringify(outputSchema) }
191
+ : input;
180
192
 
181
- // Anthropic (기존 claude -p 경로)
182
193
  const electedToken = this.selectToken();
183
194
  if (!electedToken) throw new QuotaError("No tokens available");
184
195
 
@@ -205,7 +216,8 @@ export class QgridDispatcherClass {
205
216
 
206
217
  const result = await executeClaude(executionInput, token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
207
218
  return applyToolCallEmulation(
208
- { ...result, tokenName: electedToken.name, model: input.model ?? DEFAULT_MODEL },
219
+ // model canonical fallback cost/표기가 main 경로와 일치하게.
220
+ { ...result, tokenName: electedToken.name, model: canonicalAnthropicModel(input.model) },
209
221
  input.tools,
210
222
  );
211
223
  }
@@ -220,7 +232,38 @@ export class QgridDispatcherClass {
220
232
  onTurnId?: (turnId: string) => void;
221
233
  },
222
234
  ): Promise<void> {
235
+ // Anthropic 스트리밍: openai/ prefix 가 아니면 anthropic 경로.
223
236
  if (!input.model?.startsWith("openai/")) {
237
+ // AnthropicDispatcher 가 있으면 실제 delta 스트리밍. 없으면 기존 query() 폴백(delta 없음).
238
+ if (this.anthropicDispatcher) {
239
+ const outputSchema = buildStrictOutputSchema(input);
240
+ const decision = decideConvRouting(input);
241
+ await this.anthropicDispatcher.generateStream(
242
+ {
243
+ // model 미지정 시 dispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용. prefix 정규화도 내부에서.
244
+ model: input.model,
245
+ systemPrompt: input.system,
246
+ outputSchema,
247
+ effort: input.effort,
248
+ coldInput: decision.coldInput,
249
+ coldHistory: decision.coldHistory,
250
+ reuse: decision.reuse,
251
+ reuseInput: decision.reuseInput,
252
+ },
253
+ {
254
+ onDelta: cb.onDelta,
255
+ onThreadId: cb.onThreadId,
256
+ onComplete: (turnResult) => {
257
+ const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
258
+ cb.onComplete(
259
+ applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
260
+ );
261
+ },
262
+ onError: cb.onError,
263
+ },
264
+ );
265
+ return;
266
+ }
224
267
  const result = await this.query(input);
225
268
  cb.onComplete(result);
226
269
  return;
@@ -230,20 +273,14 @@ export class QgridDispatcherClass {
230
273
  if (!model) throw new ProcessError("unknown model");
231
274
  if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
232
275
 
233
- const outputSchema = input.tools?.length
234
- ? buildToolCallSchema(input.tools)
235
- : input.jsonSchema
236
- ? (JSON.parse(input.jsonSchema) as JsonValue)
237
- : undefined;
276
+ const outputSchema = buildStrictOutputSchema(input);
238
277
 
239
278
  const decision = decideConvRouting(input);
240
279
  await this.openaiDispatcher.generateStream(
241
280
  {
242
281
  model,
243
282
  systemPrompt: input.system,
244
- outputSchema: outputSchema
245
- ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
246
- : undefined,
283
+ outputSchema,
247
284
  effort: input.effort,
248
285
  verbosity: input.verbosity,
249
286
  reasoningSummary: input.reasoningSummary,
@@ -259,28 +296,9 @@ export class QgridDispatcherClass {
259
296
  onTurnId: cb.onTurnId,
260
297
  onComplete: (turnResult) => {
261
298
  const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
262
- const applied = applyToolCallEmulation(
263
- {
264
- text: turnResult.text,
265
- tokenName: turnResult.tokenName,
266
- model: turnResult.model,
267
- usage: {
268
- input_tokens: turnResult.usage.inputTokens,
269
- output_tokens: turnResult.usage.outputTokens,
270
- cache_creation_input_tokens: 0,
271
- cache_read_input_tokens: turnResult.usage.cachedInputTokens,
272
- },
273
- durationMs: turnResult.durationMs,
274
- costUsd: calculateCostUsd(turnResult.model, {
275
- inputTokens: turnResult.usage.inputTokens,
276
- outputTokens: turnResult.usage.outputTokens,
277
- cachedInputTokens: turnResult.usage.cachedInputTokens,
278
- }),
279
- },
280
- input.tools,
281
- issuedCoord,
299
+ cb.onComplete(
300
+ applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
282
301
  );
283
- cb.onComplete(applied);
284
302
  },
285
303
  onError: cb.onError,
286
304
  },
@@ -288,6 +306,36 @@ export class QgridDispatcherClass {
288
306
  }
289
307
  }
290
308
 
309
+ // GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
310
+ // OpenAI/Anthropic, query/queryStream 4 경로가 동일하게 쓴다.
311
+ // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
312
+ // per-request cost 는 Claude Code 가 준 total_cost_usd 를 우선 사용하고 fallback 계산만 공통 cost 함수를 쓴다.
313
+ function toEmulationResult(
314
+ result: GenerateResult,
315
+ ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
316
+ return {
317
+ text: result.text,
318
+ tokenName: result.tokenName,
319
+ model: result.model,
320
+ usage: {
321
+ input_tokens: result.usage.inputTokens,
322
+ output_tokens: result.usage.outputTokens,
323
+ cache_creation_input_tokens: result.usage.cacheCreationInputTokens ?? 0,
324
+ cache_read_input_tokens: result.usage.cachedInputTokens,
325
+ },
326
+ durationMs: result.durationMs,
327
+ costUsd:
328
+ result.costUsd !== undefined && result.costUsd > 0
329
+ ? result.costUsd
330
+ : calculateCostUsd(result.model, {
331
+ inputTokens: result.usage.inputTokens,
332
+ outputTokens: result.usage.outputTokens,
333
+ cachedInputTokens: result.usage.cachedInputTokens,
334
+ cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
335
+ }),
336
+ };
337
+ }
338
+
291
339
  async function executeClaude(
292
340
  input: QueryInput,
293
341
  token: string,
@@ -3,7 +3,7 @@ import { getLogger } from "@logtape/logtape";
3
3
  import { Client, type ClientConfig } from "pg";
4
4
 
5
5
  import { TokenModel } from "../token/token.model";
6
- import { type OpenAICredentials } from "../token/token.types";
6
+ import { type AnthropicCredentials, type OpenAICredentials } from "../token/token.types";
7
7
  import { type QgridDispatcherClass } from "./qgrid.dispatcher";
8
8
  import { type SubscriberStatus } from "./qgrid.types";
9
9
 
@@ -157,6 +157,8 @@ export class TokenSubscriber {
157
157
  this.dispatcher.openaiDispatcher
158
158
  ?.onTokenRemoved(payload.id)
159
159
  .catch((e) => logger.warn(`openai worker remove failed: ${(e as Error).message}`));
160
+ // anthropic 토큰 이벤트는 동기(void) — provider 를 모르므로 무해하게 항상 제거 시도.
161
+ this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
160
162
  logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
161
163
  return;
162
164
  }
@@ -166,6 +168,7 @@ export class TokenSubscriber {
166
168
  this.dispatcher.openaiDispatcher
167
169
  ?.onTokenRemoved(payload.id)
168
170
  .catch((e) => logger.warn(`openai worker remove failed: ${(e as Error).message}`));
171
+ this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
169
172
  logger.info(`NOTIFY ${payload.op} id=${payload.id} → missing, removed from cache`);
170
173
  return;
171
174
  }
@@ -186,6 +189,19 @@ export class TokenSubscriber {
186
189
  } else {
187
190
  this.dispatcher.openaiDispatcher?.onTokenDeactivated(payload.id);
188
191
  }
192
+ } else if (row.provider === "anthropic") {
193
+ // anthropic 토큰 이벤트는 동기(void). worker 가 없고 풀(Map)만 관리하므로 단순:
194
+ // INSERT → 추가, active 면 갱신(onTokenUpdated 가 identity 변경도 처리), inactive 면 풀에서 제거.
195
+ // (별도 activate/deactivate 콜백이 없어 active 토글을 add/remove 로 매핑.)
196
+ const creds = row.credentials as AnthropicCredentials;
197
+ if (payload.op === "INSERT" && row.active) {
198
+ this.dispatcher.anthropicDispatcher?.onTokenAdded(payload.id, row.name, creds);
199
+ } else if (row.active) {
200
+ this.dispatcher.anthropicDispatcher?.onTokenUpdated(payload.id, row.name, creds);
201
+ } else {
202
+ // inactive 면 INSERT 든 UPDATE 든 풀에 넣지 않는다.
203
+ this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
204
+ }
189
205
  }
190
206
  logger.info(`NOTIFY ${payload.op} id=${payload.id} (${row.name}) active=${row.active}`);
191
207
  }
@@ -193,6 +209,12 @@ export class TokenSubscriber {
193
209
  async reconcile(): Promise<void> {
194
210
  const rows = await TokenModel.findActive("A");
195
211
  this.dispatcher.replaceCache(rows);
212
+ // NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
213
+ // (rows 는 active 만 — inactive/삭제된 토큰은 여기 없으므로 replaceTokens 가 풀에서 제거한다.)
214
+ const anthropicRows = rows
215
+ .filter((r) => r.provider === "anthropic")
216
+ .map((r) => ({ id: r.id, name: r.name, credentials: r.credentials as AnthropicCredentials }));
217
+ this.dispatcher.anthropicDispatcher?.replaceTokens(anthropicRows);
196
218
  this.lastReconcileAt = new Date();
197
219
  }
198
220
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildToolCallSchema } from "./tool-emulation";
4
+ import { type QgridTool } from "./qgrid.types";
5
+
6
+ describe("buildToolCallSchema", () => {
7
+ it("documents that emulated tools must be requested through StructuredOutput", () => {
8
+ const tools: QgridTool[] = [
9
+ {
10
+ name: "getWeather",
11
+ description: "Get weather for a city",
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: { city: { type: "string" } },
15
+ required: ["city"],
16
+ additionalProperties: false,
17
+ },
18
+ },
19
+ ];
20
+
21
+ const schema = buildToolCallSchema(tools) as {
22
+ description?: string;
23
+ properties?: {
24
+ action?: { description?: string };
25
+ toolCalls?: {
26
+ description?: string;
27
+ anyOf?: Array<{
28
+ items?: {
29
+ properties?: {
30
+ toolName?: { description?: string };
31
+ };
32
+ };
33
+ }>;
34
+ };
35
+ };
36
+ };
37
+
38
+ expect(schema.description).toContain("StructuredOutput");
39
+ expect(schema.description).toContain("Do not invoke listed client tools as native Claude Code tools");
40
+ expect(schema.properties?.action?.description).toContain("tool_call");
41
+ expect(schema.properties?.toolCalls?.description).toContain("Do not call these tools as native");
42
+ expect(
43
+ schema.properties?.toolCalls?.anyOf?.[0]?.items?.properties?.toolName?.description,
44
+ ).toContain("getWeather");
45
+ });
46
+ });
@@ -26,10 +26,19 @@ export function buildToolCallSchema(tools: QgridTool[]): JsonValue {
26
26
 
27
27
  return {
28
28
  type: "object",
29
+ description:
30
+ "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set action to tool_call and include toolCalls. Use action answer only when no further client tool result is needed.",
29
31
  properties: {
30
- action: { type: "string", enum: ["answer", "tool_call"] },
32
+ action: {
33
+ type: "string",
34
+ enum: ["answer", "tool_call"],
35
+ description:
36
+ "Use tool_call to request client-side tool execution through this structured output. Use answer only for a final answer.",
37
+ },
31
38
  answer: { anyOf: [{ type: "string" }, { type: "null" }] },
32
39
  toolCalls: {
40
+ description:
41
+ "Client-side tool calls requested through structured output. Do not call these tools as native Claude Code tools.",
33
42
  anyOf: [
34
43
  {
35
44
  type: "array",
@@ -39,9 +48,12 @@ export function buildToolCallSchema(tools: QgridTool[]): JsonValue {
39
48
  toolName: {
40
49
  type: "string",
41
50
  enum: tools.map((tool) => tool.name),
42
- description: toolDescriptions,
51
+ description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
52
+ },
53
+ args: {
54
+ type: "string",
55
+ description: "Tool arguments as a JSON string for the client-side tool.",
43
56
  },
44
- args: { type: "string", description: "Tool arguments as JSON string" },
45
57
  },
46
58
  required: ["toolName", "args"],
47
59
  additionalProperties: false,
@@ -17,6 +17,59 @@ import { type RequestLogListParams, type RequestLogSaveParams } from "./request-
17
17
  // cost_usd는 정수 micro-USD로 저장. 실제 USD = cost_usd / MICRO_USD.
18
18
  export const MICRO_USD = 1_000_000;
19
19
 
20
+ type RequestLogUsageRow = {
21
+ token_name?: string | null;
22
+ model_name?: string | null;
23
+ input_tokens: number;
24
+ output_tokens: number;
25
+ cache_read_tokens: number;
26
+ cache_creation_tokens: number;
27
+ cost_usd?: number | null;
28
+ };
29
+
30
+ function isAnthropicUsageRow(row: Pick<RequestLogUsageRow, "token_name" | "model_name">): boolean {
31
+ return (
32
+ row.token_name?.startsWith("anthropic/") === true ||
33
+ row.model_name?.startsWith("claude-") === true ||
34
+ row.model_name?.startsWith("anthropic/claude-") === true
35
+ );
36
+ }
37
+
38
+ function canonicalModelName(modelName: string): string {
39
+ return modelName.includes("/") ? modelName.split("/").pop()! : modelName;
40
+ }
41
+
42
+ function normalizedUsageForCost(row: RequestLogUsageRow): {
43
+ model: string | null;
44
+ inputTokens: number;
45
+ outputTokens: number;
46
+ cachedInputTokens: number;
47
+ cacheCreationInputTokens: number;
48
+ } {
49
+ const cacheRead = Number(row.cache_read_tokens ?? 0);
50
+ const cacheCreation = Number(row.cache_creation_tokens ?? 0);
51
+ const storedInput = Number(row.input_tokens ?? 0);
52
+ const legacyAnthropicSplitInput =
53
+ isAnthropicUsageRow(row) && storedInput < cacheRead + cacheCreation;
54
+ return {
55
+ model: row.model_name ? canonicalModelName(row.model_name) : null,
56
+ inputTokens: legacyAnthropicSplitInput ? storedInput + cacheRead + cacheCreation : storedInput,
57
+ outputTokens: Number(row.output_tokens ?? 0),
58
+ cachedInputTokens: cacheRead,
59
+ cacheCreationInputTokens: cacheCreation,
60
+ };
61
+ }
62
+
63
+ function normalizeLegacyAnthropicRow<T extends RequestLogUsageRow>(row: T): T {
64
+ const usage = normalizedUsageForCost(row);
65
+ if (usage.inputTokens === Number(row.input_tokens ?? 0)) return row;
66
+ const normalized = { ...row, input_tokens: usage.inputTokens };
67
+ if (usage.model) {
68
+ normalized.cost_usd = Math.round(calculateCostUsd(usage.model, usage) * MICRO_USD);
69
+ }
70
+ return normalized;
71
+ }
72
+
20
73
  /*
21
74
  RequestLog Model
22
75
  */
@@ -118,10 +171,7 @@ class RequestLogModelClass extends BaseModelClass<
118
171
  }
119
172
 
120
173
  const enhancers = this.createEnhancers({
121
- A: (row) => ({
122
- ...row,
123
- // 서브셋별로 virtual 필드 계산로직 추가
124
- }),
174
+ A: (row) => normalizeLegacyAnthropicRow(row),
125
175
  });
126
176
 
127
177
  return this.executeSubsetQuery({
@@ -238,14 +288,21 @@ class RequestLogModelClass extends BaseModelClass<
238
288
  .where("id", requestLogId)
239
289
  .limit(1);
240
290
  if (row?.model_name) {
241
- const model = row.model_name.includes("/")
242
- ? row.model_name.split("/").pop()!
243
- : row.model_name;
291
+ const model = canonicalModelName(row.model_name);
292
+ const usage = normalizedUsageForCost({
293
+ token_name: params.token_name,
294
+ model_name: row.model_name,
295
+ input_tokens: params.input_tokens,
296
+ output_tokens: params.output_tokens,
297
+ cache_read_tokens: params.cache_read_tokens ?? 0,
298
+ cache_creation_tokens: params.cache_creation_tokens ?? 0,
299
+ });
244
300
  update.cost_usd = Math.round(
245
301
  calculateCostUsd(model, {
246
- inputTokens: params.input_tokens,
247
- outputTokens: params.output_tokens,
248
- cachedInputTokens: params.cache_read_tokens ?? 0,
302
+ inputTokens: usage.inputTokens,
303
+ outputTokens: usage.outputTokens,
304
+ cachedInputTokens: usage.cachedInputTokens,
305
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
249
306
  }) * 1_000_000,
250
307
  );
251
308
  }
@@ -333,9 +390,37 @@ class RequestLogModelClass extends BaseModelClass<
333
390
  if (params.token_name) {
334
391
  qb.where("token_name", params.token_name);
335
392
  }
336
- // knex는 pg에서 numeric aggregate를 string으로 반환.
337
- const row = (await qb.sum({ sum: "cost_usd" }).first()) as { sum: string | null } | undefined;
338
- return Number(row?.sum ?? 0) / MICRO_USD;
393
+ // 기존 cost_usd 에는 과거 Anthropic cache 계산 버그로 음수가 저장된 row 가 있을 수 있다.
394
+ // 화면 집계는 저장값 sum 아니라 현재 계산식으로 usage 재계산해 과거 로그도 즉시 보정한다.
395
+ const rows = (await qb.select(
396
+ "model_name",
397
+ "input_tokens",
398
+ "output_tokens",
399
+ "cache_read_tokens",
400
+ "cache_creation_tokens",
401
+ "cost_usd",
402
+ )) as Array<{
403
+ model_name: string | null;
404
+ input_tokens: number;
405
+ output_tokens: number;
406
+ cache_read_tokens: number;
407
+ cache_creation_tokens: number;
408
+ cost_usd: number | null;
409
+ }>;
410
+
411
+ return rows.reduce((sum, row) => {
412
+ const usage = normalizedUsageForCost(row);
413
+ if (!usage.model) return sum + Math.max(Number(row.cost_usd ?? 0), 0) / MICRO_USD;
414
+ return (
415
+ sum +
416
+ calculateCostUsd(usage.model, {
417
+ inputTokens: usage.inputTokens,
418
+ outputTokens: usage.outputTokens,
419
+ cachedInputTokens: usage.cachedInputTokens,
420
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
421
+ })
422
+ );
423
+ }, 0);
339
424
  }
340
425
 
341
426
  async distinctProjectNames(): Promise<string[]> {