@cartanova/qgrid-cli 2.7.7 → 2.8.0

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 (52) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +5 -2
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +63 -28
  3. package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
  4. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  5. package/bundle/dist/sonamu.config.js +3 -1
  6. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +12 -7
  7. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +7 -5
  8. package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
  9. package/bundle/src/application/qgrid/oauth.test.ts +28 -0
  10. package/bundle/src/application/qgrid/oauth.ts +5 -0
  11. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
  12. package/bundle/src/application/qgrid/qgrid.frame.test.ts +43 -0
  13. package/bundle/src/application/qgrid/qgrid.frame.ts +72 -26
  14. package/bundle/src/application/qgrid/qgrid.types.ts +5 -2
  15. package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
  16. package/bundle/src/application/request-log/request-log.model.ts +3 -1
  17. package/bundle/src/application/sonamu.generated.http +7 -3
  18. package/bundle/src/sonamu.config.ts +2 -0
  19. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +36 -0
  20. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +20 -7
  21. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +22 -0
  22. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +16 -2
  23. package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
  24. package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
  25. package/bundle/web-dist/client/assets/{index-CokxkK2G.js → index-D208L33c.js} +3 -3
  26. package/bundle/web-dist/client/assets/{logs-Ckqp98Y3.js → logs-noN5Aka6.js} +1 -1
  27. package/bundle/web-dist/client/assets/{monit-u0pMHbyf.js → monit-Ber13xcA.js} +1 -1
  28. package/bundle/web-dist/client/assets/{routes-DobxbCAV.js → routes-3_xetpSK.js} +2 -2
  29. package/bundle/web-dist/client/assets/{settings-C8rfyi9z.js → settings-C5sHCg8X.js} +1 -1
  30. package/bundle/web-dist/client/assets/{show-FbUQYs4L.js → show-BhTgGnkc.js} +1 -1
  31. package/bundle/web-dist/client/assets/tokens-R7obwdAV.js +1 -0
  32. package/bundle/web-dist/client/assets/use-oauth-login-flow-CRAqRZXX.js +1 -0
  33. package/bundle/web-dist/client/index.html +1 -1
  34. package/bundle/web-dist/server/assets/{routes-B9X0lvjQ.js → routes-Ct3I9263.js} +3 -2
  35. package/bundle/web-dist/server/assets/{tokens-CyItE-L-.js → tokens-PR42jF1J.js} +3 -2
  36. package/bundle/web-dist/server/assets/{use-oauth-login-flow-CItVKngs.js → use-oauth-login-flow-C-33Z5HA.js} +16 -10
  37. package/bundle/web-dist/server/entry-server.generated.js +3 -3
  38. package/dist/cli.js +10 -11
  39. package/package.json +1 -1
  40. package/skills/qgrid/SKILL.md +7 -2
  41. package/skills/qgrid/references/ai-sdk-provider-contract.md +7 -0
  42. package/skills/qgrid/references/anthropic-claude-code-runtime.md +5 -1
  43. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  44. package/skills/qgrid/references/decision-rationale.md +3 -1
  45. package/skills/qgrid/references/docs-workflow.md +3 -3
  46. package/skills/qgrid/references/openai-codex-runtime.md +12 -5
  47. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  48. package/skills/qgrid/references/sonamu-api-web-flow.md +4 -2
  49. package/skills/qgrid/references/token-auth-quota-lifecycle.md +7 -2
  50. package/skills/qgrid/references/verification-and-debugging.md +19 -16
  51. package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +0 -1
  52. package/bundle/web-dist/client/assets/use-oauth-login-flow-D-9h3WUt.js +0 -1
@@ -12,6 +12,11 @@ import {
12
12
  getRefreshToken,
13
13
  } from "../../utils/providers/common/credentials";
14
14
  import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
