@cartanova/qgrid-cli 2.7.7 → 2.7.9

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 (37) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +63 -28
  2. package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
  3. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  4. package/bundle/dist/sonamu.config.js +3 -1
  5. package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
  6. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
  7. package/bundle/src/application/qgrid/qgrid.frame.test.ts +43 -0
  8. package/bundle/src/application/qgrid/qgrid.frame.ts +72 -26
  9. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  10. package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
  11. package/bundle/src/application/request-log/request-log.model.ts +3 -1
  12. package/bundle/src/application/sonamu.generated.http +7 -3
  13. package/bundle/src/sonamu.config.ts +2 -0
  14. package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
  15. package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
  16. package/bundle/web-dist/client/assets/{index-CokxkK2G.js → index-D59Q_9Z4.js} +3 -3
  17. package/bundle/web-dist/client/assets/{logs-Ckqp98Y3.js → logs-BHVfI8ut.js} +1 -1
  18. package/bundle/web-dist/client/assets/{monit-u0pMHbyf.js → monit-EkLxH1oN.js} +1 -1
  19. package/bundle/web-dist/client/assets/{routes-DobxbCAV.js → routes-Kbvuclip.js} +2 -2
  20. package/bundle/web-dist/client/assets/{settings-C8rfyi9z.js → settings-B4uTFP3J.js} +1 -1
  21. package/bundle/web-dist/client/assets/{show-FbUQYs4L.js → show-BjIdSPTd.js} +1 -1
  22. package/bundle/web-dist/client/assets/tokens-BbCSxbFu.js +1 -0
  23. package/bundle/web-dist/client/assets/use-oauth-login-flow-4OltfVAd.js +1 -0
  24. package/bundle/web-dist/client/index.html +1 -1
  25. package/bundle/web-dist/server/assets/{routes-B9X0lvjQ.js → routes-Ct3I9263.js} +3 -2
  26. package/bundle/web-dist/server/assets/{tokens-CyItE-L-.js → tokens-PR42jF1J.js} +3 -2
  27. package/bundle/web-dist/server/assets/{use-oauth-login-flow-CItVKngs.js → use-oauth-login-flow-C-33Z5HA.js} +16 -10
  28. package/bundle/web-dist/server/entry-server.generated.js +3 -3
  29. package/dist/cli.js +10 -11
  30. package/package.json +1 -1
  31. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -0
  32. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  33. package/skills/qgrid/references/openai-codex-runtime.md +12 -5
  34. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  35. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  36. package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +0 -1
  37. package/bundle/web-dist/client/assets/use-oauth-login-flow-D-9h3WUt.js +0 -1
@@ -4,10 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { CONSOLE_CALLBACK_URL } from "./oauth";
5
5
  import { QgridFrame } from "./qgrid.frame";
6
6
 
