@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.
- 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/openai/openai-callback-relay.js +86 -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 +4 -1
- 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/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-D59Q_9Z4.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-Ckqp98Y3.js → logs-BHVfI8ut.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-u0pMHbyf.js → monit-EkLxH1oN.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-DobxbCAV.js → routes-Kbvuclip.js} +2 -2
- package/bundle/web-dist/client/assets/{settings-C8rfyi9z.js → settings-B4uTFP3J.js} +1 -1
- package/bundle/web-dist/client/assets/{show-FbUQYs4L.js → show-BjIdSPTd.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-BbCSxbFu.js +1 -0
- package/bundle/web-dist/client/assets/use-oauth-login-flow-4OltfVAd.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/references/ai-sdk-provider-contract.md +2 -0
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- 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/token-auth-quota-lifecycle.md +2 -0
- 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
|
@@ -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 {
|
|
8
|
-
|
|
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
|
|
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("
|
|
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:
|
|
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: "
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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"] })
|
|
@@ -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) {
|
|
@@ -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
|
+
|