@cartanova/qgrid-cli 2.10.2 → 2.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bundle/dist/application/monit/log-buffer.js +7 -11
  2. package/bundle/dist/application/monit/monit.frame.js +6 -11
  3. package/bundle/dist/application/qgrid/oauth.js +2 -2
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
  7. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  8. package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
  9. package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
  10. package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
  11. package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
  12. package/bundle/dist/application/request-log/request-log.model.js +7 -5
  13. package/bundle/dist/application/setting/setting.model.js +1 -1
  14. package/bundle/dist/sonamu.config.js +6 -6
  15. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  16. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  17. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  18. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  19. package/bundle/dist/utils/providers/common/effort.js +1 -1
  20. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  21. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  22. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  23. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  24. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  25. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  26. package/bundle/src/application/monit/log-buffer.ts +13 -27
  27. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  28. package/bundle/src/application/monit/monit.frame.ts +9 -14
  29. package/bundle/src/application/qgrid/oauth.ts +1 -1
  30. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
  31. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  32. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  33. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  34. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
  35. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  36. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  37. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  38. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  39. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  40. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  41. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  42. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  43. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  44. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  45. package/bundle/src/application/setting/setting.model.ts +1 -1
  46. package/bundle/src/sonamu.config.ts +5 -5
  47. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  48. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  49. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  50. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  51. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  52. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  53. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  54. package/bundle/src/utils/providers/common/effort.ts +1 -1
  55. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  56. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  57. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  58. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  59. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  60. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  61. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  62. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  63. package/bundle/web-dist/client/assets/{index-DtRTSTes.js → index-BIC1sQxw.js} +3 -3
  64. package/bundle/web-dist/client/assets/{logs-BhVYDEzC.js → logs-zvdujZWn.js} +1 -1
  65. package/bundle/web-dist/client/assets/{monit-BxN4JWqK.js → monit-CraVV3MQ.js} +1 -1
  66. package/bundle/web-dist/client/assets/{routes-CBCqRp2o.js → routes-B3ZBoJ60.js} +1 -1
  67. package/bundle/web-dist/client/assets/{settings-hBTEC7Ki.js → settings-DwbzT5jo.js} +1 -1
  68. package/bundle/web-dist/client/assets/{show-BzYft5N4.js → show-ItTExdAh.js} +1 -1
  69. package/bundle/web-dist/client/assets/{tokens-PMybtI8X.js → tokens-BynKx8Vn.js} +1 -1
  70. package/bundle/web-dist/client/assets/{use-oauth-login-flow-C51_d6-6.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
  71. package/bundle/web-dist/client/index.html +1 -1
  72. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  73. package/package.json +1 -1
  74. package/skills/qgrid/SKILL.md +1 -0
  75. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  76. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  77. package/skills/qgrid/references/decision-rationale.md +9 -0
  78. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  79. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  80. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  81. package/skills/qgrid/references/verification-and-debugging.md +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  type ProviderDispatcher,
13
13
  } from "../common/provider-dispatcher";
