@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.
- package/bundle/dist/application/qgrid/conv-routing.js +2 -6
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
- 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 +35 -18
- 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 +110 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
- 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 +107 -19
- 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,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
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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[]> {
|