15
+ import {
16
+ OPENAI_CALLBACK_PORTS,
17
+ openAICallbackRedirectUri,
18
+ startOpenAICallbackRelay,
19
+ } from "../../utils/providers/openai/openai-callback-relay";
15
20
  import {
16
21
  buildOpenAIAuthUrl,
17
22
  exchangeOpenAICode,
@@ -70,6 +75,7 @@ type PendingOAuth = {
70
75
  };
71
76
  const OAUTH_STATE_PREFIX = "oauth:state:";
72
77
  const OAUTH_STATE_TTL = "5m";
78
+ const OAUTH_STATE_TTL_MS = 5 * 60_000;
73
79
 
74
80
  function unixSecondsToIso(seconds: number | null | undefined): string | null {
75
81
  return typeof seconds === "number" ? new Date(seconds * 1000).toISOString() : null;
@@ -101,9 +107,7 @@ async function deleteOAuthState(state: string): Promise<void> {
101
107
 
102
108
  const trustedLoopbackBase = () => `http://localhost:${process.env.PORT ?? "44900"}`;
103
109
 
104
- // Anthropic's remote/manual flow uses its fixed console callback. Headers may select between two
105
- // allowlisted URIs, but never contribute authority or path bytes to either URI.
106
- function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
110
+ function isRemoteOAuthRequest(): boolean {
107
111
  try {
108
112
  const { headers } = Sonamu.getContext();
109
113
  const origin = typeof headers.origin === "string" ? headers.origin : undefined;
@@ -112,13 +116,35 @@ function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code
112
116
  const host = origin
113
117
  ? new URL(origin).hostname
114
118
  : new URL(`http://${rawHost ?? "localhost"}`).hostname;
115
- if (!new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(host)) {
116
- return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
117
- }
119
+ return !new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(host);
118
120
  } catch {}
121
+ return false;
122
+ }
123
+
124
+ // Anthropic's remote/manual flow uses its fixed console callback. Headers may select between two
125
+ // allowlisted URIs, but never contribute authority or path bytes to either URI.
126
+ function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
127
+ if (isRemoteOAuthRequest()) {
128
+ return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
129
+ }
119
130
  return { redirectUri: `${trustedLoopbackBase()}/callback`, mode: "redirect" };
120
131
  }
121
132
 
133
+ function parsePastedOAuthResult(input: string): { code: string; state: string } | null {
134
+ const trimmed = input.trim();
135
+ try {
136
+ const callback = new URL(trimmed);
137
+ const code = callback.searchParams.get("code")?.trim() ?? "";
138
+ const state = callback.searchParams.get("state")?.trim() ?? "";
139
+ if (code && state) return { code, state };
140
+ } catch {}
141
+
142
+ const separatorAt = trimmed.indexOf("#");
143
+ const code = separatorAt > 0 ? trimmed.slice(0, separatorAt).trim() : "";
144
+ const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1).trim() : "";
145
+ return code && state ? { code, state } : null;
146
+ }
147
+
122
148
  function rejectImageGenerationStream(args: QueryInput): void {
123
149
  if (!args.imageGeneration) return;
124
150
  throw new BadRequestException(
@@ -401,6 +427,8 @@ class QgridFrameClass extends BaseFrameClass {
401
427
  project_name: input.projectName,
402
428
  system_prompt: input.systemPrompt,
403
429
  history: input.history ? JSON.parse(input.history) : undefined,
430
+ is_structured: input.isStructured,
431
+ json_schema: input.jsonSchema,
404
432
  });
405
433
  return { requestLogId };
406
434
  }
@@ -457,6 +485,7 @@ class QgridFrameClass extends BaseFrameClass {
457
485
  duration_ms: input.totalDurationMs,
458
486
  history: input.history ? JSON.parse(input.history) : undefined,
459
487
  error_message: input.errorMessage,
488
+ response_json_ok: input.responseJsonOk,
460
489
  });
461
490
  return { ok: true };
462
491
  }
@@ -574,18 +603,16 @@ class QgridFrameClass extends BaseFrameClass {
574
603
  return { authUrl, mode };
575
604
  }
576
605
 
577
- // 코드 붙여넣기 플로우(원격 접속): 콘솔 콜백이 표시한 `code#state` 를 받아 교환한다.
606
+ // 원격 접속의 수동 완료: Anthropic `code#state` 또는 OpenAI callback URL을 받아 교환한다.
578
607
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
579
608
  async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
