@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
@@ -1,7 +1,8 @@
1
- import { X as __toESM, _ as require_react, f as require_react_dom, g as require_jsx_runtime, h as useQueryClient } from "./sd.generated-DRuQSIKo.js";
2
- import { i as TokenService, t as QgridService } from "./services.generated-Dp0uKgpV.js";
1
+ import { X as __toESM, _ as require_react, f as require_react_dom, g as require_jsx_runtime, h as useQueryClient } from "./sd.generated-Cwg2f53z.js";
2
+ import { i as TokenService, t as QgridService } from "./services.generated-CsotZP44.js";
3
3
  import { r as Q } from "../entry-server.generated.js";
4
4
  import { r as formatUsd } from "./cost-CSH_t17I.js";
5
+ import { t as useUpdateTokenMutation } from "./use-update-token-mutation-BL96w-Pg.js";
5
6
  //#region ../../node_modules/.pnpm/@dnd-kit+utilities@3.2.2_react@19.2.4/node_modules/@dnd-kit/utilities/dist/utilities.esm.js
6
7
  var import_react_dom = /* @__PURE__ */ __toESM(require_react_dom());
7
8
  var import_react = /* @__PURE__ */ __toESM(require_react());
@@ -2318,6 +2319,22 @@ function validateThreshold(raw) {
2318
2319
  value: n === 0 ? null : n
2319
2320
  };
2320
2321
  }
2322
+ function validateWeight(raw) {
2323
+ const trimmed = raw.trim();
2324
+ if (!/^\d+$/.test(trimmed)) return {
2325
+ ok: false,
2326
+ error: "1–100 사이 정수를 입력하세요"
2327
+ };
2328
+ const value = Number(trimmed);
2329
+ if (value < 1 || value > 100) return {
2330
+ ok: false,
2331
+ error: "1–100 사이 정수를 입력하세요"
2332
+ };
2333
+ return {
2334
+ ok: true,
2335
+ value
2336
+ };
2337
+ }
2321
2338
  var PROVIDER_THEMES = {
2322
2339
  openai: {
2323
2340
  stripe: "bg-openai-400",
@@ -2439,7 +2456,7 @@ function ThresholdControl({ token }) {
2439
2456
  const [value, setValue] = (0, import_react.useState)("");
2440
2457
  const triggerRef = (0, import_react.useRef)(null);
2441
2458
  const queryClient = useQueryClient();
2442
- const updateMutation = QgridService.useUpdateTokenMutation();
2459
+ const updateMutation = useUpdateTokenMutation();
2443
2460
  const supported = QUOTA_THRESHOLD_PROVIDERS.has(token.provider);
2444
2461
  const open = pos !== null;
2445
2462
  const validation = validateThreshold(value);
@@ -2474,7 +2491,6 @@ function ThresholdControl({ token }) {
2474
2491
  if (!validation.ok) return;
2475
2492
  await updateMutation.mutateAsync({
2476
2493
  id: token.id,
2477
- name: token.name ?? "",
2478
2494
  quotaThreshold: validation.value
2479
2495
  });
2480
2496
  await Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
@@ -2560,6 +2576,113 @@ function ThresholdControl({ token }) {
2560
2576
  })] })]
2561
2577
  });
2562
2578
  }
