@cartanova/qgrid-cli 2.4.3 → 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.
Files changed (89) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
  2. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  4. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  5. package/bundle/dist/application/sonamu.generated.js +5 -3
  6. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  7. package/bundle/dist/application/token/token.model.js +8 -3
  8. package/bundle/dist/application/token/token.types.js +8 -3
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/index.js +3 -6
  11. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  12. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  13. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  14. package/bundle/dist/server-bootstrap.js +17 -0
  15. package/bundle/dist/sonamu.config.js +1 -10
  16. package/bundle/dist/startup-migrations.js +21 -0
  17. package/bundle/dist/testing/global.js +93 -5
  18. package/bundle/dist/testing/test-database-name.js +22 -0
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  20. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  21. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  22. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  23. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  24. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  25. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  26. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  27. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  28. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  29. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  30. package/bundle/src/application/sonamu.generated.http +4 -2
  31. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  32. package/bundle/src/application/sonamu.generated.ts +10 -1
  33. package/bundle/src/application/token/token.entity.json +17 -1
  34. package/bundle/src/application/token/token.model.test.ts +43 -0
  35. package/bundle/src/application/token/token.model.ts +18 -2
  36. package/bundle/src/application/token/token.types.test.ts +11 -0
  37. package/bundle/src/application/token/token.types.ts +3 -0
  38. package/bundle/src/i18n/sd.generated.ts +1 -0
  39. package/bundle/src/index.ts +2 -5
  40. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  41. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  42. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  43. package/bundle/src/server-bootstrap.test.ts +39 -0
  44. package/bundle/src/server-bootstrap.ts +21 -0
  45. package/bundle/src/sonamu.config.ts +0 -12
  46. package/bundle/src/startup-migrations.test.ts +25 -0
  47. package/bundle/src/startup-migrations.ts +20 -0
  48. package/bundle/src/testing/global.test.ts +130 -0
  49. package/bundle/src/testing/global.ts +143 -13
  50. package/bundle/src/testing/test-database-name.test.ts +38 -0
  51. package/bundle/src/testing/test-database-name.ts +21 -0
  52. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  53. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  54. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  55. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  56. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  57. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  58. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  59. package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D1ExP77_.js} +3 -3
  60. package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-vuI-Zgnq.js} +1 -1
  61. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  62. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  63. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  64. package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-BreAgaFH.js} +1 -1
  65. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  66. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  67. package/bundle/web-dist/client/index.html +2 -2
  68. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  69. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  70. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  71. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  72. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  73. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  74. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  75. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  76. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  77. package/package.json +1 -1
  78. package/skills/qgrid/SKILL.md +1 -1
  79. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  80. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  81. package/skills/qgrid/references/decision-rationale.md +23 -0
  82. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  83. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  84. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  85. package/skills/qgrid/references/repo-map.md +2 -0
  86. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  87. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  88. package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
  89. package/bundle/web-dist/client/assets/tokens-BMsL6vk9.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", "tanstack-mutation"] })
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
- const entry = await TokenModel.findOne("A", { id });
414
- if (!entry) return { updated: false };
415
-
416
- const patch: TokenSaveParamsType = {
417
- id: entry.id,
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.save([parsed.data]);
435
- return { updated: true };
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(payload.id, row.name, creds as OpenAICredentials, row.quota_threshold)
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(payload.id, row.name, creds as OpenAICredentials, row.quota_threshold)
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 SETUP_SQL = `
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(SETUP_SQL);
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 { ...result, content: appendImages(content, images), finishReason: "tool-calls", runContext };
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
  ###
@@ -147,6 +147,7 @@ export const tokenSubsetQueries = {
147
147
  active: "tokens.active",
148
148
  ord: "tokens.ord",
149
149
  quota_threshold: "tokens.quota_threshold",
150
+ weight: "tokens.weight",
150
151
  });
151
152
  },
152
153
  };
@@ -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 ["created_at", "active", "ord", "quota_threshold", "id"];
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": ["id", "created_at", "provider", "credentials", "name", "active", "ord", "quota_threshold"]
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 || sp.quota_threshold !== undefined) return sp;
20
- return { ...sp, quota_threshold: DEFAULT_QUOTA_THRESHOLD };
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",
@@ -1,6 +1,3 @@
1
- import { Sonamu } from "sonamu";
1
+ import { bootstrapServer } from "./server-bootstrap";
2
2
 
3
- async function bootstrap() {
4
- await Sonamu.createServer();
5
- }
6
- bootstrap();
3
+ void bootstrapServer();
@@ -1,13 +1,9 @@
1
1
  import { type Knex } from "knex";
2
2
 
3
- export async function up(knex: Knex): Promise<void> {
4
- await knex.raw(
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(knex: Knex): Promise<void> {
10
- await knex.schema.alterTable("request_log_steps", (table) => {
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
  }
@@ -1,4 +1,4 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("request_logs", (table) => {