7
- const { exchangeMock, openAIExchangeMock, tokenSaveMock, tokenDelMock, findByAccountMock, notifyMock } =
8
- vi.hoisted(() => ({
7
+ const {
8
+ exchangeMock,
9
+ openAIExchangeMock,
10
+ relayStartMock,
11
+ tokenSaveMock,
12
+ tokenDelMock,
13
+ findByAccountMock,
14
+ notifyMock,
15
+ } = vi.hoisted(() => ({
9
16
  exchangeMock: vi.fn(),
10
17
  openAIExchangeMock: vi.fn(),
18
+ relayStartMock: vi.fn(async () => "http://localhost:1455/auth/callback"),
11
19
  tokenSaveMock: vi.fn(async () => [1]),
12
20
  tokenDelMock: vi.fn(async () => 1),
13
21
  findByAccountMock: vi.fn(async () => []),
@@ -40,6 +48,12 @@ vi.mock("../../utils/providers/openai/openai-oauth", async (importOriginal) => (
40
48
  exchangeOpenAICode: openAIExchangeMock,
41
49
  }));
42
50
 
51
+ // The relay binds a real loopback port; these tests only assert which redirect URI is signed in.
52
+ vi.mock("../../utils/providers/openai/openai-callback-relay", async (importOriginal) => ({
53
+ ...(await importOriginal<typeof import("../../utils/providers/openai/openai-callback-relay")>()),
54
+ startOpenAICallbackRelay: relayStartMock,
55
+ }));
56
+
43
57
  vi.mock("../token/token.model", () => ({
44
58
  TokenModel: {
45
59
  save: tokenSaveMock,
@@ -193,22 +207,36 @@ describe("QgridFrame direct OpenAI OAuth", () => {
193
207
  tokenDelMock.mockClear();
194
208
  findByAccountMock.mockClear().mockResolvedValue([]);
195
209
  notifyMock.mockClear();
210
+ relayStartMock.mockClear();
196
211
  });
197
212
 
198
- it("uses the pinned loopback callback despite hostile forwarding headers", async () => {
213
+ it("uses manual code completion without opening a server relay on remote origins", async () => {
199
214
  mockHttpContext({ origin: "https://qgrid.example.com" });
200
215
 
201
216
  const result = await QgridFrame.oauthStartOpenAI("openai-token");
202
217
  const url = new URL(result.authUrl);
203
- expect(result.mode).toBe("redirect");
218
+ expect(result.mode).toBe("code");
204
219
  expect(url.origin + url.pathname).toBe("https://auth.openai.com/oauth/authorize");
205
- expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:44900/auth/callback");
220
+ expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback");
206
221
  expect(url.searchParams.get("state")).toBeTruthy();
207
222
  expect(url.searchParams.get("code_challenge_method")).toBe("S256");
223
+ expect(relayStartMock).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("keeps the automatic callback relay for loopback origins", async () => {
227
+ mockHttpContext({ origin: "http://localhost:44900" });
228
+
229
+ const result = await QgridFrame.oauthStartOpenAI("openai-token");
230
+
231
+ expect(result.mode).toBe("redirect");
232
+ expect(authUrlParam(result.authUrl, "redirect_uri")).toBe(
233
+ "http://localhost:1455/auth/callback",
234
+ );
235
+ expect(relayStartMock).toHaveBeenCalledWith("http://localhost:44900", 5 * 60_000);
208
236
  });
209
237
 
210
238
  it("exchanges an OpenAI callback, replaces the duplicate account, and notifies", async () => {
211
- mockHttpContext({ origin: "https://qgrid.example.com" });
239
+ mockHttpContext({ origin: "http://localhost:44900" });
212
240
  const { authUrl } = await QgridFrame.oauthStartOpenAI("openai-token");
213
241
  const state = authUrlParam(authUrl, "state")!;
214
242
  findByAccountMock.mockResolvedValueOnce([{ id: 9, active: true }] as never);
@@ -228,7 +256,7 @@ describe("QgridFrame direct OpenAI OAuth", () => {
228
256
  expect(openAIExchangeMock).toHaveBeenCalledWith(
229
257
  "the-code",
230
258
  expect.any(String),
231
- "http://localhost:44900/auth/callback",
259
+ "http://localhost:1455/auth/callback",
232
260
  );
233
261
  expect(tokenDelMock).toHaveBeenCalledOnce();
234
262
  expect(tokenSaveMock).toHaveBeenCalledWith([
@@ -242,8 +270,43 @@ describe("QgridFrame direct OpenAI OAuth", () => {
242
270
  expect(reply.redirect).toHaveBeenCalledWith("/?oauth=success&name=openai-token");
243
271
  });
244
272
 
245
- it("consumes OpenAI state before a failed exchange", async () => {
273
+ it("exchanges a pasted OpenAI callback URL for remote login", async () => {
246
274
  mockHttpContext({ origin: "https://qgrid.example.com" });
275
+ const { authUrl } = await QgridFrame.oauthStartOpenAI("remote-openai-token");
276
+ const state = authUrlParam(authUrl, "state")!;
277
+ openAIExchangeMock.mockResolvedValueOnce({
278
+ accessToken: "access",
279
+ refreshToken: "refresh",
280
+ idToken: "id",
281
+ accessTokenExpiresAt: 123,
282
+ idTokenExpiresAt: 456,
283
+ accountId: "account-1",
284
+ planType: "pro",
285
+ });
286
+
287
+ await expect(
288
+ QgridFrame.oauthComplete(
289
+ `http://localhost:1455/auth/callback?code=the-code&state=${state}`,
290
+ ),
291
+ ).resolves.toEqual({ added: true, name: "remote-openai-token" });
292
+
293
+ expect(openAIExchangeMock).toHaveBeenCalledWith(
294
+ "the-code",
295
+ expect.any(String),
296
+ "http://localhost:1455/auth/callback",
297
+ );
298
+ expect(tokenSaveMock).toHaveBeenCalledWith([
299
+ expect.objectContaining({
300
+ provider: "openai",
301
+ name: "remote-openai-token",
302
+ credentials: expect.objectContaining({ accountId: "account-1" }),
303
+ }),
304
+ ]);
305
+ expect(notifyMock).toHaveBeenCalledWith("remote-openai-token", "openai");
306
+ });
307
+
308
+ it("consumes OpenAI state before a failed exchange", async () => {
309
+ mockHttpContext({ origin: "http://localhost:44900" });
247
310
  const { authUrl } = await QgridFrame.oauthStartOpenAI("openai-token");
248
311
  const state = authUrlParam(authUrl, "state")!;
249
312
  openAIExchangeMock.mockRejectedValueOnce(new Error("exchange rejected"));
@@ -14,6 +14,7 @@ const {
14
14
  updateFieldsMock,
15
15
  requestLogSaveMock,
16
16
  requestLogCreateRunMock,
17
+ requestLogFinishRunMock,
17
18
  appendStepMock,
18
19
  dispatcherQueryMock,
19
20
  dispatcherQueryStreamMock,
@@ -36,6 +37,7 @@ const {
36
37
  updateFieldsMock: vi.fn(),
37
38
  requestLogSaveMock: vi.fn(),
38
39
  requestLogCreateRunMock: vi.fn(),
40
+ requestLogFinishRunMock: vi.fn(),
39
41
  appendStepMock: vi.fn(),
40
42
  dispatcherQueryMock: vi.fn(),
41
43
  dispatcherQueryStreamMock: vi.fn(),
@@ -53,6 +55,7 @@ vi.mock("../request-log/request-log.model", () => ({
53
55
  RequestLogModel: {
54
56
  save: requestLogSaveMock,
55
57
  createRun: requestLogCreateRunMock,
58
+ finishRun: requestLogFinishRunMock,
56
59
  appendStep: appendStepMock,
57
60
  },
58
61
  }));
@@ -890,6 +893,46 @@ describe("QgridFrame raw lifecycle API", () => {
890
893
  expect.not.objectContaining({ model_name: expect.anything() }),
891
894
  );
892
895
  });
896
+
897
+ it("passes optional structured metadata through the external createRun API", async () => {
898
+ requestLogCreateRunMock.mockReset().mockResolvedValue(82);
899
+ const jsonSchema = JSON.stringify({
900
+ type: "object",
901
+ properties: { answer: { type: "string" } },
902
+ required: ["answer"],
903
+ });
904
+
905
+ await QgridFrame.createRun({
906
+ userPrompt: "hi",
907
+ modelName: "google/gemini-3.5-flash-lite",
908
+ isStructured: true,
909
+ jsonSchema,
910
+ });
911
+
912
+ expect(requestLogCreateRunMock).toHaveBeenCalledWith(
913
+ expect.objectContaining({
914
+ is_structured: true,
915
+ json_schema: jsonSchema,
916
+ }),
917
+ );
918
+ });
919
+
920
+ it("passes optional response JSON validity through the external finishRun API", async () => {
921
+ requestLogFinishRunMock.mockReset().mockResolvedValue(undefined);
922
+
923
+ await QgridFrame.finishRun({
924
+ requestLogId: 82,
925
+ status: "succeeded",
926
+ response: '{"answer":"ok"}',
927
+ tokenName: "external",
928
+ responseJsonOk: true,
929
+ });
930
+
931
+ expect(requestLogFinishRunMock).toHaveBeenCalledWith(
932
+ 82,
933
+ expect.objectContaining({ response_json_ok: true }),
934
+ );
935
+ });
893
936
  });
894
937
 
895
938
  describe("QgridFrame.queryStream request logging", () => {
@@ -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"] })
@@ -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) {
@@ -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
+