@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.
- package/bundle/dist/application/qgrid/conv-routing.js +2 -6
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +64 -37
- package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
- package/bundle/dist/application/request-log/request-log.model.js +54 -8
- package/bundle/dist/sonamu.config.js +11 -2
- package/bundle/dist/testing/global.js +5 -2
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
- package/bundle/dist/utils/providers/common/model-cost.js +64 -32
- package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
- package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
- package/bundle/src/application/qgrid/conv-routing.ts +3 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +37 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +99 -44
- package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
- package/bundle/src/application/request-log/request-log.model.ts +98 -13
- package/bundle/src/sonamu.config.ts +13 -2
- package/bundle/src/testing/global.ts +16 -2
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
- package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
- package/bundle/src/utils/providers/common/model-cost.ts +159 -25
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
- package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
- 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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
242
|
-
|
|
243
|
-
:
|
|
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:
|
|
247
|
-
outputTokens:
|
|
248
|
-
cachedInputTokens:
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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;
|