@cartanova/qgrid-cli 2.10.1 → 2.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/monit/log-buffer.js +7 -11
- package/bundle/dist/application/monit/monit.frame.js +6 -11
- package/bundle/dist/application/qgrid/oauth.js +2 -2
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
- package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
- package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
- package/bundle/dist/application/request-log/request-log.model.js +7 -5
- package/bundle/dist/application/setting/setting.model.js +1 -1
- package/bundle/dist/sonamu.config.js +6 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
- package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
- package/bundle/dist/utils/providers/common/effort.js +1 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
- package/bundle/dist/utils/providers/common/thinking.js +12 -0
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
- package/bundle/src/application/monit/log-buffer.test.ts +3 -4
- package/bundle/src/application/monit/log-buffer.ts +13 -27
- package/bundle/src/application/monit/monit.frame.test.ts +7 -8
- package/bundle/src/application/monit/monit.frame.ts +9 -14
- package/bundle/src/application/qgrid/oauth.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
- package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
- package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
- package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
- package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
- package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
- package/bundle/src/application/request-log/request-log.model.ts +5 -5
- package/bundle/src/application/setting/setting.model.ts +1 -1
- package/bundle/src/sonamu.config.ts +5 -5
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
- package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
- package/bundle/src/utils/providers/common/effort.ts +1 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
- package/bundle/src/utils/providers/common/thinking.ts +10 -0
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
- package/bundle/web-dist/client/assets/{index-Cq4_ExLO.js → index-BIC1sQxw.js} +3 -3
- package/bundle/web-dist/client/assets/logs-zvdujZWn.js +1 -0
- package/bundle/web-dist/client/assets/monit-CraVV3MQ.js +1 -0
- package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
- package/bundle/web-dist/client/assets/{routes-CXotE1OF.js → routes-B3ZBoJ60.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-CfJDz4AC.js → settings-DwbzT5jo.js} +1 -1
- package/bundle/web-dist/client/assets/{show-gBpWK4z2.js → show-ItTExdAh.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-CbFJjIyC.js → tokens-BynKx8Vn.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-jJo7GV3r.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{logs-DMlOXcDS.js → logs-BSk9lOHe.js} +13 -3
- package/bundle/web-dist/server/assets/{monit-Dg1wZCdx.js → monit-D9W4ioUi.js} +2 -21
- package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
- package/bundle/web-dist/server/assets/{settings-BaIBt_Vs.js → settings-BFVuldch.js} +1 -1
- package/bundle/web-dist/server/assets/{show-CDxGr4rS.js → show-Cswh_tqs.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-DJlfeGcx.js → tokens-DiTUwUwr.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +6 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -0
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
- package/skills/qgrid/references/decision-rationale.md +9 -0
- package/skills/qgrid/references/openai-codex-runtime.md +6 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +3 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
- package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
- package/skills/qgrid/references/verification-and-debugging.md +1 -1
- package/bundle/web-dist/client/assets/logs-kg1KOMBk.js +0 -1
- package/bundle/web-dist/client/assets/monit-DmtHTmSM.js +0 -1
- /package/bundle/web-dist/client/assets/{plus-CknMNCP5.js → plus-B62CuqT8.js} +0 -0
- /package/bundle/web-dist/client/assets/{request-model-eygfdRv7.js → request-model-D4CZPmnG.js} +0 -0
- /package/bundle/web-dist/server/assets/{plus-DVobMrag.js → plus-CmvbWuDf.js} +0 -0
- /package/bundle/web-dist/server/assets/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
|
@@ -32,8 +32,7 @@ function subscriberWith(openaiDispatcher: Record<string, unknown>) {
|
|
|
32
32
|
return new TokenSubscriber(
|
|
33
33
|
{} as never,
|
|
34
34
|
{
|
|
35
|
-
|
|
36
|
-
upsertCache: vi.fn(),
|
|
35
|
+
tokens: new Map(),
|
|
37
36
|
replaceCache: vi.fn(),
|
|
38
37
|
openaiDispatcher,
|
|
39
38
|
anthropicDispatcher: null,
|
|
@@ -82,8 +81,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
82
81
|
const subscriber = new TokenSubscriber(
|
|
83
82
|
{} as never,
|
|
84
83
|
{
|
|
85
|
-
|
|
86
|
-
upsertCache: vi.fn(),
|
|
84
|
+
tokens: new Map(),
|
|
87
85
|
replaceCache: vi.fn(),
|
|
88
86
|
openaiDispatcher: null,
|
|
89
87
|
anthropicDispatcher,
|
|
@@ -112,15 +110,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
112
110
|
{} as never,
|
|
113
111
|
{
|
|
114
112
|
tokens: new Map(),
|
|
115
|
-
removeCache: vi.fn(),
|
|
116
|
-
upsertCache: vi.fn(),
|
|
117
113
|
replaceCache: vi.fn(),
|
|
118
114
|
openaiDispatcher: null,
|
|
119
115
|
anthropicDispatcher,
|
|
120
116
|
} as never,
|
|
121
117
|
);
|
|
122
118
|
const onTokensChanged = vi.fn();
|
|
123
|
-
subscriber.
|
|
119
|
+
subscriber.tokenChangeHandler = onTokensChanged;
|
|
124
120
|
findOneMock.mockResolvedValueOnce({
|
|
125
121
|
...openaiToken(true),
|
|
126
122
|
provider: "anthropic",
|
|
@@ -143,15 +139,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
143
139
|
{} as never,
|
|
144
140
|
{
|
|
145
141
|
tokens: new Map([[previous.id, previous]]),
|
|
146
|
-
removeCache: vi.fn(),
|
|
147
|
-
upsertCache: vi.fn(),
|
|
148
142
|
replaceCache: vi.fn(),
|
|
149
143
|
openaiDispatcher: null,
|
|
150
144
|
anthropicDispatcher,
|
|
151
145
|
} as never,
|
|
152
146
|
);
|
|
153
147
|
const onTokensChanged = vi.fn();
|
|
154
|
-
subscriber.
|
|
148
|
+
subscriber.tokenChangeHandler = onTokensChanged;
|
|
155
149
|
findOneMock.mockResolvedValueOnce({
|
|
156
150
|
...previous,
|
|
157
151
|
credentials: { accessToken: "new", refreshToken: "refresh" },
|
|
@@ -173,15 +167,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
173
167
|
{} as never,
|
|
174
168
|
{
|
|
175
169
|
tokens: new Map([[previous.id, previous]]),
|
|
176
|
-
removeCache: vi.fn(),
|
|
177
|
-
upsertCache: vi.fn(),
|
|
178
170
|
replaceCache: vi.fn(),
|
|
179
171
|
openaiDispatcher: null,
|
|
180
172
|
anthropicDispatcher: { onTokenRemoved: vi.fn() },
|
|
181
173
|
} as never,
|
|
182
174
|
);
|
|
183
175
|
const onTokensChanged = vi.fn();
|
|
184
|
-
subscriber.
|
|
176
|
+
subscriber.tokenChangeHandler = onTokensChanged;
|
|
185
177
|
findOneMock.mockResolvedValueOnce({ ...previous, active: false });
|
|
186
178
|
|
|
187
179
|
await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: previous.id }));
|
|
@@ -198,8 +190,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
198
190
|
const subscriber = new TokenSubscriber(
|
|
199
191
|
{} as never,
|
|
200
192
|
{
|
|
201
|
-
|
|
202
|
-
upsertCache: vi.fn(),
|
|
193
|
+
tokens: new Map(),
|
|
203
194
|
replaceCache: vi.fn(),
|
|
204
195
|
openaiDispatcher: null,
|
|
205
196
|
anthropicDispatcher,
|
|
@@ -243,8 +234,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
243
234
|
const subscriber = new TokenSubscriber(
|
|
244
235
|
{} as never,
|
|
245
236
|
{
|
|
246
|
-
|
|
247
|
-
upsertCache: vi.fn(),
|
|
237
|
+
tokens: new Map(),
|
|
248
238
|
replaceCache: vi.fn(),
|
|
249
239
|
openaiDispatcher,
|
|
250
240
|
anthropicDispatcher,
|
|
@@ -290,7 +280,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
290
280
|
};
|
|
291
281
|
const subscriber = new TokenSubscriber({} as never, dispatcher as never);
|
|
292
282
|
const onTokensChanged = vi.fn();
|
|
293
|
-
subscriber.
|
|
283
|
+
subscriber.tokenChangeHandler = onTokensChanged;
|
|
294
284
|
findActiveMock
|
|
295
285
|
.mockResolvedValueOnce([currentAnthropic])
|
|
296
286
|
.mockResolvedValueOnce([
|
|
@@ -25,19 +25,15 @@ export class TokenSubscriber {
|
|
|
25
25
|
attempt = 0;
|
|
26
26
|
connectedAt: Date | null = null;
|
|
27
27
|
lastReconcileAt: Date | null = null;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
operationChain: Promise<void> = Promise.resolve();
|
|
29
|
+
tokenChangeHandler: (() => void) | null = null;
|
|
30
30
|
|
|
31
31
|
constructor(
|
|
32
32
|
public connConfig: ClientConfig,
|
|
33
33
|
public dispatcher: QgridDispatcherClass,
|
|
34
34
|
) {}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
this.tokenChangeHandler = handler;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
private notifyTokensChanged(): void {
|
|
36
|
+
notifyTokensChanged(): void {
|
|
41
37
|
try {
|
|
42
38
|
this.tokenChangeHandler?.();
|
|
43
39
|
} catch (error) {
|
|
@@ -168,18 +164,18 @@ export class TokenSubscriber {
|
|
|
168
164
|
return this.enqueueOperation(() => this.handleNotificationNow(payloadJson));
|
|
169
165
|
}
|
|
170
166
|
|
|
171
|
-
|
|
167
|
+
async handleNotificationNow(payloadJson: string): Promise<void> {
|
|
172
168
|
const payload = JSON.parse(payloadJson) as Payload;
|
|
173
169
|
const previousRow = this.tokenChangeHandler
|
|
174
170
|
? this.dispatcher.tokens.get(payload.id)
|
|
175
171
|
: undefined;
|
|
176
172
|
const wasKeepaliveTarget = previousRow?.active === true && previousRow.provider === "anthropic";
|
|
177
173
|
if (payload.op === "DELETE") {
|
|
178
|
-
this.dispatcher.
|
|
174
|
+
this.dispatcher.tokens.delete(payload.id);
|
|
179
175
|
await this.dispatcher.openaiDispatcher
|
|
180
176
|
?.onTokenRemoved(payload.id)
|
|
181
177
|
.catch((e) => logger.warn(`openai token remove failed: ${(e as Error).message}`));
|
|
182
|
-
//
|
|
178
|
+
// 삭제된 토큰의 provider를 모를 수 있으므로 양쪽 풀에서 제거한다.
|
|
183
179
|
this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
|
|
184
180
|
logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
|
|
185
181
|
if (previousRow === undefined || wasKeepaliveTarget) this.notifyTokensChanged();
|
|
@@ -187,7 +183,7 @@ export class TokenSubscriber {
|
|
|
187
183
|
}
|
|
188
184
|
const row = await TokenModel.findOne("A", { id: payload.id });
|
|
189
185
|
if (!row) {
|
|
190
|
-
this.dispatcher.
|
|
186
|
+
this.dispatcher.tokens.delete(payload.id);
|
|
191
187
|
await this.dispatcher.openaiDispatcher
|
|
192
188
|
?.onTokenRemoved(payload.id)
|
|
193
189
|
.catch((e) => logger.warn(`openai token remove failed: ${(e as Error).message}`));
|
|
@@ -197,7 +193,7 @@ export class TokenSubscriber {
|
|
|
197
193
|
return;
|
|
198
194
|
}
|
|
199
195
|
|
|
200
|
-
this.dispatcher.
|
|
196
|
+
this.dispatcher.tokens.set(payload.id, row);
|
|
201
197
|
|
|
202
198
|
if (row.provider === "openai") {
|
|
203
199
|
const creds = row.credentials as Record<string, unknown>;
|
|
@@ -229,9 +225,6 @@ export class TokenSubscriber {
|
|
|
229
225
|
openaiDispatcher?.onTokenDeactivated(payload.id);
|
|
230
226
|
}
|
|
231
227
|
} else if (row.provider === "anthropic") {
|
|
232
|
-
// anthropic 토큰 이벤트는 동기(void). worker 가 없고 풀(Map)만 관리하므로 단순:
|
|
233
|
-
// INSERT → 추가, active 면 갱신(onTokenUpdated 가 identity 변경도 처리), inactive 면 풀에서 제거.
|
|
234
|
-
// (별도 activate/deactivate 콜백이 없어 active 토글을 add/remove 로 매핑.)
|
|
235
228
|
const creds = row.credentials as AnthropicCredentials;
|
|
236
229
|
if (payload.op === "INSERT" && row.active) {
|
|
237
230
|
this.dispatcher.anthropicDispatcher?.onTokenAdded(
|
|
@@ -250,7 +243,6 @@ export class TokenSubscriber {
|
|
|
250
243
|
row.weight,
|
|
251
244
|
);
|
|
252
245
|
} else {
|
|
253
|
-
// inactive 면 INSERT 든 UPDATE 든 풀에 넣지 않는다.
|
|
254
246
|
this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
|
|
255
247
|
}
|
|
256
248
|
}
|
|
@@ -263,7 +255,7 @@ export class TokenSubscriber {
|
|
|
263
255
|
return this.enqueueOperation(() => this.reconcileNow());
|
|
264
256
|
}
|
|
265
257
|
|
|
266
|
-
|
|
258
|
+
async reconcileNow(): Promise<void> {
|
|
267
259
|
const previousAnthropicIds = this.tokenChangeHandler
|
|
268
260
|
? new Set(
|
|
269
261
|
[...this.dispatcher.tokens.values()]
|
|
@@ -274,8 +266,6 @@ export class TokenSubscriber {
|
|
|
274
266
|
await TokenModel.deactivateExpiredTokens();
|
|
275
267
|
const rows = await TokenModel.findActive("A");
|
|
276
268
|
this.dispatcher.replaceCache(rows);
|
|
277
|
-
// NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
|
|
278
|
-
// (rows 는 active 만 — inactive/삭제된 토큰은 여기 없으므로 replaceTokens 가 풀에서 제거한다.)
|
|
279
269
|
const anthropicRows = rows
|
|
280
270
|
.filter((r) => r.provider === "anthropic")
|
|
281
271
|
.map((r) => ({
|
|
@@ -308,7 +298,7 @@ export class TokenSubscriber {
|
|
|
308
298
|
}
|
|
309
299
|
}
|
|
310
300
|
|
|
311
|
-
|
|
301
|
+
enqueueOperation(operation: () => Promise<void>): Promise<void> {
|
|
312
302
|
const next = this.operationChain.then(operation, operation);
|
|
313
303
|
this.operationChain = next.catch(() => {});
|
|
314
304
|
return next;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
KEEPALIVE_MODEL,
|
|
5
4
|
KEEPALIVE_PROJECT_NAME,
|
|
6
5
|
POST_KEEPALIVE_USAGE_DELAY_MS,
|
|
7
6
|
rescheduleTokenWindowKeepalive,
|
|
@@ -76,7 +75,8 @@ describe("token window keepalive", () => {
|
|
|
76
75
|
|
|
77
76
|
expect(dispatch).toHaveBeenCalledWith({
|
|
78
77
|
prompt: "Reply OK.",
|
|
79
|
-
model:
|
|
78
|
+
model: "anthropic/claude-haiku-5-5",
|
|
79
|
+
thinking: false,
|
|
80
80
|
effort: "low",
|
|
81
81
|
projectName: KEEPALIVE_PROJECT_NAME,
|
|
82
82
|
preferredTokenId: 1,
|
|
@@ -15,7 +15,7 @@ const RESET_GRACE_MS = 1_000;
|
|
|
15
15
|
// usage API 성공 응답은 1분 캐시된다. keepalive 직후 즉시 재조회하면 이전 윈도우를
|
|
16
16
|
// 다시 보므로 캐시가 지난 뒤 한 번 확인하고, 그때도 새 윈도우가 없으면 다음 5h까지 기다린다.
|
|
17
17
|
export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
|
|
18
|
-
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-
|
|
18
|
+
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-5-5";
|
|
19
19
|
export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
|
|
20
20
|
export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
|
|
21
21
|
export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
|
|
@@ -163,6 +163,7 @@ async function inspectToken(
|
|
|
163
163
|
await deps.dispatch({
|
|
164
164
|
prompt: "Reply OK.",
|
|
165
165
|
model: KEEPALIVE_MODEL,
|
|
166
|
+
thinking: false,
|
|
166
167
|
effort: "low",
|
|
167
168
|
projectName: KEEPALIVE_PROJECT_NAME,
|
|
168
169
|
preferredTokenId: token.id,
|
|
@@ -23,16 +23,16 @@ describe("buildToolCallSchema", () => {
|
|
|
23
23
|
},
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
-
it("
|
|
26
|
+
it("requests tools in the response body and ends the turn until the client returns results", () => {
|
|
27
27
|
const schema = buildToolCallSchema(tools);
|
|
28
28
|
|
|
29
29
|
expect(schema).toHaveProperty(
|
|
30
30
|
"description",
|
|
31
|
-
expect.stringContaining("
|
|
31
|
+
expect.stringContaining("then end your turn"),
|
|
32
32
|
);
|
|
33
33
|
expect(schema).toHaveProperty(
|
|
34
34
|
["properties", "result", "anyOf", 1, "description"],
|
|
35
|
-
expect.stringContaining("
|
|
35
|
+
expect.stringContaining("wait for the next request containing tool results"),
|
|
36
36
|
);
|
|
37
37
|
expect(schema).toHaveProperty(
|
|
38
38
|
[
|
|
@@ -49,6 +49,9 @@ describe("buildToolCallSchema", () => {
|
|
|
49
49
|
],
|
|
50
50
|
expect.stringContaining("getWeather"),
|
|
51
51
|
);
|
|
52
|
+
expect(JSON.stringify(schema)).not.toContain("StructuredOutput");
|
|
53
|
+
expect(JSON.stringify(schema)).not.toContain("Claude Code");
|
|
54
|
+
expect(schema).toHaveProperty("description", expect.stringContaining("Do not invent tool results"));
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
it("makes degenerate combinations grammatically impossible", () => {
|
|
@@ -54,7 +54,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
|
|
|
54
54
|
return {
|
|
55
55
|
type: "object",
|
|
56
56
|
description:
|
|
57
|
-
"
|
|
57
|
+
"Return exactly one JSON object matching this schema as your final response, including when requesting a tool. This object is the response body, not a call to a built-in tool. To request client-side tools, set result.action to tool_call and include result.toolCalls, then end your turn. The client executes those tools and supplies their results in a later request. Do not invent tool results or emit an answer before receiving the required results.",
|
|
58
58
|
properties: {
|
|
59
59
|
result: {
|
|
60
60
|
anyOf: [
|
|
@@ -75,7 +75,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
|
|
|
75
75
|
{
|
|
76
76
|
type: "object",
|
|
77
77
|
description:
|
|
78
|
-
"Request client-side tool execution
|
|
78
|
+
"Request client-side tool execution. Return this object as the complete response, then wait for the next request containing tool results.",
|
|
79
79
|
properties: {
|
|
80
80
|
action: { type: "string", enum: ["tool_call"] },
|
|
81
81
|
answer: { type: "null" },
|
|
@@ -88,7 +88,7 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
|
|
|
88
88
|
toolName: {
|
|
89
89
|
type: "string",
|
|
90
90
|
enum: tools.map((tool) => tool.name),
|
|
91
|
-
description: `Client-side tool name
|
|
91
|
+
description: `Client-side tool name. The caller executes these tools after receiving your JSON response.\n${toolDescriptions}`,
|
|
92
92
|
},
|
|
93
93
|
args: {
|
|
94
94
|
type: "string",
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { CALLER_SCHEMA_LIMITS } from "../../utils/providers/common/schema-validation";
|
|
4
4
|
import { type QgridTool } from "./qgrid.types";
|
|
5
|
-
import { applyToolCallEmulation, ToolCallEmulationError } from "./tool-emulation";
|
|
5
|
+
import { applyToolCallEmulation, isToolCallEnvelope, ToolCallEmulationError } from "./tool-emulation";
|
|
6
6
|
|
|
7
7
|
const baseResult = {
|
|
8
8
|
text: "",
|
|
@@ -84,6 +84,13 @@ describe("applyToolCallEmulation image parts", () => {
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
describe("applyToolCallEmulation envelope validation", () => {
|
|
87
|
+
it("ignores intermediate prose but never repairs malformed final JSON or accepts an unknown control tool", () => {
|
|
88
|
+
const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "notAllowed", args: "{}"}]}});
|
|
89
|
+
const allowed = [{name: "lookup", inputSchema: {type: "object"}}];
|
|
90
|
+
expect(isToolCallEnvelope("Checking the source.", allowed, "json", "commentary")).toBe(false);
|
|
91
|
+
expect(() => isToolCallEnvelope(tool, allowed, "json", "commentary")).toThrow("unknown emulated tool");
|
|
92
|
+
expect(() => isToolCallEnvelope(tool + tool, allowed, "json", "final_answer")).toThrow("response is not JSON");
|
|
93
|
+
});
|
|
87
94
|
const tools: QgridTool[] = [
|
|
88
95
|
{
|
|
89
96
|
name: "lookup",
|
|
@@ -111,6 +111,35 @@ export function applyToolCallEmulation(
|
|
|
111
111
|
);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
export function isToolCallEnvelope(
|
|
115
|
+
text: string,
|
|
116
|
+
tools: QgridTool[],
|
|
117
|
+
answerKind: AnswerKind,
|
|
118
|
+
phase?: string,
|
|
119
|
+
): boolean {
|
|
120
|
+
if (phase === "commentary") {
|
|
121
|
+
// Commentary is not the structured answer. Only an explicit tool control
|
|
122
|
+
// object is eligible, and that object must still pass the strict parser.
|
|
123
|
+
let value: unknown;
|
|
124
|
+
try {
|
|
125
|
+
value = JSON.parse(text);
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!value ||
|
|
131
|
+
typeof value !== "object" ||
|
|
132
|
+
!("result" in value) ||
|
|
133
|
+
!value.result ||
|
|
134
|
+
typeof value.result !== "object" ||
|
|
135
|
+
!("action" in value.result) ||
|
|
136
|
+
value.result.action !== "tool_call"
|
|
137
|
+
)
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return parseEnvelope(text, tools, answerKind).action === "tool_call";
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
function parseEnvelope(text: string, tools: QgridTool[], answerKind: AnswerKind) {
|
|
115
144
|
if (Buffer.byteLength(text, "utf8") > STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes) {
|
|
116
145
|
throw new ToolCallEmulationError(
|
|
@@ -257,7 +257,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
257
257
|
* 화면에서 필터를 걸면 행 수와 비용이 함께 좁혀져야 하는데, 두 쿼리가 각자 필터를
|
|
258
258
|
* 구현하면 한쪽에 축을 추가할 때 조용히 어긋난다.
|
|
259
259
|
*/
|
|
260
|
-
|
|
260
|
+
applyListFilters(
|
|
261
261
|
qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
|
|
262
262
|
params: RequestLogListParams,
|
|
263
263
|
): void {
|
|
@@ -378,7 +378,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
378
378
|
string,
|
|
379
379
|
{ requests: number; errors: number; inputTokens: number; cacheReadTokens: number }
|
|
380
380
|
>();
|
|
381
|
-
|
|
381
|
+
rows.forEach((row) => {
|
|
382
382
|
const model = row.requested_model_name ?? "";
|
|
383
383
|
const provider = model.startsWith("openai/")
|
|
384
384
|
? "openai"
|
|
@@ -396,7 +396,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
396
396
|
agg.inputTokens += row.input_tokens;
|
|
397
397
|
agg.cacheReadTokens += row.cache_read_tokens;
|
|
398
398
|
byProvider.set(provider, agg);
|
|
399
|
-
}
|
|
399
|
+
});
|
|
400
400
|
return [...byProvider.entries()]
|
|
401
401
|
.map(([provider, agg]) => ({ provider, ...agg }))
|
|
402
402
|
.toSorted((a, b) => a.provider.localeCompare(b.provider));
|
|
@@ -584,9 +584,9 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
584
584
|
"is_image_generation",
|
|
585
585
|
"response_json_ok",
|
|
586
586
|
] as const;
|
|
587
|
-
|
|
587
|
+
fields.forEach((key) => {
|
|
588
588
|
if (params[key] !== undefined) update[key] = params[key];
|
|
589
|
-
}
|
|
589
|
+
});
|
|
590
590
|
if (params.history !== undefined) update.history = params.history;
|
|
591
591
|
update.ttft_ms = (await this.firstGenerateStepTtft(requestLogId)) ?? 0;
|
|
592
592
|
|
|
@@ -251,7 +251,7 @@ class SettingModelClass extends BaseModelClass<
|
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
runtimeInfo(): RuntimeInfoItem[] {
|
|
255
255
|
const env = process.env;
|
|
256
256
|
const password = env.QGRID_DB_PASSWORD ?? env.SONAMU_DB_PASSWORD ?? "";
|
|
257
257
|
return [
|
|
@@ -195,14 +195,14 @@ export default defineConfig({
|
|
|
195
195
|
log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
subscriber.
|
|
198
|
+
subscriber.tokenChangeHandler = () => {
|
|
199
199
|
void rescheduleTokenWindowKeepalive();
|
|
200
|
-
}
|
|
200
|
+
};
|
|
201
201
|
startExpiredTokenReminder();
|
|
202
202
|
void startTokenWindowKeepalive();
|
|
203
203
|
|
|
204
|
-
const anthropicCount = QgridDispatcher.anthropicDispatcher?.
|
|
205
|
-
const openaiCount = QgridDispatcher.openaiDispatcher?.
|
|
204
|
+
const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenPool.size ?? 0;
|
|
205
|
+
const openaiCount = QgridDispatcher.openaiDispatcher?.countActiveTokens() ?? 0;
|
|
206
206
|
|
|
207
207
|
log.info(`listening on http://${host}:${port}`);
|
|
208
208
|
log.info(`anthropic: ${anthropicCount} tokens ready`);
|
|
@@ -217,7 +217,7 @@ export default defineConfig({
|
|
|
217
217
|
stopTokenWindowKeepalive();
|
|
218
218
|
stopOpenAICallbackRelay();
|
|
219
219
|
setSettingChangeHandler(null);
|
|
220
|
-
QgridDispatcher.subscriber
|
|
220
|
+
if (QgridDispatcher.subscriber) QgridDispatcher.subscriber.tokenChangeHandler = null;
|
|
221
221
|
if (QgridDispatcher.openaiDispatcher) {
|
|
222
222
|
await QgridDispatcher.openaiDispatcher.stop();
|
|
223
223
|
}
|
|
@@ -58,12 +58,9 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
|
|
|
58
58
|
]);
|
|
59
59
|
|
|
60
60
|
const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
|
|
65
|
-
// 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
|
|
66
|
-
// tool/XML 출력 오염도 피한다.
|
|
61
|
+
// Preserve these defaults when thinking is omitted. This is not an off-capability list:
|
|
62
|
+
// explicit false supports Opus 5, and native extra-body delivery supports Haiku 5.5
|
|
63
|
+
// (disabled) and Sonnet 5.5 (between_tools). See anthropic-thinking.ts.
|
|
67
64
|
const ADAPTIVE_THINKING_MODELS = new Set([
|
|
68
65
|
"claude-fable-5-1",
|
|
69
66
|
"claude-fable-5",
|
|
@@ -123,6 +123,32 @@ describe("AnthropicDispatcher", () => {
|
|
|
123
123
|
it("토큰 없으면 에러", async () => {
|
|
124
124
|
const d = new AnthropicDispatcher();
|
|
125
125
|
await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
|
|
126
|
+
expect(d.inFlight).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([false, true])("releases the in-flight count after a session (fails=%s)", async (fails) => {
|
|
130
|
+
const d = new AnthropicDispatcher();
|
|
131
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
132
|
+
runClaudeSessionMock.mockImplementationOnce(async () => {
|
|
133
|
+
expect(d.inFlight).toBe(1);
|
|
134
|
+
if (fails) throw new Error("session failed");
|
|
135
|
+
return sessionResult();
|
|
136
|
+
});
|
|
137
|
+
if (fails) await expect(d.generate(baseReq())).rejects.toThrow("session failed");
|
|
138
|
+
else await expect(d.generate(baseReq())).resolves.toMatchObject({text: "hello"});
|
|
139
|
+
expect(d.inFlight).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects unsupported thinking:false before selecting a token or spawning", async () => {
|
|
143
|
+
const d = new AnthropicDispatcher();
|
|
144
|
+
const req = baseReq({model: "anthropic/claude-fable-5-1", thinking: false});
|
|
145
|
+
await expect(d.generate(req)).rejects.toMatchObject({statusCode: 400});
|
|
146
|
+
const cb = {onDelta: vi.fn(), onComplete: vi.fn(), onError: vi.fn()};
|
|
147
|
+
await d.generateStream(req, cb);
|
|
148
|
+
expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({statusCode: 400}));
|
|
149
|
+
expect(cb.onComplete).not.toHaveBeenCalled();
|
|
150
|
+
expect(runClaudeSessionMock).not.toHaveBeenCalled();
|
|
151
|
+
expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
|
|
126
152
|
});
|
|
127
153
|
|
|
128
154
|
it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {
|