@cartanova/qgrid-cli 2.4.2 → 2.4.4
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/qgrid.frame.js +13 -17
- package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
- package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +8 -3
- package/bundle/dist/application/token/token.types.js +8 -3
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/index.js +3 -6
- package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
- package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
- package/bundle/dist/server-bootstrap.js +17 -0
- package/bundle/dist/sonamu.config.js +2 -10
- package/bundle/dist/startup-migrations.js +21 -0
- package/bundle/dist/testing/global.js +93 -5
- package/bundle/dist/testing/test-database-name.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
- package/bundle/dist/utils/providers/common/model-cost.js +22 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
- package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
- package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +10 -1
- package/bundle/src/application/token/token.entity.json +17 -1
- package/bundle/src/application/token/token.model.test.ts +43 -0
- package/bundle/src/application/token/token.model.ts +18 -2
- package/bundle/src/application/token/token.types.test.ts +11 -0
- package/bundle/src/application/token/token.types.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/index.ts +2 -5
- package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
- package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
- package/bundle/src/server-bootstrap.test.ts +39 -0
- package/bundle/src/server-bootstrap.ts +21 -0
- package/bundle/src/sonamu.config.ts +3 -12
- package/bundle/src/startup-migrations.test.ts +25 -0
- package/bundle/src/startup-migrations.ts +20 -0
- package/bundle/src/testing/global.test.ts +130 -0
- package/bundle/src/testing/global.ts +143 -13
- package/bundle/src/testing/test-database-name.test.ts +38 -0
- package/bundle/src/testing/test-database-name.ts +21 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
- package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
- package/bundle/src/utils/providers/common/model-cost.ts +31 -0
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
- package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
- package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
- package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
- package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
- package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
- package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
- package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/openai-codex-runtime.md +4 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
- package/skills/qgrid/references/provider-runtime-differences.md +2 -1
- package/skills/qgrid/references/repo-map.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
- package/skills/qgrid/references/verification-and-debugging.md +3 -0
- package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
- package/bundle/web-dist/client/assets/tokens-XAhJyLqj.js +0 -1
|
@@ -13,12 +13,8 @@ import {
|
|
|
13
13
|
} from "../../utils/providers/common/credentials";
|
|
14
14
|
import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
|
|
15
15
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
16
|
-
import { TokenModel } from "../token/token.model";
|
|
17
|
-
import {
|
|
18
|
-
TokenCredentials,
|
|
19
|
-
TokenSaveParams,
|
|
20
|
-
type TokenSaveParams as TokenSaveParamsType,
|
|
21
|
-
} from "../token/token.types";
|
|
16
|
+
import { TokenModel, type TokenUpdateFields } from "../token/token.model";
|
|
17
|
+
import { TokenCredentials } from "../token/token.types";
|
|
22
18
|
import {
|
|
23
19
|
type AnthropicUsageRaw,
|
|
24
20
|
buildAuthUrl,
|
|
@@ -404,35 +400,36 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
404
400
|
return { added: true };
|
|
405
401
|
}
|
|
406
402
|
|
|
407
|
-
@api({ httpMethod: "POST", clients: ["axios"
|
|
403
|
+
@api({ httpMethod: "POST", clients: ["axios"] })
|
|
408
404
|
async updateToken(
|
|
409
405
|
id: number,
|
|
410
406
|
name?: string,
|
|
411
407
|
quotaThreshold?: number | null,
|
|
408
|
+
weight?: number,
|
|
412
409
|
): Promise<{ updated: boolean }> {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
provider: entry.provider,
|
|
419
|
-
credentials: entry.credentials,
|
|
420
|
-
name: name !== undefined ? name : entry.name,
|
|
421
|
-
};
|
|
422
|
-
if (quotaThreshold !== undefined) {
|
|
423
|
-
patch.quota_threshold = quotaThreshold;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const parsed = TokenSaveParams.safeParse(patch);
|
|
427
|
-
if (!parsed.success) {
|
|
410
|
+
if (
|
|
411
|
+
quotaThreshold !== undefined &&
|
|
412
|
+
quotaThreshold !== null &&
|
|
413
|
+
(!Number.isInteger(quotaThreshold) || quotaThreshold < 1 || quotaThreshold > 100)
|
|
414
|
+
) {
|
|
428
415
|
throw new BadRequestException(
|
|
429
416
|
"quotaThreshold must be an integer between 1 and 100, or null" as LocalizedString,
|
|
430
|
-
{ zodError: parsed.error },
|
|
431
417
|
);
|
|
432
418
|
}
|
|
419
|
+
if (weight !== undefined && (!Number.isInteger(weight) || weight < 1 || weight > 100)) {
|
|
420
|
+
throw new BadRequestException(
|
|
421
|
+
"weight must be an integer between 1 and 100" as LocalizedString,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const patch: TokenUpdateFields = {};
|
|
426
|
+
if (name !== undefined) patch.name = name;
|
|
427
|
+
if (quotaThreshold !== undefined) patch.quota_threshold = quotaThreshold;
|
|
428
|
+
if (weight !== undefined) patch.weight = weight;
|
|
429
|
+
if (Object.keys(patch).length === 0) return { updated: false };
|
|
433
430
|
|
|
434
|
-
await TokenModel.
|
|
435
|
-
return { updated:
|
|
431
|
+
const updated = await TokenModel.updateFields(id, patch);
|
|
432
|
+
return { updated: updated > 0 };
|
|
436
433
|
}
|
|
437
434
|
|
|
438
435
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
@@ -22,6 +22,7 @@ function openaiToken(active = true) {
|
|
|
22
22
|
name: "tok-A",
|
|
23
23
|
credentials: { accessToken: "access", accountId: "account" },
|
|
24
24
|
quota_threshold: 80,
|
|
25
|
+
weight: 4,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -70,9 +71,73 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
70
71
|
"tok-A",
|
|
71
72
|
{ accessToken: "access", accountId: "account" },
|
|
72
73
|
80,
|
|
74
|
+
4,
|
|
73
75
|
);
|
|
74
76
|
});
|
|
75
77
|
|
|
78
|
+
it("passes weight to Anthropic token updates", async () => {
|
|
79
|
+
const anthropicDispatcher = { onTokenUpdated: vi.fn() };
|
|
80
|
+
const subscriber = new TokenSubscriber(
|
|
81
|
+
{} as never,
|
|
82
|
+
{
|
|
83
|
+
removeCache: vi.fn(),
|
|
84
|
+
upsertCache: vi.fn(),
|
|
85
|
+
replaceCache: vi.fn(),
|
|
86
|
+
openaiDispatcher: null,
|
|
87
|
+
anthropicDispatcher,
|
|
88
|
+
} as never,
|
|
89
|
+
);
|
|
90
|
+
findOneMock.mockResolvedValueOnce({
|
|
91
|
+
...openaiToken(true),
|
|
92
|
+
provider: "anthropic",
|
|
93
|
+
credentials: { accessToken: "access", refreshToken: "refresh" },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: 1 }));
|
|
97
|
+
|
|
98
|
+
expect(anthropicDispatcher.onTokenUpdated).toHaveBeenCalledWith(
|
|
99
|
+
1,
|
|
100
|
+
"tok-A",
|
|
101
|
+
{ accessToken: "access", refreshToken: "refresh" },
|
|
102
|
+
80,
|
|
103
|
+
4,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("serializes token reloads so rapid weight updates cannot apply backward", async () => {
|
|
108
|
+
let resolveFirst!: (value: ReturnType<typeof openaiToken>) => void;
|
|
109
|
+
const firstRow = new Promise<ReturnType<typeof openaiToken>>((resolve) => {
|
|
110
|
+
resolveFirst = resolve;
|
|
111
|
+
});
|
|
112
|
+
const anthropicDispatcher = { onTokenUpdated: vi.fn() };
|
|
113
|
+
const subscriber = new TokenSubscriber(
|
|
114
|
+
{} as never,
|
|
115
|
+
{
|
|
116
|
+
removeCache: vi.fn(),
|
|
117
|
+
upsertCache: vi.fn(),
|
|
118
|
+
replaceCache: vi.fn(),
|
|
119
|
+
openaiDispatcher: null,
|
|
120
|
+
anthropicDispatcher,
|
|
121
|
+
} as never,
|
|
122
|
+
);
|
|
123
|
+
findOneMock.mockReturnValueOnce(firstRow).mockResolvedValueOnce({
|
|
124
|
+
...openaiToken(true),
|
|
125
|
+
provider: "anthropic",
|
|
126
|
+
weight: 5,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const first = subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: 1 }));
|
|
130
|
+
const second = subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: 1 }));
|
|
131
|
+
|
|
132
|
+
await flushPromises();
|
|
133
|
+
expect(findOneMock).toHaveBeenCalledTimes(1);
|
|
134
|
+
|
|
135
|
+
resolveFirst({ ...openaiToken(true), provider: "anthropic", weight: 2 });
|
|
136
|
+
await Promise.all([first, second]);
|
|
137
|
+
|
|
138
|
+
expect(anthropicDispatcher.onTokenUpdated.mock.calls.map((call) => call[4])).toEqual([2, 5]);
|
|
139
|
+
});
|
|
140
|
+
|
|
76
141
|
it("does not spawn workers for inactive OpenAI inserts", async () => {
|
|
77
142
|
const openaiDispatcher = {
|
|
78
143
|
onTokenAdded: vi.fn(async () => {}),
|
|
@@ -86,4 +151,41 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
86
151
|
expect(openaiDispatcher.onTokenAdded).not.toHaveBeenCalled();
|
|
87
152
|
expect(openaiDispatcher.onTokenDeactivated).toHaveBeenCalledWith(1);
|
|
88
153
|
});
|
|
154
|
+
|
|
155
|
+
it("passes weight while reconciling both provider token pools", async () => {
|
|
156
|
+
const anthropicDispatcher = { replaceTokens: vi.fn() };
|
|
157
|
+
const openaiDispatcher = { replaceTokens: vi.fn(async () => {}) };
|
|
158
|
+
const subscriber = new TokenSubscriber(
|
|
159
|
+
{} as never,
|
|
160
|
+
{
|
|
161
|
+
removeCache: vi.fn(),
|
|
162
|
+
upsertCache: vi.fn(),
|
|
163
|
+
replaceCache: vi.fn(),
|
|
164
|
+
openaiDispatcher,
|
|
165
|
+
anthropicDispatcher,
|
|
166
|
+
} as never,
|
|
167
|
+
);
|
|
168
|
+
findActiveMock.mockResolvedValueOnce([
|
|
169
|
+
{
|
|
170
|
+
...openaiToken(true),
|
|
171
|
+
provider: "anthropic",
|
|
172
|
+
credentials: { accessToken: "access-a", refreshToken: "refresh-a" },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
...openaiToken(true),
|
|
176
|
+
id: 2,
|
|
177
|
+
name: "tok-B",
|
|
178
|
+
credentials: { accessToken: "access-b", accountId: "account-b" },
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
await subscriber.reconcile();
|
|
183
|
+
|
|
184
|
+
expect(anthropicDispatcher.replaceTokens).toHaveBeenCalledWith([
|
|
185
|
+
expect.objectContaining({ id: 1, quotaThreshold: 80, weight: 4 }),
|
|
186
|
+
]);
|
|
187
|
+
expect(openaiDispatcher.replaceTokens).toHaveBeenCalledWith([
|
|
188
|
+
expect.objectContaining({ id: 2, quotaThreshold: 80, weight: 4 }),
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
89
191
|
});
|
|
@@ -25,6 +25,7 @@ export class TokenSubscriber {
|
|
|
25
25
|
attempt = 0;
|
|
26
26
|
connectedAt: Date | null = null;
|
|
27
27
|
lastReconcileAt: Date | null = null;
|
|
28
|
+
private operationChain: Promise<void> = Promise.resolve();
|
|
28
29
|
|
|
29
30
|
constructor(
|
|
30
31
|
public connConfig: ClientConfig,
|
|
@@ -151,10 +152,14 @@ export class TokenSubscriber {
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
async handleNotification(payloadJson: string): Promise<void> {
|
|
155
|
+
return this.enqueueOperation(() => this.handleNotificationNow(payloadJson));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async handleNotificationNow(payloadJson: string): Promise<void> {
|
|
154
159
|
const payload = JSON.parse(payloadJson) as Payload;
|
|
155
160
|
if (payload.op === "DELETE") {
|
|
156
161
|
this.dispatcher.removeCache(payload.id);
|
|
157
|
-
this.dispatcher.openaiDispatcher
|
|
162
|
+
await this.dispatcher.openaiDispatcher
|
|
158
163
|
?.onTokenRemoved(payload.id)
|
|
159
164
|
.catch((e) => logger.warn(`openai worker remove failed: ${(e as Error).message}`));
|
|
160
165
|
// anthropic 토큰 이벤트는 동기(void) — provider 를 모르므로 무해하게 항상 제거 시도.
|
|
@@ -165,7 +170,7 @@ export class TokenSubscriber {
|
|
|
165
170
|
const row = await TokenModel.findOne("A", { id: payload.id });
|
|
166
171
|
if (!row) {
|
|
167
172
|
this.dispatcher.removeCache(payload.id);
|
|
168
|
-
this.dispatcher.openaiDispatcher
|
|
173
|
+
await this.dispatcher.openaiDispatcher
|
|
169
174
|
?.onTokenRemoved(payload.id)
|
|
170
175
|
.catch((e) => logger.warn(`openai worker remove failed: ${(e as Error).message}`));
|
|
171
176
|
this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
|
|
@@ -179,13 +184,25 @@ export class TokenSubscriber {
|
|
|
179
184
|
const creds = row.credentials as Record<string, unknown>;
|
|
180
185
|
const openaiDispatcher = this.dispatcher.openaiDispatcher;
|
|
181
186
|
if (payload.op === "INSERT" && row.active) {
|
|
182
|
-
openaiDispatcher
|
|
183
|
-
?.onTokenAdded(
|
|
187
|
+
await openaiDispatcher
|
|
188
|
+
?.onTokenAdded(
|
|
189
|
+
payload.id,
|
|
190
|
+
row.name,
|
|
191
|
+
creds as OpenAICredentials,
|
|
192
|
+
row.quota_threshold,
|
|
193
|
+
row.weight,
|
|
194
|
+
)
|
|
184
195
|
.catch((e) => logger.warn(`openai worker spawn failed: ${(e as Error).message}`));
|
|
185
196
|
} else if (row.active) {
|
|
186
197
|
if (openaiDispatcher) {
|
|
187
|
-
openaiDispatcher
|
|
188
|
-
.onTokenUpdated(
|
|
198
|
+
await openaiDispatcher
|
|
199
|
+
.onTokenUpdated(
|
|
200
|
+
payload.id,
|
|
201
|
+
row.name,
|
|
202
|
+
creds as OpenAICredentials,
|
|
203
|
+
row.quota_threshold,
|
|
204
|
+
row.weight,
|
|
205
|
+
)
|
|
189
206
|
.then(() => openaiDispatcher.onTokenActivated(payload.id))
|
|
190
207
|
.catch((e) => logger.warn(`openai worker update failed: ${(e as Error).message}`));
|
|
191
208
|
}
|
|
@@ -203,6 +220,7 @@ export class TokenSubscriber {
|
|
|
203
220
|
row.name,
|
|
204
221
|
creds,
|
|
205
222
|
row.quota_threshold,
|
|
223
|
+
row.weight,
|
|
206
224
|
);
|
|
207
225
|
} else if (row.active) {
|
|
208
226
|
this.dispatcher.anthropicDispatcher?.onTokenUpdated(
|
|
@@ -210,6 +228,7 @@ export class TokenSubscriber {
|
|
|
210
228
|
row.name,
|
|
211
229
|
creds,
|
|
212
230
|
row.quota_threshold,
|
|
231
|
+
row.weight,
|
|
213
232
|
);
|
|
214
233
|
} else {
|
|
215
234
|
// inactive 면 INSERT 든 UPDATE 든 풀에 넣지 않는다.
|
|
@@ -220,6 +239,10 @@ export class TokenSubscriber {
|
|
|
220
239
|
}
|
|
221
240
|
|
|
222
241
|
async reconcile(): Promise<void> {
|
|
242
|
+
return this.enqueueOperation(() => this.reconcileNow());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async reconcileNow(): Promise<void> {
|
|
223
246
|
const rows = await TokenModel.findActive("A");
|
|
224
247
|
this.dispatcher.replaceCache(rows);
|
|
225
248
|
// NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
|
|
@@ -231,6 +254,7 @@ export class TokenSubscriber {
|
|
|
231
254
|
name: r.name,
|
|
232
255
|
credentials: r.credentials as AnthropicCredentials,
|
|
233
256
|
quotaThreshold: r.quota_threshold,
|
|
257
|
+
weight: r.weight,
|
|
234
258
|
}));
|
|
235
259
|
this.dispatcher.anthropicDispatcher?.replaceTokens(anthropicRows);
|
|
236
260
|
const openaiRows = rows
|
|
@@ -240,10 +264,17 @@ export class TokenSubscriber {
|
|
|
240
264
|
name: r.name,
|
|
241
265
|
credentials: r.credentials as OpenAICredentials,
|
|
242
266
|
quotaThreshold: r.quota_threshold,
|
|
267
|
+
weight: r.weight,
|
|
243
268
|
}));
|
|
244
269
|
await this.dispatcher.openaiDispatcher
|
|
245
270
|
?.replaceTokens(openaiRows)
|
|
246
271
|
.catch((e) => logger.warn(`openai reconcile failed: ${(e as Error).message}`));
|
|
247
272
|
this.lastReconcileAt = new Date();
|
|
248
273
|
}
|
|
274
|
+
|
|
275
|
+
private enqueueOperation(operation: () => Promise<void>): Promise<void> {
|
|
276
|
+
const next = this.operationChain.then(operation, operation);
|
|
277
|
+
this.operationChain = next.catch(() => {});
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
249
280
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { TOKENS_TRIGGER_SETUP_SQL } from "./token-trigger-setup";
|
|
4
|
+
|
|
5
|
+
describe("tokens trigger setup", () => {
|
|
6
|
+
it("leaves weight notifications to the versioned migration trigger", () => {
|
|
7
|
+
expect(TOKENS_TRIGGER_SETUP_SQL).not.toContain("OLD.weight IS DISTINCT FROM NEW.weight");
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -11,7 +11,7 @@ const SETUP_STATEMENT_TIMEOUT_MS = 10_000;
|
|
|
11
11
|
const ADVISORY_LOCK_CLASS_ID = 717;
|
|
12
12
|
const ADVISORY_LOCK_OBJECT_ID = 44900;
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
export const TOKENS_TRIGGER_SETUP_SQL = `
|
|
15
15
|
CREATE OR REPLACE FUNCTION public.tokens_notify() RETURNS trigger
|
|
16
16
|
LANGUAGE plpgsql
|
|
17
17
|
SET search_path = pg_catalog, public
|
|
@@ -64,7 +64,7 @@ export async function ensureTokensTrigger(connConfig: ClientConfig): Promise<voi
|
|
|
64
64
|
]);
|
|
65
65
|
lockAcquired = true;
|
|
66
66
|
|
|
67
|
-
await client.query(
|
|
67
|
+
await client.query(TOKENS_TRIGGER_SETUP_SQL);
|
|
68
68
|
logger.info("trigger ensured (public.tokens_notify + tokens_changed_* triggers)");
|
|
69
69
|
} finally {
|
|
70
70
|
if (lockAcquired) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { down, up } from "../../migrations/20260710090000_alter_tokens_add_weight";
|
|
5
|
+
|
|
6
|
+
function fakeKnex() {
|
|
7
|
+
const raw = vi.fn().mockResolvedValue(undefined);
|
|
8
|
+
const table = {
|
|
9
|
+
integer: vi.fn(() => ({
|
|
10
|
+
notNullable: vi.fn(() => ({ defaultTo: vi.fn() })),
|
|
11
|
+
})),
|
|
12
|
+
dropColumns: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
const alterTable = vi.fn(async (_name: string, callback: (value: typeof table) => void) => {
|
|
15
|
+
callback(table);
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
knex: { raw, schema: { alterTable } } as unknown as Knex,
|
|
19
|
+
raw,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("tokens weight migration", () => {
|
|
24
|
+
it("installs a versioned trigger that old bootstrap code cannot replace", async () => {
|
|
25
|
+
const { knex, raw } = fakeKnex();
|
|
26
|
+
|
|
27
|
+
await up(knex);
|
|
28
|
+
|
|
29
|
+
expect(raw).toHaveBeenCalledWith(expect.stringContaining("tokens_weight_changed_upd"));
|
|
30
|
+
expect(raw).toHaveBeenCalledWith(expect.stringContaining("tokens_weight_notify"));
|
|
31
|
+
expect(raw).toHaveBeenCalledWith(expect.stringContaining("OLD.weight IS DISTINCT FROM NEW.weight"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("removes the versioned trigger before dropping the weight column", async () => {
|
|
35
|
+
const { knex, raw } = fakeKnex();
|
|
36
|
+
|
|
37
|
+
await down(knex);
|
|
38
|
+
|
|
39
|
+
expect(raw).toHaveBeenCalledWith(expect.stringContaining("DROP TRIGGER"));
|
|
40
|
+
expect(raw).toHaveBeenCalledWith(expect.stringContaining("tokens_weight_changed_upd"));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -131,7 +131,12 @@ export function applyToolCallEmulation(
|
|
|
131
131
|
input: toolCall.args,
|
|
132
132
|
};
|
|
133
133
|
});
|
|
134
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
content: appendImages(content, images),
|
|
137
|
+
finishReason: "tool-calls",
|
|
138
|
+
runContext,
|
|
139
|
+
};
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
const text = parsed.answer ?? result.text;
|
|
@@ -40,7 +40,8 @@ Content-Type: application/json
|
|
|
40
40
|
"name": "NAME",
|
|
41
41
|
"active": false,
|
|
42
42
|
"ord": 0,
|
|
43
|
-
"quota_threshold": null
|
|
43
|
+
"quota_threshold": null,
|
|
44
|
+
"weight": 1
|
|
44
45
|
}
|
|
45
46
|
]
|
|
46
47
|
}
|
|
@@ -441,7 +442,8 @@ Content-Type: application/json
|
|
|
441
442
|
{
|
|
442
443
|
"id": 0,
|
|
443
444
|
"name": "NAME",
|
|
444
|
-
"quotaThreshold": null
|
|
445
|
+
"quotaThreshold": null,
|
|
446
|
+
"weight": 0
|
|
445
447
|
}
|
|
446
448
|
|
|
447
449
|
###
|
|
@@ -196,9 +196,17 @@ export const TokenBaseSchema = z.object({
|
|
|
196
196
|
active: z.boolean(),
|
|
197
197
|
ord: z.int(),
|
|
198
198
|
quota_threshold: z.int().nullable(),
|
|
199
|
+
weight: z.int(),
|
|
199
200
|
});
|
|
200
201
|
export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
201
|
-
readonly __hasDefault__: readonly [
|
|
202
|
+
readonly __hasDefault__: readonly [
|
|
203
|
+
"created_at",
|
|
204
|
+
"active",
|
|
205
|
+
"ord",
|
|
206
|
+
"quota_threshold",
|
|
207
|
+
"weight",
|
|
208
|
+
"id",
|
|
209
|
+
];
|
|
202
210
|
};
|
|
203
211
|
|
|
204
212
|
// BaseListParams: RequestLog
|
|
@@ -372,6 +380,7 @@ export const TokenSubsetA = z.object({
|
|
|
372
380
|
active: z.boolean(),
|
|
373
381
|
ord: z.int(),
|
|
374
382
|
quota_threshold: z.int().nullable(),
|
|
383
|
+
weight: z.int(),
|
|
375
384
|
});
|
|
376
385
|
export type TokenSubsetA = z.infer<typeof TokenSubsetA>;
|
|
377
386
|
export type TokenSubsetMapping = {
|
|
@@ -20,11 +20,27 @@
|
|
|
20
20
|
"type": "integer",
|
|
21
21
|
"nullable": true,
|
|
22
22
|
"desc": "quota 사용률 임계치(%). null이면 미설정"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "weight",
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"desc": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
28
|
+
"dbDefault": "1"
|
|
23
29
|
}
|
|
24
30
|
],
|
|
25
31
|
"indexes": [],
|
|
26
32
|
"subsets": {
|
|
27
|
-
"A": [
|
|
33
|
+
"A": [
|
|
34
|
+
"id",
|
|
35
|
+
"created_at",
|
|
36
|
+
"provider",
|
|
37
|
+
"credentials",
|
|
38
|
+
"name",
|
|
39
|
+
"active",
|
|
40
|
+
"ord",
|
|
41
|
+
"quota_threshold",
|
|
42
|
+
"weight"
|
|
43
|
+
]
|
|
28
44
|
},
|
|
29
45
|
"enums": {
|
|
30
46
|
"TokenOrderBy": { "id-desc": "ID최신순", "ord-asc": "순서순" },
|
|
@@ -66,4 +66,47 @@ describe("TokenModel.save", () => {
|
|
|
66
66
|
const saved = ubRegister.mock.calls[0]?.[1];
|
|
67
67
|
expect(saved).not.toHaveProperty("quota_threshold");
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
it("applies weight 1 to newly created tokens by default", async () => {
|
|
71
|
+
const { ubRegister } = mockWritePuri();
|
|
72
|
+
|
|
73
|
+
await TokenModel.save([baseToken]);
|
|
74
|
+
|
|
75
|
+
expect(ubRegister).toHaveBeenCalledWith(
|
|
76
|
+
"tokens",
|
|
77
|
+
expect.objectContaining({ weight: 1 }),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("preserves an explicit create weight and does not inject weight into updates", async () => {
|
|
82
|
+
const { ubRegister } = mockWritePuri();
|
|
83
|
+
|
|
84
|
+
await TokenModel.save([{ ...baseToken, weight: 4 }, { ...baseToken, id: 1 }]);
|
|
85
|
+
|
|
86
|
+
expect(ubRegister.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ weight: 4 }));
|
|
87
|
+
expect(ubRegister.mock.calls[1]?.[1]).not.toHaveProperty("weight");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("TokenModel.updateFields", () => {
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("updates only the supplied columns and returns the affected row count", async () => {
|
|
97
|
+
const update = vi.fn().mockResolvedValue(1);
|
|
98
|
+
const where = vi.fn(() => ({ update }));
|
|
99
|
+
const table = vi.fn(() => ({ where }));
|
|
100
|
+
const transaction = vi.fn(async (callback: (trx: { table: typeof table }) => Promise<number>) =>
|
|
101
|
+
callback({ table }),
|
|
102
|
+
);
|
|
103
|
+
vi.spyOn(TokenModel as unknown as { getPuri: (mode: "w") => MockPuri }, "getPuri")
|
|
104
|
+
.mockReturnValue({ transaction } as unknown as MockPuri);
|
|
105
|
+
|
|
106
|
+
await expect(TokenModel.updateFields(1, { weight: 4 })).resolves.toBe(1);
|
|
107
|
+
|
|
108
|
+
expect(table).toHaveBeenCalledWith("tokens");
|
|
109
|
+
expect(where).toHaveBeenCalledWith("id", 1);
|
|
110
|
+
expect(update).toHaveBeenCalledWith({ weight: 4 });
|
|
111
|
+
});
|
|
69
112
|
});
|
|
@@ -14,10 +14,21 @@ import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso"
|
|
|
14
14
|
import { type TokenListParams, type TokenSaveParams } from "./token.types";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_QUOTA_THRESHOLD = 80;
|
|
17
|
+
const DEFAULT_WEIGHT = 1;
|
|
18
|
+
|
|
19
|
+
export type TokenUpdateFields = {
|
|
20
|
+
name?: string;
|
|
21
|
+
quota_threshold?: number | null;
|
|
22
|
+
weight?: number;
|
|
23
|
+
};
|
|
17
24
|
|
|
18
25
|
function applyCreateDefaults(sp: TokenSaveParams): TokenSaveParams {
|
|
19
|
-
if (sp.id !== undefined
|
|
20
|
-
return {
|
|
26
|
+
if (sp.id !== undefined) return sp;
|
|
27
|
+
return {
|
|
28
|
+
...sp,
|
|
29
|
+
...(sp.quota_threshold === undefined ? { quota_threshold: DEFAULT_QUOTA_THRESHOLD } : {}),
|
|
30
|
+
...(sp.weight === undefined ? { weight: DEFAULT_WEIGHT } : {}),
|
|
31
|
+
};
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
class TokenModelClass extends BaseModelClass<
|
|
@@ -157,6 +168,11 @@ class TokenModelClass extends BaseModelClass<
|
|
|
157
168
|
});
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
async updateFields(id: number, fields: TokenUpdateFields): Promise<number> {
|
|
172
|
+
const wdb = this.getPuri("w");
|
|
173
|
+
return wdb.transaction((trx) => trx.table("tokens").where("id", id).update(fields));
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
161
177
|
async reorder(ids: number[]): Promise<{ done: boolean }> {
|
|
162
178
|
const wdb = this.getPuri("w");
|
|
@@ -31,4 +31,15 @@ describe("TokenSaveParams", () => {
|
|
|
31
31
|
expect(() => TokenSaveParams.parse({ ...baseToken, quota_threshold: 0 })).toThrow();
|
|
32
32
|
expect(() => TokenSaveParams.parse({ ...baseToken, quota_threshold: 101 })).toThrow();
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it("accepts integer weights from 1 through 100", () => {
|
|
36
|
+
expect(TokenSaveParams.parse({ ...baseToken, weight: 1 }).weight).toBe(1);
|
|
37
|
+
expect(TokenSaveParams.parse({ ...baseToken, weight: 100 }).weight).toBe(100);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects invalid weights", () => {
|
|
41
|
+
for (const weight of [0, 101, 1.5]) {
|
|
42
|
+
expect(() => TokenSaveParams.parse({ ...baseToken, weight })).toThrow();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
34
45
|
});
|
|
@@ -31,6 +31,7 @@ export const TokenListParams = TokenBaseListParams;
|
|
|
31
31
|
export type TokenListParams = z.infer<typeof TokenListParams>;
|
|
32
32
|
|
|
33
33
|
const TokenQuotaThreshold = z.int().min(1).max(100).nullable();
|
|
34
|
+
const TokenWeight = z.int().min(1).max(100);
|
|
34
35
|
|
|
35
36
|
// Token - SaveParams
|
|
36
37
|
export const TokenSaveParams = TokenBaseSchema.partial({
|
|
@@ -39,7 +40,9 @@ export const TokenSaveParams = TokenBaseSchema.partial({
|
|
|
39
40
|
active: true,
|
|
40
41
|
ord: true,
|
|
41
42
|
quota_threshold: true,
|
|
43
|
+
weight: true,
|
|
42
44
|
}).extend({
|
|
43
45
|
quota_threshold: TokenQuotaThreshold.optional(),
|
|
46
|
+
weight: TokenWeight.optional(),
|
|
44
47
|
});
|
|
45
48
|
export type TokenSaveParams = z.infer<typeof TokenSaveParams>;
|
|
@@ -276,6 +276,7 @@ const entityLabels = {
|
|
|
276
276
|
"entity.Token.active": "활성 여부",
|
|
277
277
|
"entity.Token.ord": "정렬 순서",
|
|
278
278
|
"entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
|
|
279
|
+
"entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
279
280
|
"enum.TokenOrderBy.id-desc": "ID최신순",
|
|
280
281
|
"enum.TokenOrderBy.ord-asc": "순서순",
|
|
281
282
|
"enum.TokenSearchField.id": "ID",
|
package/bundle/src/index.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { type Knex } from "knex";
|
|
2
2
|
|
|
3
|
-
export async function up(
|
|
4
|
-
|
|
5
|
-
`CREATE INDEX request_log_steps_request_log_id_index ON request_log_steps USING btree(request_log_id ASC NULLS LAST);`,
|
|
6
|
-
);
|
|
3
|
+
export async function up(_knex: Knex): Promise<void> {
|
|
4
|
+
// The preceding create-table migration already creates this index.
|
|
7
5
|
}
|
|
8
6
|
|
|
9
|
-
export async function down(
|
|
10
|
-
|
|
11
|
-
table.dropIndex(["request_log_id"], "request_log_steps_request_log_id_index");
|
|
12
|
-
});
|
|
7
|
+
export async function down(_knex: Knex): Promise<void> {
|
|
8
|
+
// Keep the index owned by the preceding create-table migration.
|
|
13
9
|
}
|