580
- const trimmed = pastedCode.trim();
581
- const separatorAt = trimmed.indexOf("#");
582
- const code = separatorAt > 0 ? trimmed.slice(0, separatorAt) : "";
583
- const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1) : "";
584
- if (!code || !state) {
609
+ const result = parsePastedOAuthResult(pastedCode);
610
+ if (!result) {
585
611
  throw new BadRequestException(
586
- "expected the code shown after login, in code#state form" as LocalizedString,
612
+ "expected the callback URL or code shown after login, in code#state form" as LocalizedString,
587
613
  );
588
614
  }
615
+ const { code, state } = result;
589
616
 
590
617
  const pending = await getOAuthState(state);
591
618
  if (!pending) {
@@ -595,11 +622,12 @@ class QgridFrameClass extends BaseFrameClass {
595
622
  }
596
623
  await deleteOAuthState(state);
597
624
 
598
- if (pending.provider !== "anthropic") {
599
- throw new BadRequestException("login session is not for Anthropic" as LocalizedString);
625
+ if (pending.provider === "openai") {
626
+ await this.completeOpenAILogin(code, pending);
627
+ } else {
628
+ await this.completeAnthropicLogin(code, state, pending);
600
629
  }
601
630
 
602
- await this.completeAnthropicLogin(code, state, pending);
603
631
  return { added: true, name: pending.name };
604
632
  }
605
633
 
@@ -630,6 +658,17 @@ class QgridFrameClass extends BaseFrameClass {
630
658
  notifyTokenAdded(pending.name, "anthropic");
631
659
  }
632
660
 
661
+ private async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
662
+ const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
663
+ await TokenModel.replaceByAccount("openai", creds.accountId, {
664
+ provider: "openai",
665
+ credentials: creds,
666
+ name: pending.name,
667
+ });
668
+ logger.info(`OpenAI token saved for ${pending.name}`);
669
+ notifyTokenAdded(pending.name, "openai");
670
+ }
671
+
633
672
  async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
634
673
  const pending = await getOAuthState(state);
635
674
  if (!pending) {
@@ -640,14 +679,7 @@ class QgridFrameClass extends BaseFrameClass {
640
679
 
641
680
  try {
642
681
  if (pending.provider === "openai") {
643
- const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
644
- await TokenModel.replaceByAccount("openai", creds.accountId, {
645
- provider: "openai",
646
- credentials: creds,
647
- name: pending.name,
648
- });
649
- logger.info(`OpenAI token saved for ${pending.name}`);
650
- notifyTokenAdded(pending.name, "openai");
682
+ await this.completeOpenAILogin(code, pending);
651
683
  } else {
652
684
  await this.completeAnthropicLogin(code, state, pending);
653
685
  }
@@ -664,10 +696,24 @@ class QgridFrameClass extends BaseFrameClass {
664
696
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
665
697
  async oauthStartOpenAI(name: string): Promise<OAuthStartResult> {
666
698
  const { codeVerifier, codeChallenge, state } = generateOpenAIPKCE();
667
- const redirectUri = `${trustedLoopbackBase()}/auth/callback`;
699
+ const mode = isRemoteOAuthRequest() ? "code" : "redirect";
700
+ let redirectUri: string;
701
+ if (mode === "code") {
702
+ // OpenAI accepts only the Codex CLI's registered loopback URI. On a remote dashboard the
703
+ // browser cannot reach the server's loopback, so the user pastes the resulting callback URL.
704
+ redirectUri = openAICallbackRedirectUri(OPENAI_CALLBACK_PORTS[0]);
705
+ } else {
706
+ try {
707
+ redirectUri = await startOpenAICallbackRelay(trustedLoopbackBase(), OAUTH_STATE_TTL_MS);
708
+ } catch {
709
+ throw new BadRequestException(
710
+ `OpenAI login needs local port ${OPENAI_CALLBACK_PORTS.join(" or ")} — close any running \`codex login\` and retry` as LocalizedString,
711
+ );
712
+ }
713
+ }
668
714
  const authUrl = buildOpenAIAuthUrl(codeChallenge, state, redirectUri);
669
715
  await setOAuthState(state, { codeVerifier, name, redirectUri, provider: "openai" });
670
- return { authUrl, mode: "redirect" };
716
+ return { authUrl, mode };
671
717
  }
672
718
 
673
719
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -114,7 +114,7 @@ export const QueryInput = z.looseObject({
114
114
  serviceTier: ServiceTier.optional(),
115
115
  history: z.string().optional(),
116
116
  projectName: z.string().optional(),
117
- // 내부·대시보드 호출용 지정 주소. 공개 AI SDK provider options 에는 노출하지 않는다.
117
+ // raw/dashboard와 공개 AI SDK providerOptions.qgrid.tokenName이 공유하는 지정 주소.
118
118
  tokenName: z.string().optional(),
119
119
  // SDK-derived opaque affinity. It is intentionally distinct from the caller's sessionKey.
120
120
  cacheAffinityKey: z
@@ -214,6 +214,8 @@ export const CreateRunInput = z.object({
214
214
  effort: z.string().optional(),
215
215
  projectName: z.string().optional(),
216
216
  history: z.string().optional(),
217
+ isStructured: z.boolean().optional(),
218
+ jsonSchema: z.string().nullable().optional(),
217
219
  });
218
220
  export type CreateRunInput = z.infer<typeof CreateRunInput>;
219
221
 
@@ -266,6 +268,7 @@ export const FinishRunInput = z.object({
266
268
  totalDurationMs: z.number().optional(),
267
269
  history: z.string().optional(),
268
270
  errorMessage: z.string().optional(),
271
+ responseJsonOk: z.boolean().optional(),
269
272
  });
270
273
  export type FinishRunInput = z.infer<typeof FinishRunInput>;
271
274
 
@@ -284,7 +287,7 @@ export type TokenStats = z.infer<typeof TokenStats>;
284
287
  export const OAuthStartResult = z.object({
285
288
  authUrl: z.string(),
286
289
  // redirect: 브라우저 자동 복귀(루프백 접속). code: 콘솔 콜백 + 코드 붙여넣기(원격 접속).
287
- // OpenAI 경로는 고정 loopback 콜백의 직접 PKCE 라 항상 redirect 를 반환한다.
290
+ // OpenAI 원격 경로는 등록된 loopback callback URL 전체를 붙여넣는 code 모드를 쓴다.
288
291
  mode: z.enum(["redirect", "code"]),
289
292
  });
290
293
  export type OAuthStartResult = z.infer<typeof OAuthStartResult>;
@@ -279,6 +279,27 @@ describe("RequestLogModel run lifecycle", () => {
279
279
  vi.restoreAllMocks();
280
280
  });
281
281
 
282
+ it("keeps legacy external runs unstructured when metadata is omitted", async () => {
283
+ const ubRegister = vi.fn();
284
+ const transaction = vi.fn(
285
+ async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => Promise<number>) =>
286
+ cb({ ubUpsert: vi.fn(async () => [16]) }),
287
+ );
288
+ vi.spyOn(
289
+ RequestLogModel as unknown as {
290
+ getPuri: () => { ubRegister: typeof ubRegister; transaction: typeof transaction };
291
+ },
292
+ "getPuri",
293
+ ).mockReturnValue({ ubRegister, transaction });
294
+
295
+ await RequestLogModel.createRun({ user_prompt: "plain text" });
296
+
297
+ expect(ubRegister).toHaveBeenCalledWith(
298
+ "request_logs",
299
+ expect.objectContaining({ is_structured: false, json_schema: null }),
300
+ );
301
+ });
302
+
282
303
  it("stores the requested model and attached tools but no serving model when a run starts", async () => {
283
304
  const ubRegister = vi.fn();
284
305
  const transaction = vi.fn(
@@ -296,6 +317,8 @@ describe("RequestLogModel run lifecycle", () => {
296
317
  RequestLogModel.createRun({
297
318
  user_prompt: "hi",
298
319
  requested_model_name: "openai/gpt-5-codex",
320
+ is_structured: true,
321
+ json_schema: '{"type":"object"}',
299
322
  tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
300
323
  }),
301
324
  ).resolves.toBe(17);
@@ -306,6 +329,8 @@ describe("RequestLogModel run lifecycle", () => {
306
329
  status: "running",
307
330
  requested_model_name: "openai/gpt-5-codex",
308
331
  model_name: null,
332
+ is_structured: true,
333
+ json_schema: '{"type":"object"}',
309
334
  tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
310
335
  }),
311
336
  );
@@ -483,6 +483,7 @@ class RequestLogModelClass extends BaseModelClass<
483
483
  history?: unknown;
484
484
  tools?: ToolDefinitions;
485
485
  is_image_generation?: boolean;
486
+ is_structured?: boolean;
486
487
  json_schema?: string | null;
487
488
  }): Promise<number> {
488
489
  const wdb = this.getPuri("w");
@@ -505,7 +506,8 @@ class RequestLogModelClass extends BaseModelClass<
505
506
  // 이미지 turn 식별(R13). run 경로(tools+image 조합)도 auto 경로와 동일하게 마킹.
506
507
  is_image_generation: params.is_image_generation ?? false,
507
508
  json_schema: params.json_schema ?? null,
508
- is_structured: params.json_schema !== null && params.json_schema !== undefined,
509
+ is_structured:
510
+ params.is_structured ?? (params.json_schema !== null && params.json_schema !== undefined),
509
511
  ...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
510
512
  ...(params.tools !== undefined ? { tools: params.tools } : {}),
511
513
  });
@@ -41,7 +41,8 @@ Content-Type: application/json
41
41
  "active": false,
42
42
  "ord": 0,
43
43
  "quota_threshold": null,
44
- "weight": 1
44
+ "weight": 1,
45
+ "keepalive_enabled": false
45
46
  }
46
47
  ]
47
48
  }
@@ -437,7 +438,9 @@ Content-Type: application/json
437
438
  "modelName": "MODELNAME",
438
439
  "effort": "EFFORT",
439
440
  "projectName": "PROJECTNAME",
440
- "history": "HISTORY"
441
+ "history": "HISTORY",
442
+ "isStructured": false,
443
+ "jsonSchema": null
441
444
  }
442
445
  }
443
446
 
@@ -501,7 +504,8 @@ Content-Type: application/json
501
504
  "costSource": "provider",
502
505
  "totalDurationMs": 0,
503
506
  "history": "HISTORY",
504
- "errorMessage": "ERRORMESSAGE"
507
+ "errorMessage": "ERRORMESSAGE",
508
+ "responseJsonOk": false
505
509
  }