2579
+ function WeightControl({ token }) {
2580
+ const [pos, setPos] = (0, import_react.useState)(null);
2581
+ const [value, setValue] = (0, import_react.useState)("");
2582
+ const triggerRef = (0, import_react.useRef)(null);
2583
+ const queryClient = useQueryClient();
2584
+ const updateMutation = useUpdateTokenMutation();
2585
+ const validation = validateWeight(value);
2586
+ const openPopover = () => {
2587
+ const rect = triggerRef.current?.getBoundingClientRect();
2588
+ if (!rect) return;
2589
+ const width = 240;
2590
+ const height = 150;
2591
+ const margin = 8;
2592
+ const gap = 6;
2593
+ const left = Math.min(Math.max(rect.right - width, margin), Math.max(window.innerWidth - width - margin, margin));
2594
+ const above = rect.top - gap - height;
2595
+ const top = Math.min(Math.max(above >= margin ? above : rect.bottom + gap, margin), Math.max(window.innerHeight - height - margin, margin));
2596
+ setValue(String(token.weight));
2597
+ setPos({
2598
+ left,
2599
+ top
2600
+ });
2601
+ };
2602
+ const close = () => setPos(null);
2603
+ const adjust = (delta) => {
2604
+ const current = /^\d+$/.test(value.trim()) ? Number(value.trim()) : 1;
2605
+ setValue(String(Math.min(Math.max(current + delta, 1), 100)));
2606
+ };
2607
+ const save = async () => {
2608
+ if (!validation.ok) return;
2609
+ await updateMutation.mutateAsync({
2610
+ id: token.id,
2611
+ weight: validation.value
2612
+ });
2613
+ await Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
2614
+ close();
2615
+ };
2616
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2617
+ className: "inline-flex",
2618
+ onPointerDown: stopDragPropagation,
2619
+ onClick: stopDragPropagation,
2620
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
2621
+ ref: triggerRef,
2622
+ type: "button",
2623
+ title: "라우팅 가중치 설정",
2624
+ onClick: pos ? close : openPopover,
2625
+ className: "flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] border border-sand-200/80 text-sand-400 hover:text-sand-600 hover:border-sand-300 transition-colors duration-150",
2626
+ children: ["Weight ", token.weight]
2627
+ }), pos && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
2628
+ className: "fixed inset-0 z-40",
2629
+ onPointerDown: close
2630
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2631
+ className: "fixed z-50 w-60 panel shadow-xl p-3",
2632
+ style: pos,
2633
+ children: [
2634
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", {
2635
+ htmlFor: `weight-${token.id}`,
2636
+ className: "text-[10px] uppercase tracking-wider text-sand-500 font-medium",
2637
+ children: "라우팅 가중치"
2638
+ }),
2639
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2640
+ className: "mt-1 flex items-center gap-2",
2641
+ children: [
2642
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
2643
+ type: "button",
2644
+ onClick: () => adjust(-1),
2645
+ className: "size-9 shrink-0 rounded-md border border-sand-200 text-sand-600",
2646
+ children: "−"
2647
+ }),
2648
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Q, {
2649
+ id: `weight-${token.id}`,
2650
+ value,
2651
+ onChange: (event) => setValue(event.target.value),
2652
+ inputMode: "numeric",
2653
+ className: `h-9 w-16 border rounded-md text-center text-sm tabular-nums ${validation.ok ? "border-sand-200" : "border-red-300"}`
2654
+ }),
2655
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
2656
+ type: "button",
2657
+ onClick: () => adjust(1),
2658
+ className: "size-9 shrink-0 rounded-md border border-sand-200 text-sand-600",
2659
+ children: "+"
2660
+ })
2661
+ ]
2662
+ }),
2663
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
2664
+ className: `mt-1.5 text-[11px] ${validation.ok ? "text-sand-500" : "text-red-500"}`,
2665
+ children: validation.ok ? "새 요청의 상대 배정 비율입니다." : validation.error
2666
+ }),
2667
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2668
+ className: "mt-2 flex items-center justify-end gap-1.5",
2669
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
2670
+ type: "button",
2671
+ onClick: close,
2672
+ className: "px-2 py-1 text-[11px]",
2673
+ children: "Cancel"
2674
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
2675
+ type: "button",
2676
+ onClick: save,
2677
+ disabled: updateMutation.isPending || !validation.ok,
2678
+ className: "px-2 py-1 text-[11px] rounded-md bg-sienna-400 text-white disabled:opacity-50",
2679
+ children: updateMutation.isPending ? "Saving..." : "Save"
2680
+ })]
2681
+ })
2682
+ ]
2683
+ })] })]
2684
+ });
2685
+ }
2563
2686
  function SortableTokenCard({ token }) {
2564
2687
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
2565
2688
  id: String(token.id),
@@ -2606,9 +2729,9 @@ function SortableTokenCard({ token }) {
2606
2729
  token,
2607
2730
  theme
2608
2731
  }),
2609
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
2610
- className: "mt-2 flex items-center justify-end",
2611
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ThresholdControl, { token })
2732
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2733
+ className: "mt-2 flex items-center justify-end gap-1.5",
2734
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(WeightControl, { token }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ThresholdControl, { token })]
2612
2735
  }),
2613
2736
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
2614
2737
  className: "absolute top-2.5 right-3 flex items-center gap-1.5",
