@cartanova/qgrid-cli 2.2.0 → 2.3.1

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 +64 -37
  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 +64 -32
  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 +37 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +99 -44
  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 +159 -25
  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,37 @@
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 { QgridDispatcherClass } from "./qgrid.dispatcher";
8
+
9
+ describe("QgridDispatcherClass", () => {
10
+ it("provider 가 산출한 costUsd 를 가격표 fallback 보다 우선한다", async () => {
11
+ const dispatcher = new QgridDispatcherClass();
12
+ const generate = vi.fn(
13
+ async (_req: GenerateRequest): Promise<GenerateResult> => ({
14
+ text: "ok",
15
+ tokenName: "anthropic/test",
16
+ usage: {
17
+ totalTokens: 1_110,
18
+ inputTokens: 1_000,
19
+ cachedInputTokens: 0,
20
+ cacheCreationInputTokens: 600,
21
+ outputTokens: 110,
22
+ reasoningOutputTokens: 0,
23
+ },
24
+ durationMs: 12,
25
+ costUsd: 0.123456,
26
+ model: "claude-sonnet-4-6",
27
+ threadCoord: { workerId: 1, threadId: "sess-1", epoch: 0 },
28
+ }),
29
+ );
30
+ dispatcher.anthropicDispatcher = { generate } as never;
31
+
32
+ const result = await dispatcher.query({ prompt: "hi", model: "claude-sonnet-4-6" });
33
+
34
+ expect(result.costUsd).toBe(0.123456);
35
+ expect(result.usage.cache_creation_input_tokens).toBe(600);
36
+ });
37
+ });
@@ -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";
@@ -65,6 +68,7 @@ export class QgridDispatcherClass {
65
68
  // sonamu.config onStart 에서 처리하는 변수
66
69
  subscriber: TokenSubscriber | null = null;
67
70
  openaiDispatcher: OpenAIDispatcher | null = null;
71
+ anthropicDispatcher: AnthropicDispatcher | null = null;
68
72
 
69
73
  constructor() {
70
74
  const settingsJson = JSON.stringify(QGRID_CLAUDE_SETTINGS, null, 2);
@@ -149,36 +153,38 @@ export class QgridDispatcherClass {
149
153
  });
150
154
 
151
155
  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
- );
156
+ return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
173
157
  }
174
158
  }
175
159
 
160
+ // Anthropic — AnthropicDispatcher(멀티턴 session resume) 경로. OpenAI 경로와 동형.
161
+ if (this.anthropicDispatcher) {
162
+ const decision = decideConvRouting(input);
163
+ const result = await this.anthropicDispatcher.generate({
164
+ // model 미지정이면 AnthropicDispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용 — "sonnet" 별칭을
165
+ // 강제로 끼워 dispatcher default 를 죽이지 않는다. prefix 정규화도 dispatcher 내부에서.
166
+ model: input.model,
167
+ systemPrompt: input.system,
168
+ outputSchema: outputSchema
169
+ ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
170
+ : undefined,
171
+ effort: input.effort,
172
+ coldInput: decision.coldInput,
173
+ coldHistory: decision.coldHistory,
174
+ reuse: decision.reuse,
175
+ reuseInput: decision.reuseInput,
176
+ });
177
+
178
+ const issuedCoord = issueConvContext(result.threadCoord, decision);
179
+ return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
180
+ }
181
+
182
+ // 폴백: AnthropicDispatcher 미초기화 시 기존 stateless claude -p 경로(멀티턴 없음).
176
183
  const executionInput =
177
184
  outputSchema && input.tools?.length
178
185
  ? { ...input, jsonSchema: JSON.stringify(outputSchema) }
179
186
  : input;
180
187
 
181
- // Anthropic (기존 claude -p 경로)
182
188
  const electedToken = this.selectToken();
183
189
  if (!electedToken) throw new QuotaError("No tokens available");
184
190
 