506
510
  }
507
511
 
@@ -25,6 +25,7 @@ import {
25
25
  import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
26
26
  import { handleServerError } from "./server-error-handler";
27
27
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
28
+ import { stopOpenAICallbackRelay } from "./utils/providers/openai/openai-callback-relay";
28
29
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
29
30
 
30
31
  const host = process.env.HOST ?? "localhost";
@@ -241,6 +242,7 @@ export default defineConfig({
241
242
  const log = getLogger(["qgrid", "startup"]);
242
243
  stopExpiredTokenReminder();
243
244
  stopTokenWindowKeepalive();
245
+ stopOpenAICallbackRelay();
244
246
  setSettingChangeHandler(null);
245
247
  QgridDispatcher.subscriber?.setTokenChangeHandler(null);
246
248
  if (QgridDispatcher.openaiDispatcher) {
@@ -12,12 +12,14 @@ const {
12
12
  runClaudeSessionMock,
13
13
  refreshTokenMock,
14
14
  readAnthropicQuotaUsageMock,
15
+ invalidateAnthropicQuotaUsageMock,
15
16
  loggerInfoMock,
16
17
  loggerWarnMock,
17
18
  } = vi.hoisted(() => ({
18
19
  runClaudeSessionMock: vi.fn(),
19
20
  refreshTokenMock: vi.fn(),
20
21
  readAnthropicQuotaUsageMock: vi.fn(),
22
+ invalidateAnthropicQuotaUsageMock: vi.fn(),
21
23
  loggerInfoMock: vi.fn(),
22
24
  loggerWarnMock: vi.fn(),
23
25
  }));
@@ -33,6 +35,7 @@ vi.mock("./claude-session", async (importActual) => {
33
35
 
34
36
  vi.mock("./anthropic-quota", () => ({
35
37
  readAnthropicQuotaUsage: readAnthropicQuotaUsageMock,
38
+ invalidateAnthropicQuotaUsage: invalidateAnthropicQuotaUsageMock,
36
39
  }));
37
40
 
38
41
  vi.mock("../../../application/qgrid/qgrid.frame", () => ({
@@ -112,6 +115,7 @@ describe("AnthropicDispatcher", () => {
112
115
  refreshTokenMock.mockReset();
113
116
  refreshTokenMock.mockResolvedValue("sk-ant-oat01-refreshed");
114
117
  readAnthropicQuotaUsageMock.mockReset();
118
+ invalidateAnthropicQuotaUsageMock.mockReset();
115
119
  loggerInfoMock.mockReset();
116
120
  loggerWarnMock.mockReset();
117
121
  });
@@ -211,6 +215,21 @@ describe("AnthropicDispatcher", () => {
211
215
  runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
212
216
 
213
217
  await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
218
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenCalledWith("sk-ant-oat01-test");
219
+ });
220
+
221
+ it("refresh 후 quota 소진은 선택 전후 access token cache를 모두 무효화한다", async () => {
222
+ const d = new AnthropicDispatcher();
223
+ d.onTokenAdded(1, "tok-A", creds(10_000), null, 1);
224
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
225
+
226
+ await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
227
+
228
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(1, "sk-ant-oat01-test");
229
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(
230
+ 2,
231
+ "sk-ant-oat01-refreshed",
232
+ );
214
233
  });
215
234
 
216
235
  it("claude 에러 → 에러", async () => {
@@ -306,6 +325,23 @@ describe("AnthropicDispatcher", () => {
306
325
  expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
307
326
  });
308
327
 
328
+ it("Fable 요청은 Fable 전용 quota가 소진된 토큰을 건너뛴다", async () => {
329
+ const d = new AnthropicDispatcher();
330
+ d.onTokenAdded(1, "tok-A", creds(), 80, 1);
331
+ d.onTokenAdded(2, "tok-B", creds(), 80, 1);
332
+ readAnthropicQuotaUsageMock
333
+ .mockResolvedValueOnce(quotaOk(100))
334
+ .mockResolvedValueOnce(quotaOk(10));
335
+
336
+ const result = await d.generate(baseReq({ model: "claude-fable-5" }));
337
+
338
+ expect(result.tokenName).toBe("tok-B");
339
+ expect(readAnthropicQuotaUsageMock).toHaveBeenCalledWith(
340
+ expect.any(String),
341
+ "claude-fable-5",
342
+ );
343
+ });
344
+
309
345
  it("resets the schedule when a token weight changes", async () => {
310
346
  const d = new AnthropicDispatcher();
311
347
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -26,7 +26,11 @@ import {
26
26
  import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
27
27
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
28
28
  import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
29
- import { readAnthropicQuotaUsage, type AnthropicQuotaUsageResult } from "./anthropic-quota";
29
+ import {
30
+ invalidateAnthropicQuotaUsage,
31
+ readAnthropicQuotaUsage,
32
+ type AnthropicQuotaUsageResult,
33
+ } from "./anthropic-quota";
30
34
  import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
31
35
 
32
36
  const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
@@ -163,7 +167,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
163
167
 
164
168
  // 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
165
169
  // 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
166
- private async selectToken(preferredTokenId?: number): Promise<PooledToken | null> {
170
+ private async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
167
171
  const preferred =
168
172
  preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
169
173
  if (preferredTokenId !== undefined && !preferred) {
@@ -172,7 +176,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
172
176
 
173
177
  const rows = preferred ? [preferred] : [...this.tokenPool.values()];
174
178
  if (rows.length === 0) return null;
175
- const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
179
+ const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows, model);
176
180
  if (eligible.length === 0) {
177
181
  logger.warn("quota_threshold gate: all_exceeded", {
178
182
  provider: "anthropic",
@@ -193,7 +197,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
193
197
  return this.tokenPool.get(selectedId) ?? null;
194
198
  }
195
199
 
196
- private async filterEligibleTokens(rows: PooledToken[]): Promise<{
200
+ private async filterEligibleTokens(
201
+ rows: PooledToken[],
202
+ model: string,
203
+ ): Promise<{
197
204
  eligible: PooledToken[];
198
205
  overThresholdTokens: Array<{ tokenName: string; threshold: number }>;
199
206
  }> {
@@ -207,7 +214,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
207
214
  const checks = await Promise.allSettled(
208
215
  thresholded.map(async (token) => ({
209
216
  token,
210
- result: await readAnthropicQuotaUsage(token.credentials.accessToken),
217
+ result: await readAnthropicQuotaUsage(token.credentials.accessToken, model),
211
218
  })),
212
219
  );
213
220
 
@@ -314,7 +321,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
314
321
  const model = canonicalAnthropicModel(req.model);
315
322
  const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
316
323
 
317
- const token = await this.selectToken(req.preferredTokenId);
324
+ const token = await this.selectToken(model, req.preferredTokenId);
318
325
  if (!token) throw new Error("No anthropic tokens available");
319
326
 
320
327
  const exec = async (): Promise<GenerateResult> => {
@@ -359,7 +366,13 @@ export class AnthropicDispatcher implements ProviderDispatcher {
359
366
  onDelta,
360
367
  );
361
368
 
362
- if (session.quotaExhausted) throw new Error(`quota exhausted (${token.name})`);
369
+ if (session.quotaExhausted) {
370
+ invalidateAnthropicQuotaUsage(token.credentials.accessToken);
371
+ if (accessToken !== token.credentials.accessToken) {
372
+ invalidateAnthropicQuotaUsage(accessToken);
373
+ }
374
+ throw new Error(`quota exhausted (${token.name})`);
375
+ }
363
376
  if (session.isError) {
364
377
  // 진단(SON-495): isError 판정 사유(subtype/terminal_reason)를 메시지 앞에 드러낸다.
365
378
  // 그동안 detail 이 session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /
@@ -34,6 +34,28 @@ describe("readAnthropicQuotaUsage", () => {
34
34
  });
35
35
  });
36
36
 
37
+ it("uses the separate Fable 5 limit only for Fable requests", async () => {
38
+ const usage = {
39
+ data: {
40
+ five_hour: { utilization: 10, resets_at: null },
41
+ seven_day_overage_included: { utilization: 100, resets_at: null },
42
+ },
43
+ cachedAt: Date.now(),
44
+ };
45
+ fetchUsageWithMetaMock.mockResolvedValue(usage);
46
+
47
+ await expect(readAnthropicQuotaUsage("access-token", "claude-fable-5")).resolves.toMatchObject({
48
+ kind: "ok",
49
+ utilizationPct: 100,
50
+ });
51
+ await expect(
52
+ readAnthropicQuotaUsage("access-token", "claude-sonnet-4-6"),
53
+ ).resolves.toMatchObject({
54
+ kind: "ok",
55
+ utilizationPct: 10,
56
+ });
57
+ });
58
+
37
59
  it("keeps low percent utilization unchanged", async () => {
38
60
  fetchUsageWithMetaMock.mockResolvedValueOnce({
39
61
  data: { five_hour: { utilization: 1, resets_at: null } },
@@ -1,4 +1,9 @@
1
- import { fetchUsageWithMeta } from "../../../application/qgrid/oauth";
1
+ import {
2
+ fetchUsageWithMeta,
3
+ invalidateAnthropicQuotaUsage,
4
+ } from "../../../application/qgrid/oauth";
5
+
6
+ export { invalidateAnthropicQuotaUsage };
2
7
 
3
8
  export type AnthropicQuotaUsageResult =
4
9
  | { kind: "ok"; utilizationPct: number; cacheAgeMs: number }
@@ -6,12 +11,21 @@ export type AnthropicQuotaUsageResult =
6
11
 
7
12
  export async function readAnthropicQuotaUsage(
8
13
  accessToken: string,
14
+ model?: string,
9
15
  ): Promise<AnthropicQuotaUsageResult> {
10
16
  try {
11
17
  const { data, cachedAt } = await fetchUsageWithMeta(accessToken);
12
18
  if (data.error) return { kind: "lookup_failed", reason: data.error };
13
19
 
14
- const utilization = data.five_hour?.utilization;
20
+ const utilizations = [data.five_hour?.utilization];
21
+ if (model === "claude-fable-5") {
22
+ utilizations.push(data.seven_day_overage_included?.utilization);
23
+ }
24
+ const utilization = Math.max(
25
+ ...utilizations.filter(
26
+ (value): value is number => typeof value === "number" && Number.isFinite(value),
27
+ ),
28
+ );
15
29
  if (typeof utilization !== "number" || !Number.isFinite(utilization)) {
16
30
  return { kind: "lookup_failed", reason: "five_hour utilization unavailable" };
17
31
  }
@@ -0,0 +1,109 @@
1
+ import http from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ OPENAI_CALLBACK_PATH,
8
+ OPENAI_CALLBACK_PORTS,
9
+ openAICallbackRedirectUri,
10
+ startOpenAICallbackRelay,
11
+ stopOpenAICallbackRelay,
12
+ } from "./openai-callback-relay";
13
+
14
+ const DASHBOARD = "http://localhost:44900";
15
+
16
+ function freePort(): Promise<number> {
17
+ return new Promise((resolve) => {
18
+ const probe = http.createServer();
19
+ probe.listen(0, "127.0.0.1", () => {
20
+ const port = (probe.address() as AddressInfo).port;
21
+ probe.close(() => resolve(port));
22
+ });
23
+ });
24
+ }
25
+
26
+ function occupy(port: number): Promise<http.Server> {
27
+ return new Promise((resolve) => {
28
+ const server = http.createServer();
29
+ server.listen(port, "127.0.0.1", () => resolve(server));
30
+ });
31
+ }
32
+
33
+ function get(url: string): Promise<{ status: number; location?: string }> {
34
+ return new Promise((resolve, reject) => {
35
+ const request = http.get(url, (response) => {
36
+ response.resume();
37
+ resolve({
38
+ status: response.statusCode ?? 0,
39
+ ...(response.headers.location ? { location: response.headers.location } : {}),
40
+ });
41
+ });
42
+ request.on("error", reject);
43
+ });
44
+ }
45
+
46
+ describe("openai callback relay", () => {
47
+ const occupied: http.Server[] = [];
48
+
49
+ afterEach(async () => {
50
+ stopOpenAICallbackRelay();
51
+ await Promise.all(occupied.splice(0).map((s) => new Promise((r) => s.close(r))));
52
+ });
53
+
54
+ it("pins the redirect URI to the ports OpenAI registered for the Codex CLI client", () => {
55
+ expect(OPENAI_CALLBACK_PORTS).toEqual([1455, 1457]);
56
+ expect(openAICallbackRedirectUri(1455)).toBe("http://localhost:1455/auth/callback");
57
+ });
58
+
59
+ it("forwards only code and state to the qgrid callback route", async () => {
60
+ const port = await freePort();
61
+ const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
62
+ expect(redirectUri).toBe(`http://localhost:${port}${OPENAI_CALLBACK_PATH}`);
63
+
64
+ const result = await get(
65
+ `http://127.0.0.1:${port}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state&extra=x`,
66
+ );
67
+
68
+ expect(result.status).toBe(302);
69
+ expect(result.location).toBe(
70
+ `${DASHBOARD}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state`,
71
+ );
72
+ });
73
+
74
+ it("answers 404 on any other path", async () => {
75
+ const port = await freePort();
76
+ await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
77
+
78
+ expect((await get(`http://127.0.0.1:${port}/callback?code=c&state=s`)).status).toBe(404);
79
+ });
80
+
81
+ it("falls back to the next registered port when the first is taken", async () => {
82
+ const taken = await freePort();
83
+ const fallback = await freePort();
84
+ occupied.push(await occupy(taken));
85
+
86
+ const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [taken, fallback]);
87
+
88
+ expect(redirectUri).toBe(`http://localhost:${fallback}${OPENAI_CALLBACK_PATH}`);
89
+ });
90
+
91
+ it("reuses the running listener for a second login", async () => {
92
+ const port = await freePort();
93
+ const first = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
94
+ const second = await startOpenAICallbackRelay(DASHBOARD, 60_000, [await freePort()]);
95
+
96
+ expect(second).toBe(first);
97
+ });
98
+
99
+ it("fails when every registered port is taken", async () => {
100
+ const a = await freePort();
101
+ const b = await freePort();
102
+ occupied.push(await occupy(a), await occupy(b));
103
+
104
+ await expect(startOpenAICallbackRelay(DASHBOARD, 60_000, [a, b])).rejects.toThrow(
105
+ /PORTS_UNAVAILABLE/,
106
+ );
107
+ });
108
+ });
109
+