@@ -22600,6 +22600,7 @@ var entityLabels = {
22600
22600
  "entity.Token.active": "활성 여부",
22601
22601
  "entity.Token.ord": "정렬 순서",
22602
22602
  "entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
22603
+ "entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
22603
22604
  "enum.TokenOrderBy.id-desc": "ID최신순",
22604
22605
  "enum.TokenOrderBy.ord-asc": "순서순",
22605
22606
  "enum.TokenSearchField.id": "ID",
@@ -1,4 +1,4 @@
1
- import { A as hashKey, B as shallowEqualObjects, D as pendingThenable, E as notifyManager, F as noop, G as focusManager, K as Subscribable, L as replaceData, M as isValidTimeout, O as environmentManager, R as resolveEnabled, S as infiniteQueryBehavior, U as timeUntilStale, V as shouldThrowError, W as timeoutManager, X as __toESM, _ as require_react, a as fetch, b as hasNextPage, d as require_lib, g as require_jsx_runtime, h as useQueryClient, i as dedupeAndFlatten, o as useRefreshable, s as useSSEStream, w as fetchState, x as hasPreviousPage, y as getDefaultState, z as resolveStaleTime } from "./sd.generated-DRuQSIKo.js";
1
+ import { A as hashKey, B as shallowEqualObjects, D as pendingThenable, E as notifyManager, F as noop, G as focusManager, K as Subscribable, L as replaceData, M as isValidTimeout, O as environmentManager, R as resolveEnabled, S as infiniteQueryBehavior, U as timeUntilStale, V as shouldThrowError, W as timeoutManager, X as __toESM, _ as require_react, a as fetch, b as hasNextPage, d as require_lib, g as require_jsx_runtime, h as useQueryClient, i as dedupeAndFlatten, o as useRefreshable, s as useSSEStream, w as fetchState, x as hasPreviousPage, y as getDefaultState, z as resolveStaleTime } from "./sd.generated-Cwg2f53z.js";
2
2
  //#region ../../node_modules/.pnpm/@tanstack+query-core@5.95.2/node_modules/@tanstack/query-core/build/modern/queryObserver.js
3
3
  var QueryObserver = class extends Subscribable {
4
4
  constructor(client, options) {
@@ -1036,19 +1036,19 @@ var QgridService;
1036
1036
  }
1037
1037
  _QgridService.addToken = addToken;
1038
1038
  _QgridService.useAddTokenMutation = () => useMutation({ mutationFn: (params) => addToken(params.provider, params.credentials, params.name) });
1039
- async function updateToken(id, name, quotaThreshold) {
1039
+ async function updateToken(id, name, quotaThreshold, weight) {
1040
1040
  return fetch({
1041
1041
  method: "POST",
1042
1042
  url: `/api/qgrid/updateToken`,
1043
1043
  data: {
1044
1044
  id,
1045
1045
  name,
1046
- quotaThreshold
1046
+ quotaThreshold,
1047
+ weight
1047
1048
  }
1048
1049
  });
1049
1050
  }
1050
1051
  _QgridService.updateToken = updateToken;
1051
- _QgridService.useUpdateTokenMutation = () => useMutation({ mutationFn: (params) => updateToken(params.id, params.name, params.quotaThreshold) });
1052
1052
  async function removeToken(id) {
1053
1053
  return fetch({
1054
1054
  method: "POST",
@@ -1124,4 +1124,4 @@ RequestLogService.useRequestLogs, RequestLogService.useRequestLogsInfinite;
1124
1124
  RequestLogStepService.useRequestLogSteps, RequestLogStepService.useRequestLogStepsInfinite;
1125
1125
  TokenService.useTokens, TokenService.useTokensInfinite;
1126
1126
  //#endregion
1127
- export { TokenService as i, RequestLogService as n, RequestLogStepService as r, QgridService as t };
1127
+ export { useMutation as a, TokenService as i, RequestLogService as n, RequestLogStepService as r, QgridService as t };
@@ -1,6 +1,6 @@
1
- import { J as __exportAll, X as __toESM, _ as require_react, g as require_jsx_runtime, q as __commonJSMin } from "./sd.generated-DRuQSIKo.js";
2
- import { n as RequestLogService, r as RequestLogStepService } from "./services.generated-Dp0uKgpV.js";
3
- import { a as Link } from "./lazyRouteComponent-BswulcOx.js";
1
+ import { J as __exportAll, X as __toESM, _ as require_react, g as require_jsx_runtime, q as __commonJSMin } from "./sd.generated-Cwg2f53z.js";
2
+ import { n as RequestLogService, r as RequestLogStepService } from "./services.generated-CsotZP44.js";
3
+ import { a as Link } from "./lazyRouteComponent-BrPAFtBA.js";
4
4
  import { t as Route } from "../entry-server.generated.js";
5
5
  import { n as formatMicroUsd, t as cacheHitRate } from "./cost-CSH_t17I.js";
6
6
  import minpath from "node:path";
@@ -1,6 +1,7 @@
1
- import { X as __toESM, _ as require_react, g as require_jsx_runtime, h as useQueryClient } from "./sd.generated-DRuQSIKo.js";
2
- import { i as TokenService, t as QgridService } from "./services.generated-Dp0uKgpV.js";
1
+ import { X as __toESM, _ as require_react, g as require_jsx_runtime, h as useQueryClient } from "./sd.generated-Cwg2f53z.js";
2
+ import { i as TokenService, t as QgridService } from "./services.generated-CsotZP44.js";
3
3
  import { i as lucideKeyRound, r as Q } from "../entry-server.generated.js";
4
+ import { t as useUpdateTokenMutation } from "./use-update-token-mutation-BL96w-Pg.js";
4
5
  //#region ~icons/lucide/plus.jsx
5
6
  var import_react = /* @__PURE__ */ __toESM(require_react());
6
7
  var import_jsx_runtime = require_jsx_runtime();
@@ -216,7 +217,7 @@ function TokenTable({ data, isLoading }) {
216
217
  const [editName, setEditName] = (0, import_react.useState)("");
217
218
  const queryClient = useQueryClient();
218
219
  const removeMutation = QgridService.useRemoveTokenMutation();
219
- const updateMutation = QgridService.useUpdateTokenMutation();
220
+ const updateMutation = useUpdateTokenMutation();
220
221
  const toggleMutation = QgridService.useToggleTokenMutation();
221
222
  const invalidate = () => Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
222
223
  const handleToggle = async (token) => {
@@ -237,8 +238,7 @@ function TokenTable({ data, isLoading }) {
237
238
  if (!editTarget) return;
238
239
  await updateMutation.mutateAsync({
239
240
  id: editTarget.id,
240
- name: editName.trim(),
241
- quotaThreshold: editTarget.quota_threshold
241
+ name: editName.trim()
242
242
  });
243
243
  await invalidate();
244
244
  setEditTarget(null);
@@ -0,0 +1,7 @@
1
+ import { a as useMutation, t as QgridService } from "./services.generated-CsotZP44.js";
2
+ //#region src/services/token/use-update-token-mutation.ts
3
+ function useUpdateTokenMutation() {
4
+ return useMutation({ mutationFn: ({ id, name, quotaThreshold, weight }) => QgridService.updateToken(id, name, quotaThreshold, weight) });
5
+ }
6
+ //#endregion
7
+ export { useUpdateTokenMutation as t };
@@ -1,5 +1,5 @@
1
- import { A as hashKey, C as Query, E as notifyManager, F as noop$1, G as focusManager, H as skipToken, I as partialMatchKey, K as Subscribable, N as matchMutation, P as matchQuery, S as infiniteQueryBehavior, T as onlineManager, X as __toESM, Y as __require, _ as require_react, c as number, f as require_react_dom, g as require_jsx_runtime, j as hashQueryKeyByOptions, k as functionalUpdate, l as object, m as QueryClientProvider, n as SUPPORTED_LOCALES, p as require_shim, q as __commonJSMin, r as setLocale, t as SD, u as string, v as Mutation, z as resolveStaleTime } from "./assets/sd.generated-DRuQSIKo.js";
2
- import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-BswulcOx.js";
1
+ import { A as hashKey, C as Query, E as notifyManager, F as noop$1, G as focusManager, H as skipToken, I as partialMatchKey, K as Subscribable, N as matchMutation, P as matchQuery, S as infiniteQueryBehavior, T as onlineManager, X as __toESM, Y as __require, _ as require_react, c as number, f as require_react_dom, g as require_jsx_runtime, j as hashQueryKeyByOptions, k as functionalUpdate, l as object, m as QueryClientProvider, n as SUPPORTED_LOCALES, p as require_shim, q as __commonJSMin, r as setLocale, t as SD, u as string, v as Mutation, z as resolveStaleTime } from "./assets/sd.generated-Cwg2f53z.js";
2
+ import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-BrPAFtBA.js";
3
3
  //#region ../../node_modules/.pnpm/@tanstack+query-core@5.95.2/node_modules/@tanstack/query-core/build/modern/hydration.js
4
4
  function defaultTransformerFn(data) {
5
5
  return data;
@@ -22679,7 +22679,7 @@ var sections = [{
22679
22679
  icon: lucideFileText
22680
22680
  }]
22681
22681
  }];
22682
- var qgridVersion = "2.4.3";
22682
+ var qgridVersion = "2.4.4";
22683
22683
  function Sidebar({ className }) {
22684
22684
  const pathname = useRouterState({ select: (s) => s.location.pathname });
22685
22685
  const isActive = (path) => {
@@ -44559,11 +44559,11 @@ function RootComponent() {
44559
44559
  }
44560
44560
  //#endregion
44561
44561
  //#region src/routes/tokens.tsx
44562
- var $$splitComponentImporter$3 = () => import("./assets/tokens-Cjw6cQxZ.js");
44562
+ var $$splitComponentImporter$3 = () => import("./assets/tokens-DfGj1y2v.js");
44563
44563
  var Route$3 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$3, "component") });
44564
44564
  //#endregion
44565
44565
  //#region src/routes/logs.tsx
44566
- var $$splitComponentImporter$2 = () => import("./assets/logs-Bk9C0ELo.js");
44566
+ var $$splitComponentImporter$2 = () => import("./assets/logs-CKM6W3W6.js");
44567
44567
  var logsSearchSchema = object({
44568
44568
  token: string().optional(),
44569
44569
  project: string().optional(),
@@ -44575,11 +44575,11 @@ var Route$2 = createFileRoute("/logs")({
44575
44575
  });
44576
44576
  //#endregion
44577
44577
  //#region src/routes/index.tsx
44578
- var $$splitComponentImporter$1 = () => import("./assets/routes-D9OiKoqa.js");
44578
+ var $$splitComponentImporter$1 = () => import("./assets/routes-DhIpPgXz.js");
44579
44579
  var Route$1 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
44580
44580
  //#endregion
44581
44581
  //#region src/routes/requests/show.tsx
44582
- var $$splitComponentImporter = () => import("./assets/show-3GokIDEJ.js");
44582
+ var $$splitComponentImporter = () => import("./assets/show-CV2UFjaH.js");
44583
44583
  var showSearchSchema = object({ id: number() });
44584
44584
  var Route = createFileRoute("/requests/show")({
44585
44585
  validateSearch: showSearchSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-cli",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cartanova-ai/qgrid"
@@ -15,7 +15,7 @@ Before changing code, identify the affected path:
15
15
  - CLI startup, env vars, server boot, OAuth callback URL: read `references/cli-env-and-server-boot.md`.
16
16
  - OpenAI models or Codex worker/thread/cache behavior: read `references/openai-codex-runtime.md`.
17
17
  - Anthropic models or Claude Code spawn/stream-json behavior: read `references/anthropic-claude-code-runtime.md`.
18
- - Token registration, OAuth, token sync, active/inactive behavior, or quota thresholds: read `references/token-auth-quota-lifecycle.md`.
18
+ - Token registration, OAuth, token sync, active/inactive behavior, quota thresholds, or weighted token routing: read `references/token-auth-quota-lifecycle.md`.
19
19
  - Provider comparisons, routing, or cross-provider bugs: read `references/provider-runtime-differences.md`.
20
20
  - Tool calling, AI SDK multi-step loops, or tool-call request logs: read `references/tool-calling-and-multiturn.md`.
21
21
  - `sessionKey`, `threadCoord`, prompt cache metrics, usage accounting, or cost: read `references/prompt-cache-and-usage.md`.
@@ -37,8 +37,8 @@ Anthropic tokens live in an in-memory `Map<tokenId, PooledToken>`.
37
37
  - Startup loads active Anthropic tokens from DB.
38
38
  - Token subscriber events add/update/remove entries.
39
39
  - Periodic reconcile replaces pool contents from active DB rows.
40
- - Request selection is least-used plus round-robin tie-break.
41
- - Request count is incremented before awaiting execution so concurrent requests spread across tokens.
40
+ - Request selection is smooth weighted round-robin over quota-eligible tokens, driven by per-token `tokens.weight` (shared selector in `providers/common/smooth-weighted-round-robin.ts`).
41
+ - Selection and score mutation run synchronously before any await, so concurrent requests observe committed prior selections and spread across tokens.
42
42
 
43
43
  Quota threshold:
44
44
 
@@ -50,15 +50,15 @@ When helping a user set up qgrid locally, check for `QGRID_PROJECT_NAME` or conf
50
50
 
51
51
  ## Server boot lifecycle
52
52
 
53
- On server start, `packages/api/src/sonamu.config.ts`:
54
-
55
- 1. Loads `.env` from `packages/api/.env` when running API directly.
56
- 2. Configures Sonamu database connection from `QGRID_DB_*`.
57
- 3. Runs latest migrations from `packages/api/src/migrations`.
58
- 4. Ensures PostgreSQL `tokens_changed` triggers.
59
- 5. Starts `TokenSubscriber` for LISTEN/NOTIFY plus periodic reconcile.
60
- 6. Starts `OpenAIDispatcher` and `AnthropicDispatcher`.
61
- 7. Logs server URL and provider readiness counts.
53
+ `packages/api/src/index.ts` runs `bootstrapServer` (`server-bootstrap.ts`) with a strict init → migrate → listen order:
54
+
55
+ 1. `Sonamu.init()` loads `.env` from `packages/api/.env` when running API directly and configures the Sonamu database connection from `QGRID_DB_*`.
56
+ 2. `runRequiredMigrations` (`startup-migrations.ts`) applies latest migrations from `packages/api/src/migrations` before the server listens. Migration failure exits the process — qgrid must not boot against a schema missing required columns such as `tokens.weight`. This is a hard-fail; it used to be a soft-fail warn inside `onStart`.
57
+ 3. `Sonamu.createServer()` starts the server. Its `onStart` in `packages/api/src/sonamu.config.ts`:
58
+ 1. Ensures PostgreSQL `tokens_changed` triggers.
59
+ 2. Starts `TokenSubscriber` for LISTEN/NOTIFY plus periodic reconcile.
60
+ 3. Starts `OpenAIDispatcher` and `AnthropicDispatcher`.
61
+ 4. Logs server URL and provider readiness counts.
62
62
 
63
63
  On shutdown, it stops provider dispatchers and the token subscriber.
64
64
 
@@ -11,6 +11,7 @@ Do not copy these notes blindly into implementation. Use them to find the releva
11
11
  - OpenAI/Codex runtime
12
12
  - Anthropic/Claude Code runtime
13
13
  - Token sync and quota thresholds
14
+ - Weighted token routing
14
15
  - Request logs, metrics, and dashboard
15
16
  - CLI packaging and server boot
16
17
  - Image generation
@@ -119,6 +120,28 @@ Key decisions:
119
120
  - Lookup failure is fail-open with logs/metrics. Hard failure happens only when usage was read successfully and the token is over threshold.
120
121
  - OpenAI needs the gate in reuse, idle selection, and queue drain paths. A reusable but over-threshold worker must fall back cold to another eligible token when possible.
121
122
 
123
+ ## Weighted Token Routing
124
+
125
+ Sources:
126
+
127
+ - `docs/brainstorms/2026-07-10-weighted-token-routing-requirements.md`
128
+ - `docs/plans/2026-07-10-weighted-token-routing-plan.md`
129
+
130
+ Key decisions:
131
+
132
+ - `tokens.weight` is a per-token integer from 1 to 100 defaulting to 1. It is a relative routing share for new requests, not an enable/disable switch. Weight 0 is invalid; disabling a token stays on `active`.
133
+ - Both providers share one smooth weighted round-robin selector in the provider common layer. The selector knows only token ids, weights, and current scores. Dispatchers own quota, lifecycle, and worker availability, and pass in only the eligible token id set per selection. Do not teach the selector about credentials, workers, or queues.
134
+ - The quota threshold gate runs before weighted selection. An over-threshold token receives no weighted assignment regardless of weight, and the schedule is computed from the remaining eligible tokens.
135
+ - OpenAI thread reuse stays pinned to its original worker and neither reads nor mutates weighted-selector state. Cache affinity intentionally beats weight distribution; only cold requests are weighted.
136
+ - OpenAI routing is work-conserving. A token whose workers are all busy is omitted from the current selection round instead of making requests wait for the heavy-weight token. Queue drain re-runs weighted selection for the head item; the previous released-worker shortcut was removed because it bypassed weighting.
137
+ - Candidate collection, selector mutation, and worker acquisition must run without an intervening `await`. A weighted score is consumed only when the dispatcher can commit the assignment synchronously; this is what keeps concurrent Anthropic selections spread correctly.
138
+ - Selector scores reset when token topology or configured weights change. Temporary ineligibility from quota or busy workers only excludes the token from that selection's candidate set and does not reset schedule state.
139
+ - Weight-change notification is owned by the versioned migration trigger (`tokens_weight_changed_upd`); the boot-time trigger setup SQL intentionally excludes `weight` from its WHEN clause, and a test pins this so exactly one trigger fires per weight-only change.
140
+ - The token subscriber serializes notification and reconcile handling through an operation chain because weight propagation made dispatcher updates awaited; out-of-order token events could otherwise apply stale weights.
141
+ - Required migrations moved from a soft-fail `onStart` step to a hard-fail `bootstrapServer` order (init → migrate → listen). Dispatchers must never boot against a schema missing `tokens.weight`.
142
+ - `updateToken` became a partial field update (`TokenModel.updateFields`) so the dashboard weight control cannot overwrite name or quota threshold edits happening elsewhere.
143
+ - Explicit scope boundaries: no per-model, per-project, per-request, or time-varying weights, and no automatic weight adjustment from remaining quota, latency, cost, errors, or worker counts.
144
+
122
145
  ## Request Logs, Metrics, And Dashboard
123
146
 
124
147
  Sources:
@@ -100,9 +100,10 @@ There is no explicit close RPC for old ephemeral threads; qgrid removes them fro
100
100
 
101
101
  OpenAI worker selection:
102
102
 
103
- - Prefer reuse worker when a valid reuse coordinate exists.
104
- - Otherwise select an idle ready active worker round-robin across eligible workers.
105
- - If no worker is free, enqueue.
103
+ - Prefer reuse worker when a valid reuse coordinate exists. Successful reuse bypasses weighted selection and does not read or mutate its state.
104
+ - Otherwise cold selection is two-level: group quota-eligible ready active workers by token, keep only tokens with at least one idle worker, choose the token with the shared smooth weighted round-robin selector (`providers/common/smooth-weighted-round-robin.ts`, driven by `tokens.weight`), then rotate a per-token worker cursor inside the chosen token.
105
+ - Selection is work-conserving: a token whose workers are all busy is omitted from that selection round, so an idle lower-weight token receives the request immediately instead of waiting for the heavy token.
106
+ - If no eligible worker is idle, enqueue. Queue drain re-runs the same weighted cold selection; it must not hand the queue head to the worker that happened to finish first.
106
107
  - Queue timeout: 60 seconds.
107
108
  - Max queue size: 50.
108
109
 
@@ -45,6 +45,8 @@ Cache hit requires:
45
45
 
46
46
  When reuse succeeds, qgrid sends only delta input to `turn/start`. When reuse fails, qgrid falls back to cold execution with full prompt plus history injection.
47
47
 
48
+ Reuse is decided before weighted token routing and stays pinned to its original worker: a successful reuse neither consumes nor advances the weighted schedule. Cache affinity intentionally beats weight distribution; only cold requests are weighted.
49
+
48
50
  Important cache breakers:
49
51
 
50
52
  - Different system prompt.
@@ -8,7 +8,8 @@ Use this reference when comparing OpenAI and Anthropic behavior, debugging provi
8
8
  | Worker pool | Token x `QGRID_WORKERS_PER_TOKEN`, default 3, max 5 | No workers; in-memory token pool only |
9
9
  | Worker id | `tokenId * 10 + workerIndex` | `tokenId` |
10
10
  | Epoch | Worker spawn counter; changes on restart | Always `0` |
11
- | Request concurrency | One turn per worker; queue when all eligible workers busy | Fresh process per request; token selection uses least-used RR |
11
+ | Request concurrency | One turn per worker; queue when all eligible workers busy | Fresh process per request |
12
+ | Token selection | Smooth weighted RR picks a quota-eligible token with an idle worker, then a per-token worker cursor; reuse bypasses weights | Smooth weighted RR over quota-eligible tokens per request |
12
13
  | Model routing | `openai/*`; qgrid strips provider prefix before provider call | `anthropic/*`; provider canonicalizes model and strips prefix/`[1m]` |
13
14
  | Prefix-less models | Fallback not implemented | Fallback not implemented |
14
15
  | Thread/session | Ephemeral Codex thread stored in worker memory | Fresh Claude `--session-id` per request |
@@ -26,6 +26,8 @@ Use this map to orient qgrid work quickly.
26
26
  - OpenAI Codex runtime: `packages/api/src/utils/providers/openai/*`.
27
27
  - Anthropic Claude Code runtime: `packages/api/src/utils/providers/anthropic/*`.
28
28
  - Cost/usage accounting: `packages/api/src/utils/providers/common/model-cost.ts`.
29
+ - Weighted token selection: `packages/api/src/utils/providers/common/smooth-weighted-round-robin.ts`.
30
+ - Boot order and startup migrations: `packages/api/src/server-bootstrap.ts`, `packages/api/src/startup-migrations.ts`.
29
31
  - Tool-call emulation: `packages/api/src/application/qgrid/tool-emulation.ts`.
30
32
 
31
33
  ## Web/Sonamu hot spots
@@ -34,8 +34,9 @@ Use this reference before changing token storage, OAuth flows, token activation,
34
34
  - `active`: whether provider dispatchers may use it.
35
35
  - `ord`: dashboard ordering.
36
36
  - `quota_threshold`: nullable integer percentage, validated as 1..100 when present.
37
+ - `weight`: non-null integer 1..100, database default 1. Relative share for weighted round-robin routing of new requests. Weight does not enable or disable a token; that stays on `active`.
37
38
 
38
- On creation, `TokenModel.save` applies default `quota_threshold = 80` unless `id` or `quota_threshold` is already provided.
39
+ On creation, `TokenModel.save` applies defaults `quota_threshold = 80` and `weight = 1` independently for any field not provided (skipped entirely when `id` is present).
39
40
 
40
41
  OpenAI credentials:
41
42
 
@@ -72,7 +73,7 @@ Duplicate account replacement is account-identifier based:
72
73
  `QgridFrame` exposes token APIs:
73
74
 
74
75
  - `addToken(provider, credentials, name)`: saves a token directly.
75
- - `updateToken(id, name?, quotaThreshold?)`: updates name and/or quota threshold, preserving provider/credentials.
76
+ - `updateToken(id, name?, quotaThreshold?, weight?)`: partial field update through `TokenModel.updateFields`; omitted fields preserve stored values, so one control cannot overwrite another control's setting. `weight` is validated as an integer 1..100. The web client uses a hand-written `useUpdateTokenMutation` hook instead of the generated tanstack-mutation client for this endpoint.
76
77
  - `removeToken(id)`: deletes token row.
77
78
  - `toggleToken(id)`: flips `active`.
78
79
  - `stats()`: reports dispatcher cache stats.
@@ -154,13 +155,16 @@ Triggers notify on:
154
155
  - INSERT
155
156
  - DELETE
156
157
  - UPDATE when `active`, `credentials`, `provider`, `name`, or `quota_threshold` changes.
158
+ - UPDATE when `weight` changes, through a separate `tokens_weight_changed_upd` trigger owned by the versioned migration `20260710090000_alter_tokens_add_weight.ts`. The boot-time setup SQL intentionally leaves `weight` out of its WHEN clause (test-enforced in `token-trigger-setup.test.ts`) so exactly one trigger fires per weight-only change.
157
159
 
158
160
  `TokenSubscriber`:
159
161
 
160
162
  - connects to PostgreSQL and `LISTEN`s on `tokens_changed`;
161
163
  - reconnects with jittered exponential backoff up to 30 seconds;
162
164
  - runs periodic reconcile every 10 minutes because LISTEN/NOTIFY can miss changes while disconnected;
163
- - replaces dispatcher cache from `TokenModel.findActive("A")`.
165
+ - replaces dispatcher cache from `TokenModel.findActive("A")`;
166
+ - serializes notification handling and reconcile through an internal operation chain, so dispatcher updates (now awaited, including weight propagation) apply in arrival order;
167
+ - passes `weight` through add, update, and reconcile calls for both provider dispatchers.
164
168
 
165
169
  On DELETE or missing row:
166
170
 
@@ -188,6 +192,7 @@ OpenAI token changes can create, update, deactivate, or kill persistent Codex wo
188
192
 
189
193
  - In-place OpenAI worker update is allowed only when account id and plan type match.
190
194
  - Otherwise workers are killed and respawned.
195
+ - Weight-only changes never respawn workers; they update token metadata and reset the weighted selector's scores.
191
196
  - If no ready active OpenAI workers remain, queued OpenAI requests are rejected.
192
197
 
193
198
  Anthropic token changes only affect the in-memory token pool because every request fresh-spawns Claude Code.
@@ -59,6 +59,9 @@ Choose by affected area:
59
59
  | qgrid frame API behavior | `packages/api/src/application/qgrid/qgrid.frame.test.ts` |
60
60
  | token subscriber and LISTEN/NOTIFY handling | `packages/api/src/application/qgrid/token-subscriber.test.ts` |
61
61
  | token defaults/validation | `packages/api/src/application/token/token.model.test.ts`, `token.types.test.ts` |
62
+ | weighted routing selector | `packages/api/src/utils/providers/common/smooth-weighted-round-robin.test.ts` |
63
+ | token weight migration/trigger split | `packages/api/src/application/qgrid/token-weight-migration.test.ts`, `token-trigger-setup.test.ts` |
64
+ | boot order and startup migrations | `packages/api/src/server-bootstrap.test.ts`, `startup-migrations.test.ts` |
62
65
  | request log queries/legacy normalization | `packages/api/src/application/request-log/request-log.model.test.ts` |
63
66
  | OpenAI dispatcher routing/queue/quota/reuse | `packages/api/src/utils/providers/openai/openai-dispatcher.test.ts` |
64
67
  | OpenAI Codex worker RPC/turn behavior | `packages/api/src/utils/providers/openai/codex-worker.test.ts` |