@@ -205,7 +211,8 @@ export class QgridDispatcherClass {
205
211
 
206
212
  const result = await executeClaude(executionInput, token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
207
213
  return applyToolCallEmulation(
208
- { ...result, tokenName: electedToken.name, model: input.model ?? DEFAULT_MODEL },
214
+ // model canonical fallback cost/표기가 main 경로와 일치하게.
215
+ { ...result, tokenName: electedToken.name, model: canonicalAnthropicModel(input.model) },
209
216
  input.tools,
210
217
  );
211
218
  }
@@ -220,7 +227,44 @@ export class QgridDispatcherClass {
220
227
  onTurnId?: (turnId: string) => void;
221
228
  },
222
229
  ): Promise<void> {
230
+ // Anthropic 스트리밍: openai/ prefix 가 아니면 anthropic 경로.
223
231
  if (!input.model?.startsWith("openai/")) {
232
+ // AnthropicDispatcher 가 있으면 실제 delta 스트리밍. 없으면 기존 query() 폴백(delta 없음).
233
+ if (this.anthropicDispatcher) {
234
+ const outputSchema = input.tools?.length
235
+ ? buildToolCallSchema(input.tools)
236
+ : input.jsonSchema
237
+ ? (JSON.parse(input.jsonSchema) as JsonValue)
238
+ : undefined;
239
+ const decision = decideConvRouting(input);
240
+ await this.anthropicDispatcher.generateStream(
241
+ {
242
+ // model 미지정 시 dispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용. prefix 정규화도 내부에서.
243
+ model: input.model,
244
+ systemPrompt: input.system,
245
+ outputSchema: outputSchema
246
+ ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
247
+ : undefined,
248
+ effort: input.effort,
249
+ coldInput: decision.coldInput,
250
+ coldHistory: decision.coldHistory,
251
+ reuse: decision.reuse,
252
+ reuseInput: decision.reuseInput,
253
+ },
254
+ {
255
+ onDelta: cb.onDelta,
256
+ onThreadId: cb.onThreadId,
257
+ onComplete: (turnResult) => {
258
+ const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
259
+ cb.onComplete(
260
+ applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
261
+ );
262
+ },
263
+ onError: cb.onError,
264
+ },
265
+ );
266
+ return;
267
+ }
224
268
  const result = await this.query(input);
225
269
  cb.onComplete(result);
226
270
  return;
@@ -259,28 +303,9 @@ export class QgridDispatcherClass {
259
303
  onTurnId: cb.onTurnId,
260
304
  onComplete: (turnResult) => {
261
305
  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,
306
+ cb.onComplete(
307
+ applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
282
308
  );
283
- cb.onComplete(applied);
284
309
  },
285
310
  onError: cb.onError,
286
311
  },
@@ -288,6 +313,36 @@ export class QgridDispatcherClass {
288
313
  }
289
314
  }
290
315
 
316
+ // GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
317
+ // OpenAI/Anthropic, query/queryStream 4 경로가 동일하게 쓴다.
318
+ // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
319
+ // per-request cost 는 Claude Code 가 준 total_cost_usd 를 우선 사용하고 fallback 계산만 공통 cost 함수를 쓴다.
320
+ function toEmulationResult(
321
+ result: GenerateResult,
322
+ ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
323
+ return {
324
+ text: result.text,
325
+ tokenName: result.tokenName,
326
+ model: result.model,
327
+ usage: {
328
+ input_tokens: result.usage.inputTokens,
329
+ output_tokens: result.usage.outputTokens,
330
+ cache_creation_input_tokens: result.usage.cacheCreationInputTokens ?? 0,
331
+ cache_read_input_tokens: result.usage.cachedInputTokens,
332
+ },
333
+ durationMs: result.durationMs,
334
+ costUsd:
335
+ result.costUsd !== undefined && result.costUsd > 0
336
+ ? result.costUsd
337
+ : calculateCostUsd(result.model, {
338
+ inputTokens: result.usage.inputTokens,
339
+ outputTokens: result.usage.outputTokens,
340
+ cachedInputTokens: result.usage.cachedInputTokens,
341
+ cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
342
+ }),
343
+ };
344
+ }
345
+
291
346
  async function executeClaude(
292
347
  input: QueryInput,
293
348
  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[]> {
@@ -12,6 +12,7 @@ import { QgridFrame } from "./application/qgrid/qgrid.frame";
12
12
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
13
13
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
14
14
  import { TokenModel } from "./application/token/token.model";
15
+ import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
15
16
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
16
17
 
17
18
  dotenv.config({ path: path.join(import.meta.dirname, "../.env") });
@@ -202,8 +203,15 @@ export default defineConfig({
202
203
  log.warn(`openai dispatcher failed: ${(e as Error).message}`);
203
204
  }
204
205
 
205
- const allTokens = [...QgridDispatcher.tokens.values()];
206
- const anthropicCount = allTokens.filter((t) => t.provider === "anthropic").length;
206
+ try {
207
+ const anthropicDispatcher = new AnthropicDispatcher();
208
+ await anthropicDispatcher.start();
209
+ QgridDispatcher.anthropicDispatcher = anthropicDispatcher;
210
+ } catch (e) {
211
+ log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
212
+ }
213
+
214
+ const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
207
215
  const openaiReady = QgridDispatcher.openaiDispatcher?.readyWorkerCount ?? 0;
208
216
  const openaiTotal = QgridDispatcher.openaiDispatcher?.workerCount ?? 0;
209
217
 
@@ -219,6 +227,9 @@ export default defineConfig({
219
227
  if (QgridDispatcher.openaiDispatcher) {
220
228
  await QgridDispatcher.openaiDispatcher.stop();
221
229
  }
230
+ if (QgridDispatcher.anthropicDispatcher) {
231
+ await QgridDispatcher.anthropicDispatcher.stop();
232
+ }
222
233
  if (QgridDispatcher.subscriber) {
223
234
  await QgridDispatcher.subscriber.stop();
224
235
  }
@@ -2,5 +2,19 @@ import dotenv from "dotenv";
2
2
 
3
3
  dotenv.config();
4
4
 
5
- // sonamu.config.ts의 test 설정을 기반으로 병렬 테스트 환경을 구성합니다.
6
- export { setup } from "sonamu/test";
5
+ type SonamuGlobalSetupModule = {
6
+ setup: () => Promise<(() => Promise<void>) | void>;
7
+ };
8
+
9
+ // sonamu/test 의 barrel export 는 bootstrap.js 를 함께 로드하고, bootstrap.js 는 top-level 에서
10
+ // vitest 를 import 한다. Vitest 4 globalSetup 컨텍스트에서는 이 import 가 internal state 에러를
11
+ // 만들기 때문에 global-setup.js 만 직접 로드한다.
12
+ export async function setup(): Promise<(() => Promise<void>) | void> {
13
+ const globalSetupUrl = new URL(
14
+ "../../node_modules/sonamu/dist/testing/global-setup.js",
15
+ import.meta.url,
16
+ );
17
+ const { setup: sonamuSetup } = (await import(globalSetupUrl.href)) as SonamuGlobalSetupModule;
18
+
19
+ return sonamuSetup();
20
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Anthropic(Claude) provider 상수.
3
+ *
4
+ * 기존 qgrid.dispatcher.ts 의 executeClaude 플래그/env 와 정합을 맞추되, 멀티턴용으로 조정한다:
5
+ * - `--no-session-persistence` 제거 (멀티턴 필수).
6
+ * - `--input-format stream-json` 추가 (stdin 으로 입력 어댑터 JSONL 흘림).
7
+ * - `--session-id` / `--resume` 로 세션 소유.
8
+ */
9
+
10
+ // CC --model 플래그가 받는 official model id (alias "sonnet" 가 아니라 정확한 식별자로 고정).
11
+ // claude-api 스킬 확인: Sonnet 4.6 official id = "claude-sonnet-4-6" (날짜 suffix 없음).
12
+ // model-cost 테이블에도 "claude-sonnet-4-6" 키 존재. (owner 지시)
13
+ export const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-6";
14
+ export const ANTHROPIC_DEFAULT_EFFORT = "high";
15
+
16
+ // model id 를 canonical(provider prefix 없는 CLI/cost 키) 로 정규화한다.
17
+ // ai-sdk 는 'anthropic/claude-*' 형태로 보내는데, prefix 가 compatibilityKey/calculateCostUsd 에
18
+ // 새면 호환키가 갈리거나 cost 가 default 단가로 오계산된다. 미지정이면 default.
19
+ export function canonicalAnthropicModel(model?: string): string {
20
+ const raw = model || ANTHROPIC_DEFAULT_MODEL;
21
+ return raw.includes("/") ? raw.split("/").pop()! : raw;
22
+ }
23
+
24
+ // project scope cwd (settings.json 격리). 토큰별 격리는 CLAUDE_CONFIG_DIR 로 한다.
25
+ export const ANTHROPIC_CLAUDE_CWD = "/tmp/qgrid-anthropic";
26
+
27
+ // per-token CLAUDE_CONFIG_DIR 의 베이스. 실제 경로는 `${BASE}/${tokenId}` (R10 격리).
28
+ export const ANTHROPIC_CONFIG_DIR_BASE = "/tmp/qgrid-anthropic-config";
29
+
30
+ // 토큰별 CLAUDE_CONFIG_DIR 경로 규칙(R10 격리 계약). tokenId 별로 분리되어, 같은 claude session-id
31
+ // 라도 다른 토큰이면 다른 config dir → transcript 가 섞이지 않는다. 부수효과 없는 순수 함수라
32
+ // 격리 계약을 단위 테스트로 고정할 수 있다(U6 R10 구조 검증). 실제 dir 생성은 ensureConfigDir 가 한다.
33
+ export function anthropicConfigDir(tokenId: number): string {
34
+ return `${ANTHROPIC_CONFIG_DIR_BASE}/${tokenId}`;
35
+ }
36
+
37
+ // CC 가 자동 활성화하는 deferred 도구 — 토큰 최적화 위해 차단(기존 executeClaude 와 동일).
38
+ export const ANTHROPIC_DISALLOWED_TOOLS = ["Monitor", "PushNotification", "RemoteTrigger"] as const;