@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.
- package/bundle/dist/application/qgrid/oauth.js +5 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +63 -28
- package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
- package/bundle/dist/application/request-log/request-log.model.js +2 -2
- package/bundle/dist/sonamu.config.js +3 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +12 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +7 -5
- package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
- package/bundle/src/application/qgrid/oauth.test.ts +28 -0
- package/bundle/src/application/qgrid/oauth.ts +5 -0
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +43 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +72 -26
- package/bundle/src/application/qgrid/qgrid.types.ts +5 -2
- package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
- package/bundle/src/application/request-log/request-log.model.ts +3 -1
- package/bundle/src/application/sonamu.generated.http +7 -3
- package/bundle/src/sonamu.config.ts +2 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +20 -7
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +22 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +16 -2
- package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
- package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
- package/bundle/web-dist/client/assets/{index-CokxkK2G.js → index-D208L33c.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-Ckqp98Y3.js → logs-noN5Aka6.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-u0pMHbyf.js → monit-Ber13xcA.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-DobxbCAV.js → routes-3_xetpSK.js} +2 -2
- package/bundle/web-dist/client/assets/{settings-C8rfyi9z.js → settings-C5sHCg8X.js} +1 -1
- package/bundle/web-dist/client/assets/{show-FbUQYs4L.js → show-BhTgGnkc.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-R7obwdAV.js +1 -0
- package/bundle/web-dist/client/assets/use-oauth-login-flow-CRAqRZXX.js +1 -0
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{routes-B9X0lvjQ.js → routes-Ct3I9263.js} +3 -2
- package/bundle/web-dist/server/assets/{tokens-CyItE-L-.js → tokens-PR42jF1J.js} +3 -2
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-CItVKngs.js → use-oauth-login-flow-C-33Z5HA.js} +16 -10
- package/bundle/web-dist/server/entry-server.generated.js +3 -3
- package/dist/cli.js +10 -11
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +7 -2
- package/skills/qgrid/references/ai-sdk-provider-contract.md +7 -0
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +5 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +3 -1
- package/skills/qgrid/references/docs-workflow.md +3 -3
- package/skills/qgrid/references/openai-codex-runtime.md +12 -5
- package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +4 -2
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +7 -2
- package/skills/qgrid/references/verification-and-debugging.md +19 -16
- package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
581
|
-
|
|
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
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 경로는
|
|
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:
|
|
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 {
|
|
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(
|
|
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)
|
|
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 {
|
|
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
|
|
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
|
+
|