@cartanova/qgrid-cli 2.10.0 → 2.10.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/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/qgrid/conv-routing.js +4 -14
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +10 -5
- package/bundle/dist/application/request-log/request-log.model.js +14 -12
- package/bundle/dist/application/request-log-step/request-log-step.model.js +7 -11
- package/bundle/dist/application/setting/setting.model.js +13 -27
- package/bundle/dist/application/token/token.model.js +52 -47
- package/bundle/dist/server-error-handler.js +1 -1
- package/bundle/dist/sonamu.config.js +3 -15
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +4 -2
- package/bundle/dist/utils/providers/common/model-cost.js +10 -3
- package/bundle/src/application/qgrid/conv-routing.test.ts +0 -32
- package/bundle/src/application/qgrid/conv-routing.ts +7 -26
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +65 -121
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +12 -6
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +10 -24
- package/bundle/src/application/request-log/request-log.model.test.ts +37 -2
- package/bundle/src/application/request-log/request-log.model.ts +14 -7
- package/bundle/src/application/request-log-step/request-log-step.model.ts +3 -15
- package/bundle/src/application/setting/setting.constant.test.ts +1 -13
- package/bundle/src/application/setting/setting.model.ts +10 -26
- package/bundle/src/application/setting/setting.persistence.test.ts +27 -0
- package/bundle/src/application/token/token.model.test.ts +86 -19
- package/bundle/src/application/token/token.model.ts +59 -44
- package/bundle/src/server-error-handler.ts +1 -2
- package/bundle/src/sonamu.config.ts +4 -31
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +6 -1
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +5 -2
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +1 -26
- package/bundle/src/utils/providers/common/model-cost.test.ts +22 -1
- package/bundle/src/utils/providers/common/model-cost.ts +22 -5
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -11
- package/bundle/web-dist/client/assets/index-DtRTSTes.js +106 -0
- package/bundle/web-dist/client/assets/logs-BhVYDEzC.js +1 -0
- package/bundle/web-dist/client/assets/monit-BxN4JWqK.js +1 -0
- package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
- package/bundle/web-dist/client/assets/{routes-C8Y6olae.js → routes-CBCqRp2o.js} +1 -1
- package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +51 -0
- package/bundle/web-dist/client/assets/{settings-CdVqtDQ5.js → settings-hBTEC7Ki.js} +1 -1
- package/bundle/web-dist/client/assets/show-BzYft5N4.js +2 -0
- package/bundle/web-dist/client/assets/{tokens-CxXtdk9o.js → tokens-PMybtI8X.js} +1 -1
- package/bundle/web-dist/client/assets/use-oauth-login-flow-C51_d6-6.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-Dkv_HG-C.js → use-update-token-mutation-D-Y8OySK.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-DyizoQKi.js → lazyRouteComponent-Bj1cPq6x.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-DO8zBdBR.js → logs-BSk9lOHe.js} +31 -13
- package/bundle/web-dist/server/assets/{monit-COH4MckI.js → monit-D9W4ioUi.js} +5 -24
- package/bundle/web-dist/server/assets/{plus-C1D8z9o0.js → plus-CmvbWuDf.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-PfWvArTX.js → react-dom-BjR1Iaz8.js} +1 -1
- package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
- package/bundle/web-dist/server/assets/{routes-Btja-741.js → routes-C7lB2Q85.js} +5 -5
- package/bundle/web-dist/server/assets/{services.generated-BsKOUSXl.js → services.generated-BM4X3Ecj.js} +102 -6
- package/bundle/web-dist/server/assets/{settings-DPcWGvy-.js → settings-BFVuldch.js} +4 -4
- package/bundle/web-dist/server/assets/{show-CMXbb3TJ.js → show-Cswh_tqs.js} +10 -9
- package/bundle/web-dist/server/assets/{tokens-DAUmTrgO.js → tokens-DiTUwUwr.js} +5 -5
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-GsGwqefs.js → use-oauth-login-flow-DYKnG4A2.js} +32 -15
- package/bundle/web-dist/server/assets/{use-update-token-mutation-RujWERvj.js → use-update-token-mutation-DqGMopII.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +381 -22
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -2
- package/skills/qgrid/references/request-log-run-lifecycle.md +2 -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/verification-and-debugging.md +24 -13
- package/bundle/dist/utils/providers/common/token-picker.js +0 -14
- package/bundle/src/utils/providers/common/token-picker.ts +0 -18
- package/bundle/web-dist/client/assets/index-BfwrIBHs.js +0 -106
- package/bundle/web-dist/client/assets/logs-Brit-LqB.js +0 -1
- package/bundle/web-dist/client/assets/monit-DrYZkisv.js +0 -1
- package/bundle/web-dist/client/assets/services.generated-Bg2XkD-D.js +0 -51
- package/bundle/web-dist/client/assets/show-2B2X9MkO.js +0 -2
- package/bundle/web-dist/client/assets/use-oauth-login-flow-B9EqnwY9.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/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
|
@@ -48,8 +48,9 @@ export type InternalQueryInput = QueryInput & {
|
|
|
48
48
|
export class QgridDispatcherClass {
|
|
49
49
|
tokens = new Map<number, TokenSubsetA>();
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
|
|
51
|
+
// 프로세스 시작 이후 완료된 provider generation 수. 실패/중간 시도는 제외하며,
|
|
52
|
+
// 후속 tool turn도 한 generation으로 센다. 동명 토큰은 provider별로 분리한다.
|
|
53
|
+
private requestCounts = new Map<string, number>();
|
|
53
54
|
|
|
54
55
|
// sonamu.config onStart 에서 처리하는 변수
|
|
55
56
|
subscriber: TokenSubscriber | null = null;
|
|
@@ -61,7 +62,7 @@ export class QgridDispatcherClass {
|
|
|
61
62
|
* HTTP 리스닝은 dispatcher 준비보다 먼저 열리므로(sonamu.config onStart),
|
|
62
63
|
* 그 사이 들어온 요청은 "잠시 후 되는" 상태와 "재시도해도 안 되는" 상태가 다르다.
|
|
63
64
|
*
|
|
64
|
-
* - `starting`:
|
|
65
|
+
* - `starting`: provider 토큰·런타임 초기화 중 → 재시도 가능
|
|
65
66
|
* - `ready`: 정상
|
|
66
67
|
* - `failed`: start() 가 예외로 끝남 → 재시도해도 같은 결과
|
|
67
68
|
*/
|
|
@@ -84,8 +85,9 @@ export class QgridDispatcherClass {
|
|
|
84
85
|
: new ServiceUnavailableException(SD("qgrid.dispatcherStarting")(label));
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
private recordCompleted(provider: string, tokenName: string): void {
|
|
89
|
+
const key = `${provider}\0${tokenName}`;
|
|
90
|
+
this.requestCounts.set(key, (this.requestCounts.get(key) ?? 0) + 1);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// TokenSubscriber 콜백 — 캐시 mutation
|
|
@@ -106,7 +108,7 @@ export class QgridDispatcherClass {
|
|
|
106
108
|
token: maskToken(getAccessToken(r.credentials)),
|
|
107
109
|
name: r.name,
|
|
108
110
|
provider: r.provider,
|
|
109
|
-
requests: this.
|
|
111
|
+
requests: this.requestCounts.get(`${r.provider}\0${r.name}`) ?? 0,
|
|
110
112
|
}));
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -143,6 +145,7 @@ export class QgridDispatcherClass {
|
|
|
143
145
|
imageGeneration: input.imageGeneration,
|
|
144
146
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
145
147
|
});
|
|
148
|
+
this.recordCompleted("openai", result.tokenName);
|
|
146
149
|
|
|
147
150
|
// 이미지 요청은 cold-only(R8)라 재사용 좌표를 발급하지 않는다. 좌표를 실으면
|
|
148
151
|
// sessionKey 소비자의 warm 좌표를 죽은 좌표로 덮어써 다음 텍스트 turn 이 cold 로 떨어진다.
|
|
@@ -175,6 +178,7 @@ export class QgridDispatcherClass {
|
|
|
175
178
|
imageGeneration: input.imageGeneration,
|
|
176
179
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
177
180
|
});
|
|
181
|
+
this.recordCompleted("anthropic", result.tokenName);
|
|
178
182
|
|
|
179
183
|
return applyToolCallEmulation(toEmulationResult(result), input.tools, {
|
|
180
184
|
threadCoord: issueConvContext(result.threadCoord, decision),
|
|
@@ -227,6 +231,7 @@ export class QgridDispatcherClass {
|
|
|
227
231
|
onThreadId: cb.onThreadId,
|
|
228
232
|
onTurnId: cb.onTurnId,
|
|
229
233
|
onComplete: (turnResult) => {
|
|
234
|
+
this.recordCompleted("openai", turnResult.tokenName);
|
|
230
235
|
cb.onComplete(
|
|
231
236
|
applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
|
|
232
237
|
threadCoord: issueConvContext(
|
|
@@ -276,6 +281,7 @@ export class QgridDispatcherClass {
|
|
|
276
281
|
onDelta: emitDelta,
|
|
277
282
|
onThreadId: cb.onThreadId,
|
|
278
283
|
onComplete: (turnResult) => {
|
|
284
|
+
this.recordCompleted("anthropic", turnResult.tokenName);
|
|
279
285
|
// 홀드백 잔여(닫는 펜스가 아니었던 tail)를 done 전에 마저 방출한다 —
|
|
280
286
|
// 델타를 조립하는 클라이언트(EnvelopeStreamParser 등)의 무손실 보장.
|
|
281
287
|
const rest = fenceStrip?.flush();
|
|
@@ -896,20 +896,29 @@ describe("QgridFrame.prepareStream", () => {
|
|
|
896
896
|
});
|
|
897
897
|
|
|
898
898
|
describe("QgridFrame raw lifecycle API", () => {
|
|
899
|
-
it("
|
|
899
|
+
it("maps the requested model and structured metadata through the external createRun API", async () => {
|
|
900
900
|
requestLogCreateRunMock.mockReset().mockResolvedValue(81);
|
|
901
|
+
const jsonSchema = JSON.stringify({
|
|
902
|
+
type: "object",
|
|
903
|
+
properties: { answer: { type: "string" } },
|
|
904
|
+
required: ["answer"],
|
|
905
|
+
});
|
|
901
906
|
|
|
902
907
|
await expect(
|
|
903
908
|
QgridFrame.createRun({
|
|
904
909
|
userPrompt: "hi",
|
|
905
910
|
modelName: "google/gemini-3-flash",
|
|
906
911
|
projectName: "external",
|
|
912
|
+
isStructured: true,
|
|
913
|
+
jsonSchema,
|
|
907
914
|
}),
|
|
908
915
|
).resolves.toEqual({ requestLogId: 81 });
|
|
909
916
|
|
|
910
917
|
expect(requestLogCreateRunMock).toHaveBeenCalledWith(
|
|
911
918
|
expect.objectContaining({
|
|
912
919
|
requested_model_name: "google/gemini-3-flash",
|
|
920
|
+
is_structured: true,
|
|
921
|
+
json_schema: jsonSchema,
|
|
913
922
|
}),
|
|
914
923
|
);
|
|
915
924
|
expect(requestLogCreateRunMock).toHaveBeenCalledWith(
|
|
@@ -917,29 +926,6 @@ describe("QgridFrame raw lifecycle API", () => {
|
|
|
917
926
|
);
|
|
918
927
|
});
|
|
919
928
|
|
|
920
|
-
it("passes optional structured metadata through the external createRun API", async () => {
|
|
921
|
-
requestLogCreateRunMock.mockReset().mockResolvedValue(82);
|
|
922
|
-
const jsonSchema = JSON.stringify({
|
|
923
|
-
type: "object",
|
|
924
|
-
properties: { answer: { type: "string" } },
|
|
925
|
-
required: ["answer"],
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
await QgridFrame.createRun({
|
|
929
|
-
userPrompt: "hi",
|
|
930
|
-
modelName: "google/gemini-3.5-flash-lite",
|
|
931
|
-
isStructured: true,
|
|
932
|
-
jsonSchema,
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
expect(requestLogCreateRunMock).toHaveBeenCalledWith(
|
|
936
|
-
expect.objectContaining({
|
|
937
|
-
is_structured: true,
|
|
938
|
-
json_schema: jsonSchema,
|
|
939
|
-
}),
|
|
940
|
-
);
|
|
941
|
-
});
|
|
942
|
-
|
|
943
929
|
it("passes optional response JSON validity through the external finishRun API", async () => {
|
|
944
930
|
requestLogFinishRunMock.mockReset().mockResolvedValue(undefined);
|
|
945
931
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { Client, type ClientConfig } from "pg";
|
|
2
4
|
import { Sonamu } from "sonamu";
|
|
3
5
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
@@ -613,7 +615,7 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
613
615
|
await RequestLogModel.totalCost();
|
|
614
616
|
|
|
615
617
|
expect(sumChain.select).toHaveBeenCalledTimes(1);
|
|
616
|
-
//
|
|
618
|
+
// source 없는 행도 저장된 이미지 비용을 포함하고 driver 차액만 별도로 보정한다.
|
|
617
619
|
expect(sumChain.whereRaw).not.toHaveBeenCalled();
|
|
618
620
|
expect(legacyChain.whereRaw).toHaveBeenCalledWith(
|
|
619
621
|
"request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL",
|
|
@@ -723,11 +725,44 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
723
725
|
});
|
|
724
726
|
});
|
|
725
727
|
|
|
726
|
-
describe("RequestLogModel PostgreSQL
|
|
728
|
+
describe("RequestLogModel PostgreSQL", () => {
|
|
727
729
|
beforeAll(async () => {
|
|
728
730
|
await Sonamu.initForTesting();
|
|
729
731
|
});
|
|
730
732
|
|
|
733
|
+
it("includes image costs once and filters both stored totals and legacy adjustments", async () => {
|
|
734
|
+
const projectName = `cost-${randomUUID()}`;
|
|
735
|
+
const ids: number[] = [];
|
|
736
|
+
try {
|
|
737
|
+
for (const [index, costs] of [
|
|
738
|
+
{ cost_usd: 5_000, image_cost_usd: 10_000, cost_source: "provider" },
|
|
739
|
+
{ cost_usd: 1_000, image_cost_usd: 20_000, cost_source: null },
|
|
740
|
+
{ cost_usd: 900_000, image_cost_usd: 700_000, cost_source: null },
|
|
741
|
+
].entries()) {
|
|
742
|
+
const id = await RequestLogModel.createRun({
|
|
743
|
+
user_prompt: "cost regression",
|
|
744
|
+
project_name: index < 2 ? projectName : `${projectName}-other`,
|
|
745
|
+
});
|
|
746
|
+
ids.push(id);
|
|
747
|
+
await RequestLogModel.getPuri("w").table("request_logs").where("id", id).update({
|
|
748
|
+
...costs,
|
|
749
|
+
model_name: "anthropic/claude-sonnet-4-6",
|
|
750
|
+
token_name: "anthropic/test",
|
|
751
|
+
input_tokens: 1_000,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const params = { sonamuFilter: { project_name: projectName }, num: 0, page: 1 };
|
|
756
|
+
const { rows } = await RequestLogModel.findMany("C", params);
|
|
757
|
+
expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining(ids.slice(0, 2)));
|
|
758
|
+
expect(rows).toHaveLength(2);
|
|
759
|
+
// Confirmed: .005 + .010; legacy driver repriced to .003, plus .020 image.
|
|
760
|
+
await expect(RequestLogModel.totalCost(params)).resolves.toBeCloseTo(0.038, 10);
|
|
761
|
+
} finally {
|
|
762
|
+
await RequestLogModel.del(ids);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
731
766
|
it("does not expire a run while another connection holds its follow-up lock", async () => {
|
|
732
767
|
const wdb = RequestLogModel.getPuri("w");
|
|
733
768
|
const client = new Client(wdb.knex.client.config.connection as ClientConfig);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
BadRequestException,
|
|
6
6
|
BaseModelClass,
|
|
7
7
|
type ListResult,
|
|
8
|
+
normalizeFilterQuery,
|
|
8
9
|
NotFoundException,
|
|
9
10
|
Puri,
|
|
10
11
|
transactional,
|
|
@@ -228,6 +229,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
228
229
|
id,
|
|
229
230
|
num: 1,
|
|
230
231
|
page: 1,
|
|
232
|
+
queryMode: "list",
|
|
231
233
|
});
|
|
232
234
|
if (!rows[0]) {
|
|
233
235
|
throw new NotFoundException(SD("error.entityNotFound")("RequestLog", id));
|
|
@@ -244,6 +246,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
244
246
|
...listParams,
|
|
245
247
|
num: 1,
|
|
246
248
|
page: 1,
|
|
249
|
+
queryMode: "list",
|
|
247
250
|
});
|
|
248
251
|
|
|
249
252
|
return rows[0] ?? null;
|
|
@@ -304,7 +307,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
304
307
|
...rawParams,
|
|
305
308
|
} satisfies RequestLogListParams;
|
|
306
309
|
|
|
307
|
-
const { qb
|
|
310
|
+
const { qb } = this.getSubsetQueries(subset);
|
|
308
311
|
|
|
309
312
|
this.applyListFilters(qb, params);
|
|
310
313
|
|
|
@@ -320,7 +323,6 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
320
323
|
qb,
|
|
321
324
|
params,
|
|
322
325
|
enhancers,
|
|
323
|
-
debug: false,
|
|
324
326
|
});
|
|
325
327
|
}
|
|
326
328
|
|
|
@@ -808,10 +810,8 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
808
810
|
/**
|
|
809
811
|
* 화면 표시용 총 비용. 목록과 같은 필터를 적용한다.
|
|
810
812
|
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
* 탄다 — `cost_source IS NOT NULL` 을 걸면 그 컬럼이 인덱스에 없어 heap 을 다시 봐야
|
|
814
|
-
* 하므로 플래너가 인덱스를 버리고 seq scan 으로 돌아간다(실측 230ms → 814ms).
|
|
813
|
+
* driver와 image 비용을 함께 SUM한 뒤, legacy driver 비용의 차액만 보정한다.
|
|
814
|
+
* 이미지 비용은 저장값을 한 번만 포함하고 가격표 재계산 대상에서 제외한다.
|
|
815
815
|
*
|
|
816
816
|
* legacy 보정을 남겨둔 이유: 백필로 기존 legacy 는 0 이 되었지만, 외부 로거가
|
|
817
817
|
* `AppendStepInput`/`FinishRunInput` 으로 costSource 없이 넣으면 다시 생길 수 있다.
|
|
@@ -830,18 +830,25 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
830
830
|
|
|
831
831
|
const { qb: totalQb } = this.getSubsetQueries("C");
|
|
832
832
|
this.applyListFilters(totalQb, params);
|
|
833
|
+
const sonamuFilter = params.sonamuFilter
|
|
834
|
+
? normalizeFilterQuery(params.sonamuFilter)
|
|
835
|
+
: undefined;
|
|
836
|
+
this.applySonamuFilters(totalQb, sonamuFilter);
|
|
833
837
|
// subset qb 는 컬럼 목록이 이미 SELECT 에 박혀 있어 집계를 얹으면 GROUP BY 에러가 난다.
|
|
834
838
|
// executeCountQuery 와 같은 방식으로 select 를 비우고 집계만 남긴다.
|
|
835
839
|
const stored = await totalQb
|
|
836
840
|
.clear("select")
|
|
837
841
|
// 음수 저장값(과거 버그)은 0 으로 눌러서 더한다 — JS 쪽 Math.max 와 같은 규칙.
|
|
838
842
|
.select({
|
|
839
|
-
total: Puri.rawNumber(
|
|
843
|
+
total: Puri.rawNumber(
|
|
844
|
+
"COALESCE(SUM(GREATEST(request_logs.cost_usd, 0) + COALESCE(request_logs.image_cost_usd, 0)), 0)",
|
|
845
|
+
),
|
|
840
846
|
})
|
|
841
847
|
.first();
|
|
842
848
|
|
|
843
849
|
const { qb: legacyQb } = this.getSubsetQueries("C");
|
|
844
850
|
this.applyListFilters(legacyQb, params);
|
|
851
|
+
this.applySonamuFilters(legacyQb, sonamuFilter);
|
|
845
852
|
const legacyRows = await legacyQb
|
|
846
853
|
.whereRaw("request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL")
|
|
847
854
|
.clear("select")
|
|
@@ -41,6 +41,7 @@ class RequestLogStepModelClass extends BaseModelClass<
|
|
|
41
41
|
id,
|
|
42
42
|
num: 1,
|
|
43
43
|
page: 1,
|
|
44
|
+
queryMode: "list",
|
|
44
45
|
});
|
|
45
46
|
if (!rows[0]) {
|
|
46
47
|
throw new NotFoundException(SD("error.entityNotFound")("RequestLogStep", id));
|
|
@@ -57,6 +58,7 @@ class RequestLogStepModelClass extends BaseModelClass<
|
|
|
57
58
|
...listParams,
|
|
58
59
|
num: 1,
|
|
59
60
|
page: 1,
|
|
61
|
+
queryMode: "list",
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
return rows[0] ?? null;
|
|
@@ -77,7 +79,7 @@ class RequestLogStepModelClass extends BaseModelClass<
|
|
|
77
79
|
} satisfies RequestLogStepListParams;
|
|
78
80
|
|
|
79
81
|
// build queries
|
|
80
|
-
const { qb
|
|
82
|
+
const { qb } = this.getSubsetQueries(subset);
|
|
81
83
|
|
|
82
84
|
if (params.id) {
|
|
83
85
|
qb.whereIn("request_log_steps.id", asArray(params.id));
|
|
@@ -91,8 +93,6 @@ class RequestLogStepModelClass extends BaseModelClass<
|
|
|
91
93
|
if (params.search && params.keyword && params.keyword.length > 0) {
|
|
92
94
|
if (params.search === "id") {
|
|
93
95
|
qb.where("request_log_steps.id", Number(params.keyword));
|
|
94
|
-
// } else if (params.search === "field") {
|
|
95
|
-
// qb.where("request_log_steps.field", "like", `%${params.keyword}%`);
|
|
96
96
|
} else {
|
|
97
97
|
throw new BadRequestException(SD("error.unknownSearchField")(params.search));
|
|
98
98
|
}
|
|
@@ -109,22 +109,10 @@ class RequestLogStepModelClass extends BaseModelClass<
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
const enhancers = this.createEnhancers({
|
|
113
|
-
A: (row) => ({
|
|
114
|
-
...row,
|
|
115
|
-
// 서브셋별로 virtual 필드 계산로직 추가
|
|
116
|
-
}),
|
|
117
|
-
T: (row) => ({
|
|
118
|
-
...row,
|
|
119
|
-
}),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
112
|
return this.executeSubsetQuery({
|
|
123
113
|
subset,
|
|
124
114
|
qb,
|
|
125
115
|
params,
|
|
126
|
-
enhancers,
|
|
127
|
-
debug: false,
|
|
128
116
|
});
|
|
129
117
|
}
|
|
130
118
|
|
|
@@ -10,6 +10,7 @@ describe("validateSettingValue", () => {
|
|
|
10
10
|
// 저장을 막지 않으면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다.
|
|
11
11
|
expect(validateSettingValue(intDef, "24")).toMatchObject({ ok: false });
|
|
12
12
|
expect(validateSettingValue(intDef, "-1")).toMatchObject({ ok: false });
|
|
13
|
+
expect(validateSettingValue(intDef, "0")).toMatchObject({ ok: true, value: "0" });
|
|
13
14
|
expect(validateSettingValue(intDef, "23")).toMatchObject({ ok: true, value: "23" });
|
|
14
15
|
});
|
|
15
16
|
|
|
@@ -48,19 +49,6 @@ describe("preset 검증", () => {
|
|
|
48
49
|
});
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
describe("조용 시간 설정", () => {
|
|
52
|
-
// fallback("20"/"8")은 quiet-hours.ts 의 DEFAULT_QUIET_* 와 같아야 하지만 테스트로 묶지
|
|
53
|
-
// 않는다 — 두 모듈을 한 파일에서 import 하면 setting.store → token.model 이 딸려와,
|
|
54
|
-
// token.model 을 mock 하는 테스트가 같은 워커에 배치될 때 실제 모듈을 먼저 로드해 버린다.
|
|
55
|
-
it("0..23 을 벗어나면 거부한다", () => {
|
|
56
|
-
const fromDef = findSettingDef("slack.quietFromHour")!;
|
|
57
|
-
expect(validateSettingValue(fromDef, "24")).toMatchObject({ ok: false });
|
|
58
|
-
expect(validateSettingValue(fromDef, "-1")).toMatchObject({ ok: false });
|
|
59
|
-
expect(validateSettingValue(fromDef, "0")).toMatchObject({ ok: true });
|
|
60
|
-
expect(validateSettingValue(fromDef, "23")).toMatchObject({ ok: true });
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
52
|
describe("maskSecret", () => {
|
|
65
53
|
it("긴 값은 앞뒤만 남긴다", () => {
|
|
66
54
|
expect(maskSecret("xoxb-1234567890-abcdefghij")).toBe("xoxb-123...ghij");
|
|
@@ -90,6 +90,7 @@ class SettingModelClass extends BaseModelClass<
|
|
|
90
90
|
id,
|
|
91
91
|
num: 1,
|
|
92
92
|
page: 1,
|
|
93
|
+
queryMode: "list",
|
|
93
94
|
});
|
|
94
95
|
if (!rows[0]) {
|
|
95
96
|
throw new NotFoundException(SD("error.entityNotFound")("Setting", id));
|
|
@@ -106,6 +107,7 @@ class SettingModelClass extends BaseModelClass<
|
|
|
106
107
|
...listParams,
|
|
107
108
|
num: 1,
|
|
108
109
|
page: 1,
|
|
110
|
+
queryMode: "list",
|
|
109
111
|
});
|
|
110
112
|
|
|
111
113
|
return rows[0] ?? null;
|
|
@@ -126,7 +128,7 @@ class SettingModelClass extends BaseModelClass<
|
|
|
126
128
|
} satisfies SettingListParams;
|
|
127
129
|
|
|
128
130
|
// build queries
|
|
129
|
-
const { qb
|
|
131
|
+
const { qb } = this.getSubsetQueries(subset);
|
|
130
132
|
|
|
131
133
|
// id
|
|
132
134
|
if (params.id) {
|
|
@@ -157,19 +159,10 @@ class SettingModelClass extends BaseModelClass<
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
const enhancers = this.createEnhancers({
|
|
161
|
-
A: (row) => ({
|
|
162
|
-
...row,
|
|
163
|
-
// 서브셋별로 virtual 필드 계산로직 추가
|
|
164
|
-
}),
|
|
165
|
-
});
|
|
166
|
-
|
|
167
162
|
return this.executeSubsetQuery({
|
|
168
163
|
subset,
|
|
169
164
|
qb,
|
|
170
165
|
params,
|
|
171
|
-
enhancers,
|
|
172
|
-
debug: false,
|
|
173
166
|
});
|
|
174
167
|
}
|
|
175
168
|
|
|
@@ -202,30 +195,21 @@ class SettingModelClass extends BaseModelClass<
|
|
|
202
195
|
|
|
203
196
|
/** 저장된 설정 전체를 key→value 로. 부팅 시 한 번 읽어 메모리에 올린다. */
|
|
204
197
|
async findAllAsMap(): Promise<Map<string, string>> {
|
|
205
|
-
const { rows } = await this.findMany("A", { num: 0, page: 1 });
|
|
198
|
+
const { rows } = await this.findMany("A", { num: 0, page: 1, queryMode: "list" });
|
|
206
199
|
return new Map(rows.map((r) => [r.key, r.value]));
|
|
207
200
|
}
|
|
208
201
|
|
|
209
|
-
/**
|
|
210
|
-
* key 로 upsert 한다. `save` 는 id 기준이라 key 중복을 걸러내지 못해, 기존 행을 먼저 찾아
|
|
211
|
-
* id 를 실어 보낸다.
|
|
212
|
-
*/
|
|
202
|
+
/** 최초 동시 저장도 key unique constraint로 원자적으로 처리한다. */
|
|
213
203
|
async setByKey(key: string, value: string): Promise<void> {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{
|
|
217
|
-
|
|
218
|
-
key,
|
|
219
|
-
value,
|
|
220
|
-
updated_at: new Date(),
|
|
221
|
-
},
|
|
222
|
-
]);
|
|
204
|
+
await this.getPuri("w")
|
|
205
|
+
.table("settings")
|
|
206
|
+
.insert({ key, value, updated_at: new Date() })
|
|
207
|
+
.onConflict("key", { update: ["value", "updated_at"] });
|
|
223
208
|
}
|
|
224
209
|
|
|
225
210
|
/** 저장값을 지워 env 기본값으로 되돌린다. */
|
|
226
211
|
async clearByKey(key: string): Promise<void> {
|
|
227
|
-
|
|
228
|
-
if (existing) await this.del([existing.id]);
|
|
212
|
+
await this.getPuri("w").table("settings").where("key", key).delete();
|
|
229
213
|
}
|
|
230
214
|
|
|
231
215
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { Sonamu } from "sonamu";
|
|
4
|
+
import { beforeAll, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { SettingModel } from "./setting.model";
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
await Sonamu.initForTesting();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("atomically saves concurrent first writes to the same setting key", async () => {
|
|
13
|
+
const key = `test.concurrent.${randomUUID()}`;
|
|
14
|
+
try {
|
|
15
|
+
await Promise.all([SettingModel.setByKey(key, "first"), SettingModel.setByKey(key, "second")]);
|
|
16
|
+
const { rows } = await SettingModel.findMany("A", { key, num: 0, page: 1 });
|
|
17
|
+
expect(rows).toHaveLength(1);
|
|
18
|
+
expect(["first", "second"]).toContain(rows[0]!.value);
|
|
19
|
+
await SettingModel.setByKey(key, "final");
|
|
20
|
+
await expect(SettingModel.findOne("A", { key })).resolves.toMatchObject({
|
|
21
|
+
id: rows[0]!.id,
|
|
22
|
+
value: "final",
|
|
23
|
+
});
|
|
24
|
+
} finally {
|
|
25
|
+
await SettingModel.clearByKey(key);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
@@ -115,32 +115,99 @@ describe("TokenModel.updateFields", () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
describe("TokenModel.replaceByAccount", () => {
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
beforeAll(async () => {
|
|
119
|
+
await Sonamu.initForTesting();
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it("
|
|
123
|
-
|
|
122
|
+
it("replaces duplicate account rows while preserving routing settings and renewing login state", async () => {
|
|
123
|
+
const accountUuid = randomUUID();
|
|
124
|
+
const credentials = { ...baseToken.credentials, accountUuid };
|
|
125
|
+
const ids = await TokenModel.save([
|
|
124
126
|
{
|
|
125
|
-
id: 1,
|
|
126
|
-
created_at: new Date(),
|
|
127
|
-
active: true,
|
|
128
|
-
reauth_required: false,
|
|
129
|
-
ord: 0,
|
|
130
|
-
quota_threshold: 80,
|
|
131
|
-
weight: 1,
|
|
132
|
-
keepalive_enabled: true,
|
|
133
127
|
...baseToken,
|
|
128
|
+
name: `replace-first-${accountUuid}`,
|
|
129
|
+
credentials,
|
|
130
|
+
active: false,
|
|
131
|
+
reauth_required: true,
|
|
132
|
+
ord: 9,
|
|
133
|
+
quota_threshold: null,
|
|
134
|
+
weight: 7,
|
|
135
|
+
keepalive_enabled: false,
|
|
134
136
|
},
|
|
137
|
+
{ ...baseToken, name: `replace-second-${accountUuid}`, credentials, keepalive_enabled: true },
|
|
135
138
|
]);
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
const refreshed = { ...credentials, accessToken: "refreshed-access-token" };
|
|
140
|
+
try {
|
|
141
|
+
await TokenModel.replaceByAccount("anthropic", accountUuid, {
|
|
142
|
+
...baseToken,
|
|
143
|
+
name: `replace-first-${accountUuid}`,
|
|
144
|
+
credentials: refreshed,
|
|
145
|
+
});
|
|
146
|
+
const rows = await TokenModel.findByAccountIdentifier("A", "anthropic", accountUuid);
|
|
147
|
+
expect(rows).toHaveLength(1);
|
|
148
|
+
expect(ids).not.toContain(rows[0]!.id);
|
|
149
|
+
expect(rows[0]).toMatchObject({
|
|
150
|
+
credentials: refreshed,
|
|
151
|
+
active: true,
|
|
152
|
+
reauth_required: false,
|
|
153
|
+
ord: 9,
|
|
154
|
+
quota_threshold: null,
|
|
155
|
+
weight: 7,
|
|
156
|
+
keepalive_enabled: true,
|
|
157
|
+
});
|
|
158
|
+
} finally {
|
|
159
|
+
const rows = await TokenModel.findByAccountIdentifier("A", "anthropic", accountUuid);
|
|
160
|
+
await TokenModel.del(rows.map((row) => row.id));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
138
163
|
|
|
139
|
-
|
|
164
|
+
it("rolls back the deletion when the replacement violates a database constraint", async () => {
|
|
165
|
+
const accountUuid = randomUUID();
|
|
166
|
+
const original = {
|
|
167
|
+
...baseToken,
|
|
168
|
+
name: `rollback-${accountUuid}`,
|
|
169
|
+
credentials: { ...baseToken.credentials, accountUuid },
|
|
170
|
+
quota_threshold: 42,
|
|
171
|
+
weight: 3,
|
|
172
|
+
ord: 8,
|
|
173
|
+
keepalive_enabled: true,
|
|
174
|
+
};
|
|
175
|
+
const [id] = await TokenModel.save([original]);
|
|
176
|
+
try {
|
|
177
|
+
await expect(
|
|
178
|
+
TokenModel.replaceByAccount("anthropic", accountUuid, {
|
|
179
|
+
...original,
|
|
180
|
+
provider: "x".repeat(21),
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow();
|
|
183
|
+
await expect(TokenModel.findById("A", id!)).resolves.toMatchObject(original);
|
|
184
|
+
} finally {
|
|
185
|
+
await TokenModel.del([id!]);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
140
188
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
it("loads every active or expired token when a pool exceeds 100 accounts", async () => {
|
|
190
|
+
const suffix = randomUUID();
|
|
191
|
+
const ids = await TokenModel.save(
|
|
192
|
+
Array.from({ length: 101 }, (_, index) => ({
|
|
193
|
+
...baseToken,
|
|
194
|
+
name: `pool-${suffix}-${index}`,
|
|
195
|
+
credentials: { ...baseToken.credentials, accountUuid: suffix },
|
|
196
|
+
reauth_required: true,
|
|
197
|
+
})),
|
|
198
|
+
);
|
|
199
|
+
try {
|
|
200
|
+
for (const rows of [
|
|
201
|
+
await TokenModel.findActive("A"),
|
|
202
|
+
await TokenModel.findActiveByProvider("A", "anthropic"),
|
|
203
|
+
await TokenModel.findReauthRequired("A"),
|
|
204
|
+
await TokenModel.findByAccountIdentifier("A", "anthropic", suffix),
|
|
205
|
+
]) {
|
|
206
|
+
expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining(ids));
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
await TokenModel.del(ids);
|
|
210
|
+
}
|
|
144
211
|
});
|
|
145
212
|
});
|
|
146
213
|
|
|
@@ -176,7 +243,7 @@ describe("TokenModel.findActiveByProviderAndName", () => {
|
|
|
176
243
|
["tokens.name", "anthropic/tok-A"],
|
|
177
244
|
]);
|
|
178
245
|
expect(executeSubsetQuery).toHaveBeenCalledWith(
|
|
179
|
-
expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1 } }),
|
|
246
|
+
expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1, queryMode: "list" } }),
|
|
180
247
|
);
|
|
181
248
|
});
|
|
182
249
|
});
|