14
14
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
15
+ import { ThinkingValidationError } from "../common/thinking";
15
16
  import {
16
17
  type JsonValue,
17
18
  type OpenAIResponseItem,
@@ -23,8 +24,15 @@ import { handleChatgptAuthTokensRefresh } from "./openai-refresh";
23
24
  import { type OpenAITransportKind, resolveOpenAITransportKind } from "./openai-transport-config";
24
25
 
25
26
  const logger = getLogger(["qgrid", "openai-dispatcher"]);
26
- // 교체된 codex worker 가 강제하던 watchdog. 호출자가 timeoutMs 를 생략해도 요청이
27
- // 영원히 매달리지 않도록 direct transport 도 유한한 기본 상한을 유지한다.
27
+ // Verified with the subscription Responses backend; public API support is not sufficient.
28
+ const THINKING_OFF_MODELS = new Set([
29
+ "gpt-6-sol",
30
+ "gpt-6-luna",
31
+ "gpt-5.6-sol",
32
+ "gpt-5.6-terra",
33
+ "gpt-5.6-luna",
34
+ "gpt-5.5",
35
+ ]);
28
36
  const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
29
37
  const TRANSPARENT_IMAGE_TOOL = "generate_transparent_image";
30
38
 
@@ -97,10 +105,11 @@ function inputItems(req: GenerateRequest): OpenAIResponseItem[] {
97
105
  }
98
106
 
99
107
  function asReasoning(req: GenerateRequest): OpenAIResponsesOptions["reasoning"] | undefined {
108
+ if (req.thinking === false) return { effort: "none" };
100
109
  const summary =
101
110
  req.reasoningSummary && req.reasoningSummary !== "none" ? req.reasoningSummary : undefined;
102
- // Codex 어휘·모델 상한 밖의 effort 는 여기서 조용히 버린다(백엔드 기본값 적용).
103
- const effort = resolveOpenAIEffort(req.model ?? "", req.effort);
111
+ const effort =
112
+ resolveOpenAIEffort(req.model ?? "", req.effort) ?? (req.thinking === true ? "low" : undefined);
104
113
  if (!effort && !summary) return undefined;
105
114
  return {
106
115
  ...(effort ? { effort } : {}),
@@ -178,31 +187,24 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
178
187
  };
179
188
  }
180
189
 
181
- /**
182
- * Direct OpenAI dispatcher — Anthropic 과 동일한 stateless 실행 모델.
183
- *
184
- * 요청 = 토큰 선택(quota gate + weighted round-robin + cache affinity 선호) → HTTPS/WS
185
- * 전송. permit/큐 같은 동시성 상한은 두지 않는다: 전송은 fetch 한 번이라 로컬 자원이
186
- * 희소하지 않고, 상류 제한은 백엔드의 응답(429 등)이 진실이다. worker pool 시절의
187
- * 큐 의미론(SERVER_BUSY, 큐 타임아웃)은 이 전환으로 제거됐다.
188
- */
190
+ // quota와 cache affinity로 토큰을 선택해 HTTPS/WS로 전송한다. 로컬 동시성 제한은 없다.
189
191
  export class OpenAIDispatcher implements ProviderDispatcher {
190
192
  readonly tokenMetadata = new Map<number, TokenMetadata>();
191
193
  readonly transportKind: OpenAITransportKind;
192
194
  readonly rateLimitsCache = new Map<number, OpenAIRateLimitsWithMeta & { generation: number }>();
193
- private readonly pendingRateLimits = new Map<
195
+ readonly pendingRateLimits = new Map<
194
196
  number,
195
197
  { token: TokenMetadata; generation: number; promise: Promise<OpenAIRateLimitsWithMeta> }
196
198
  >();
197
- private readonly clients = new Map<number, ClientEntry>();
198
- private readonly retiredClients = new Set<ClientEntry>();
199
+ readonly clients = new Map<number, ClientEntry>();
200
+ readonly retiredClients = new Set<ClientEntry>();
199
201
  static readonly RATE_LIMITS_CACHE_TTL = 60_000;
200
202
 
201
- private readonly selector = new SmoothWeightedRoundRobin();
202
- private readonly clientFactory: OpenAIDirectClientFactory;
203
- private readonly fetchImpl: typeof fetch;
204
- private readonly quotaBlocked = new Set<number>();
205
- private inFlightCount = 0;
203
+ readonly selector = new SmoothWeightedRoundRobin();
204
+ readonly clientFactory: OpenAIDirectClientFactory;
205
+ readonly fetchImpl: typeof fetch;
206
+ readonly quotaBlocked = new Set<number>();
207
+ inFlight = 0;
206
208
 
207
209
  constructor(
208
210
  transportKind: OpenAITransportKind = resolveOpenAITransportKind(),
@@ -216,24 +218,25 @@ export class OpenAIDispatcher implements ProviderDispatcher {
216
218
 
217
219
  async start(): Promise<void> {
218
220
  const { rows } = await TokenModel.findMany("A");
219
- for (const row of rows) {
220
- if (row.provider !== "openai") continue;
221
- this.setToken(
222
- row.id,
223
- row.name,
224
- row.credentials as OpenAICredentials,
225
- row.quota_threshold,
226
- row.weight,
227
- row.active,
228
- );
229
- }
221
+ rows
222
+ .filter((row) => row.provider === "openai")
223
+ .forEach((row) => {
224
+ this.setToken(
225
+ row.id,
226
+ row.name,
227
+ row.credentials as OpenAICredentials,
228
+ row.quota_threshold,
229
+ row.weight,
230
+ row.active,
231
+ );
232
+ });
230
233
  logger.info(`started direct OpenAI runtime with ${this.tokenMetadata.size} tokens`);
231
234
  }
232
235
 
233
236
  async stop(): Promise<void> {
234
- for (const { client } of this.clients.values()) client.close?.();
237
+ this.clients.forEach(({ client }) => client.close?.());
235
238
  this.clients.clear();
236
- for (const entry of this.retiredClients) entry.client.close?.();
239
+ this.retiredClients.forEach((entry) => entry.client.close?.());
237
240
  this.retiredClients.clear();
238
241
  this.tokenMetadata.clear();
239
242
  this.rateLimitsCache.clear();
@@ -315,11 +318,19 @@ export class OpenAIDispatcher implements ProviderDispatcher {
315
318
  }
316
319
  }
317
320
 
318
- // generate/generateStream 공통 실행 — 토큰 선택과 in-flight 집계를 한 곳에 둔다.
319
- private async run(
320
- req: GenerateRequest,
321
- onDelta?: (text: string) => void,
322
- ): Promise<GenerateResult> {
321
+ async run(req: GenerateRequest, onDelta?: (text: string) => void): Promise<GenerateResult> {
322
+ if (req.thinking === false) {
323
+ const model = req.model ?? "";
324
+ if (!THINKING_OFF_MODELS.has(model)) {
325
+ const reason =
326
+ model === "gpt-6-astra" || model === "gpt-6.1-sol"
327
+ ? "the subscription backend requires thinking"
328
+ : "thinking-off support has not been verified on the subscription backend";
329
+ throw new ThinkingValidationError(
330
+ `thinking:false is unavailable for OpenAI model ${model || "<default>"}: ${reason}`,
331
+ );
332
+ }
333
+ }
323
334
  const signal = activeSignal(req.abortSignal, req.timeoutMs);
324
335
  if (signal.aborted) throw abortError(signal);
325
336
  const selection = await this.selectToken(
@@ -327,16 +338,16 @@ export class OpenAIDispatcher implements ProviderDispatcher {
327
338
  req.requirePreferredToken ?? false,
328
339
  signal,
329
340
  );
330
- this.inFlightCount++;
341
+ this.inFlight++;
331
342
  try {
332
343
  if (signal.aborted) throw abortError(signal);
333
344
  return await this.runDirect(selection, { ...req, abortSignal: signal }, onDelta);
334
345
  } finally {
335
- this.inFlightCount--;
346
+ this.inFlight--;
336
347
  }
337
348
  }
338
349
 
339
- private async runDirect(
350
+ async runDirect(
340
351
  selection: TokenSelection,
341
352
  req: GenerateRequest,
342
353
  onDelta?: (text: string) => void,
@@ -344,6 +355,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
344
355
  const startedAt = Date.now();
345
356
  let firstDeltaAt: number | undefined;
346
357
  let text = "";
358
+ let outputMessageAccepted = false;
359
+ const messagePhases = new Map<string, string>();
360
+ const streamedMessageIds = new Set<string>();
347
361
  let usage = {
348
362
  totalTokens: 0,
349
363
  inputTokens: 0,
@@ -366,10 +380,53 @@ export class OpenAIDispatcher implements ProviderDispatcher {
366
380
  requestOptions(req),
367
381
  req.abortSignal,
368
382
  )) {
383
+ if (
384
+ req.stopAfterOutputMessage &&
385
+ event.type === "output-item" &&
386
+ event.item.type === "message" &&
387
+ typeof event.item.id === "string" &&
388
+ typeof event.item.phase === "string"
389
+ ) {
390
+ messagePhases.set(event.item.id, event.item.phase);
391
+ }
369
392
  if (event.type === "text-delta") {
393
+ if (outputMessageAccepted) continue;
394
+ if (req.stopAfterOutputMessage && event.itemId) {
395
+ // Buffer commentary/unclassified messages until their completed item.
396
+ // Known final answers retain incremental streaming.
397
+ if (messagePhases.get(event.itemId) !== "final_answer") continue;
398
+ streamedMessageIds.add(event.itemId);
399
+ }
370
400
  firstDeltaAt ??= Date.now();
371
401
  text += event.text;
372
402
  onDelta?.(event.text);
403
+ } else if (
404
+ event.type === "output-item" &&
405
+ event.completed &&
406
+ event.item.type === "message" &&
407
+ !outputMessageAccepted &&
408
+ req.stopAfterOutputMessage &&
409
+ Array.isArray(event.item.content)
410
+ ) {
411
+ const messageText = event.item.content
412
+ .filter((part) => part?.type === "output_text" && typeof part.text === "string")
413
+ .map((part) => part.text)
414
+ .join("");
415
+ // A valid client tool request ends this application turn. Later answers cannot
416
+ // know its result yet. Never split/repair malformed JSON within a message.
417
+ const phase = typeof event.item.phase === "string" ? event.item.phase : undefined;
418
+ if (messageText) outputMessageAccepted = req.stopAfterOutputMessage(messageText, phase);
419
+ if (
420
+ typeof event.item.id === "string" &&
421
+ !streamedMessageIds.has(event.item.id) &&
422
+ (phase !== "commentary" || outputMessageAccepted) &&
423
+ messageText
424
+ ) {
425
+ firstDeltaAt ??= Date.now();
426
+ text += messageText;
427
+ onDelta?.(messageText);
428
+ streamedMessageIds.add(event.item.id);
429
+ }
373
430
  } else if (event.type === "image") {
374
431
  imageAttempted = true;
375
432
  images.push({
@@ -511,7 +568,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
511
568
  }
512
569
 
513
570
  /** 세대가 바뀐 client 는 사용 중인 요청이 끝난 뒤에 닫는다. */
514
- private acquireClient(selection: TokenSelection): ClientEntry {
571
+ acquireClient(selection: TokenSelection): ClientEntry {
515
572
  const metadata = selection.metadata;
516
573
  let entry = this.clients.get(selection.tokenId);
517
574
  if (entry && entry.generation !== metadata.generation) {
@@ -538,7 +595,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
538
595
  return entry;
539
596
  }
540
597
 
541
- private releaseClient(entry: ClientEntry): void {
598
+ releaseClient(entry: ClientEntry): void {
542
599
  entry.inFlight = Math.max(0, entry.inFlight - 1);
543
600
  if (entry.retired && entry.inFlight === 0) {
544
601
  this.retiredClients.delete(entry);
@@ -546,8 +603,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
546
603
  }
547
604
  }
548
605
 
549
- /** map 에서 떼어내고, 진행 중인 요청이 없을 때만 즉시 닫는다. */
550
- private retireClient(tokenId: number): void {
606
+ retireClient(tokenId: number): void {
551
607
  const entry = this.clients.get(tokenId);
552
608
  if (!entry) return;
553
609
  this.clients.delete(tokenId);
@@ -556,13 +612,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
556
612
  else this.retiredClients.add(entry);
557
613
  }
558
614
 
559
- /**
560
- * 토큰 선택 — quota gate 를 통과한 active 토큰 중에서 고른다. cache affinity 선호
561
- * 토큰이 eligible 하면 그 판정만으로 즉시 결정하고(전체 sweep 생략, weighted 상태
562
- * 비변경), 아니면 병렬 quota 판정 후 smooth weighted round-robin. 동시성 상한이
563
- * 없으므로 "빈 자리" 개념도 없다 — 선택 즉시 실행이다.
564
- */
565
- private async selectToken(
615
+ // 선호 토큰이 quota를 통과하면 다른 토큰 조회와 weighted 상태 변경을 생략한다.
616
+ async selectToken(
566
617
  preferredTokenId: number | undefined,
567
618
  requirePreferredToken: boolean,
568
619
  signal: AbortSignal,
@@ -585,8 +636,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
585
636
  }
586
637
  }
587
638
 
588
- // 토큰별 quota 판정은 서로 독립이라 병렬로 — 직렬 대기는 콜드 캐시에서
589
- // 토큰 수 × 조회 지연만큼 요청 시작을 늦춘다.
639
+ // 콜드 캐시에서 토큰 수만큼 조회 지연이 쌓이지 않도록 병렬 판정한다.
590
640
  const checks = await Promise.all(
591
641
  [...this.tokenMetadata.entries()]
592
642
  .filter(([, token]) => token.active)
@@ -619,7 +669,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
619
669
  return { tokenId: selected, metadata };
620
670
  }
621
671
 
622
- private setToken(
672
+ setToken(
623
673
  id: number,
624
674
  name: string,
625
675
  credentials: OpenAICredentials,
@@ -667,9 +717,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
667
717
  )
668
718
  return cached;
669
719
 
670
- // single-flight: 동시성 상한이 없어진 뒤로 TTL 만료 순간 동시 요청 수만큼 같은
671
- // 조회가 몰릴 수 있다 — 진행 중인 fetch 를 공유한다. 최초 호출자의 abort 로
672
- // 공유 조회가 실패해도 소비처(isQuotaEligible)가 fail-open 으로 처리한다.
720
+ // TTL 만료 시 조회를 공유한다. 최초 호출자의 abort로 실패해도 isQuotaEligible은 fail-open한다.
673
721
  const pending = this.pendingRateLimits.get(tokenId);
674
722
  if (pending?.token === token && pending.generation === generation) return pending.promise;
675
723
  const fetchPromise = (async () => {
@@ -695,7 +743,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
695
743
  return fetchPromise;
696
744
  }
697
745
 
698
- private async refreshCredentials(tokenId: number) {
746
+ async refreshCredentials(tokenId: number) {
699
747
  const previous = this.tokenMetadata.get(tokenId);
700
748
  const generation = previous?.generation;
701
749
  const refreshed = await handleChatgptAuthTokensRefresh(tokenId);
@@ -718,11 +766,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
718
766
  };
719
767
  }
720
768
 
721
- private async isQuotaEligible(
722
- id: number,
723
- token: TokenMetadata,
724
- signal?: AbortSignal,
725
- ): Promise<boolean> {
769
+ async isQuotaEligible(id: number, token: TokenMetadata, signal?: AbortSignal): Promise<boolean> {
726
770
  if (token.quotaThreshold === null || token.quotaThreshold === undefined) return true;
727
771
  const generation = token.generation;
728
772
  const result = await readOpenAIQuotaUsage(async () => this.getRateLimitsByTokenId(id, signal));
@@ -751,23 +795,16 @@ export class OpenAIDispatcher implements ProviderDispatcher {
751
795
  return true;
752
796
  }
753
797
 
754
- private invalidateRateLimitsCache(id: number): void {
798
+ invalidateRateLimitsCache(id: number): void {
755
799
  this.rateLimitsCache.delete(id);
756
800
  }
757
801
 
758
- get tokenCount(): number {
802
+ countActiveTokens(): number {
759
803
  return [...this.tokenMetadata.values()].filter((t) => t.active).length;
760
804
  }
761
- get inFlight(): number {
762
- return this.inFlightCount;
763
- }
764
805
 
765
- /**
766
- * 토큰별 쿼터 스냅샷 — 신선한(rate limits TTL 내) 캐시만 읽고 절대 fetch 하지 않는다.
767
- * monit vitals 는 로그 폴링에 편승하는 값이라 여기서 네트워크를 타면 안 된다.
768
- * threshold 미설정 토큰은 쿼터 판정을 돌지 않아 usedPercent 가 null 로 남을 수 있다.
769
- */
770
- get quotaByToken(): Array<{
806
+ // 모니터링 폴링은 TTL 내 캐시만 읽고 fetch하지 않는다. 미조회 토큰은 usedPercent가 null이다.
807
+ getQuotaSnapshot(): Array<{
771
808
  name: string;
772
809
  usedPercent: number | null;
773
810
  threshold: number | null;
@@ -788,8 +825,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
788
825
  usedPercent: primary?.usedPercent ?? null,
789
826
  threshold: t.quotaThreshold ?? null,
790
827
  blocked: this.quotaBlocked.has(id),
791
- // wham 의 resetsAt 은 unix 초 단위다(qgrid.frame unixSecondsToIso 와 동일 규약).
792
- // 소비처가 단위를 추측하지 않도록 여기서 ms epoch 으로 정규화한다.
828
+ // wham의 Unix 초를 epoch ms로 변환한다.
793
829
  resetsAt:
794
830
  primary?.resetsAt !== null && primary?.resetsAt !== undefined
795
831
  ? primary.resetsAt * 1000