@cartanova/qgrid-cli 2.9.2 → 2.9.5
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/expired-token-reminder.js +6 -5
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
- package/bundle/dist/application/qgrid/qgrid-response-format.js +8 -6
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +4 -3
- package/bundle/dist/application/qgrid/qgrid.types.js +31 -4
- package/bundle/dist/application/qgrid/token-death.js +6 -22
- package/bundle/dist/application/qgrid/token-subscriber.js +2 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +3 -2
- package/bundle/dist/application/setting/setting.model.js +8 -2
- package/bundle/dist/application/token/token.model.js +22 -17
- package/bundle/dist/utils/providers/common/model-cost.js +21 -13
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +41 -20
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +145 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
- package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
- package/bundle/dist/utils/slack-notify.js +8 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
- package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
- package/bundle/src/application/qgrid/qgrid-response-format.test.ts +26 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +21 -13
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +20 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +3 -2
- package/bundle/src/application/qgrid/qgrid.types.ts +32 -0
- package/bundle/src/application/qgrid/token-death.test.ts +11 -10
- package/bundle/src/application/qgrid/token-death.ts +9 -21
- package/bundle/src/application/qgrid/token-subscriber.test.ts +5 -1
- package/bundle/src/application/qgrid/token-subscriber.ts +1 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +18 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +3 -0
- package/bundle/src/application/setting/setting.model.test.ts +20 -0
- package/bundle/src/application/setting/setting.model.ts +10 -2
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/token/token.model.test.ts +24 -5
- package/bundle/src/application/token/token.model.ts +30 -23
- package/bundle/src/utils/providers/common/effort.test.ts +8 -0
- package/bundle/src/utils/providers/common/model-cost.test.ts +25 -12
- package/bundle/src/utils/providers/common/model-cost.ts +28 -14
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +5 -1
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +9 -1
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +137 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +44 -18
- package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +135 -0
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +177 -0
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
- package/bundle/src/utils/slack-notify.test.ts +14 -0
- package/bundle/src/utils/slack-notify.ts +19 -3
- package/bundle/web-dist/client/assets/{index-DH1n8kgG.js → index-BcCytvFf.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D36O-NET.js → logs-BkOilk5x.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-DhZAaz9K.js → monit-8oCSLCdL.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-C7q1CsSv.js → routes-DFIF4IoW.js} +2 -2
- package/bundle/web-dist/client/assets/{settings-M_jfoIAe.js → settings-nUI-DR3U.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CORjMGQs.js → show-Bzx6Xkoc.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-C7-j4VXD.js → tokens-DFWCL7Ts.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Fi62xbBc.js → use-oauth-login-flow-B5MAjpVc.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{routes-CxMHXfdH.js → routes-B408iqh1.js} +29 -14
- package/bundle/web-dist/server/entry-server.generated.js +3 -2
- package/package.json +2 -1
- package/skills/qgrid/SKILL.md +2 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -2
- package/skills/qgrid/references/decision-rationale.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +79 -0
- package/skills/qgrid/references/provider-runtime-differences.md +1 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +4 -3
|
@@ -186,4 +186,36 @@ describe("readOpenAIQuotaUsage", () => {
|
|
|
186
186
|
}),
|
|
187
187
|
).resolves.toEqual({ kind: "lookup_failed", reason: "OpenAI quota lookup failed: HTTP 503" });
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
it("does not refresh a cancelled 401 lookup", async () => {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const refreshCredentials = vi.fn();
|
|
193
|
+
const result = await readOpenAIQuotaUsage({
|
|
194
|
+
credentials: { accessToken: "old", accountId: "acct" },
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
refreshCredentials,
|
|
197
|
+
fetch: async () => {
|
|
198
|
+
controller.abort(new Error("cancelled"));
|
|
199
|
+
return new Response("", { status: 401 });
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
|
|
203
|
+
expect(refreshCredentials).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does not retry when cancelled while refreshing", async () => {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
|
209
|
+
const result = await readOpenAIQuotaUsage({
|
|
210
|
+
credentials: { accessToken: "old", accountId: "acct" },
|
|
211
|
+
signal: controller.signal,
|
|
212
|
+
fetch: fetchMock,
|
|
213
|
+
refreshCredentials: async () => {
|
|
214
|
+
controller.abort(new Error("cancelled"));
|
|
215
|
+
return { accessToken: "new", accountId: "acct" };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
|
|
219
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
220
|
+
});
|
|
189
221
|
});
|
|
@@ -23,19 +23,26 @@ export type OpenAIQuotaUsageResult =
|
|
|
23
23
|
|
|
24
24
|
export interface OpenAIQuotaHttpOptions {
|
|
25
25
|
credentials: Pick<OpenAICredentials, "accessToken" | "accountId">;
|
|
26
|
+
refreshCredentials?: () => Promise<Pick<OpenAICredentials, "accessToken" | "accountId">>;
|
|
26
27
|
fetch?: typeof fetch;
|
|
27
28
|
signal?: AbortSignal;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
async function readDirect(options: OpenAIQuotaHttpOptions): Promise<OpenAIRateLimitsWithMeta> {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
options.
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
const read = (credentials: OpenAIQuotaHttpOptions["credentials"]) =>
|
|
33
|
+
(options.fetch ?? fetch)(CHATGPT_WHAM_USAGE_URL, {
|
|
34
|
+
method: "GET",
|
|
35
|
+
headers: buildCodexIdentityHeaders(credentials.accessToken, credentials.accountId),
|
|
36
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
37
|
+
});
|
|
38
|
+
let response = await read(options.credentials);
|
|
39
|
+
if (response.status === 401 && options.refreshCredentials) {
|
|
40
|
+
await response.body?.cancel();
|
|
41
|
+
options.signal?.throwIfAborted();
|
|
42
|
+
const credentials = await options.refreshCredentials();
|
|
43
|
+
options.signal?.throwIfAborted();
|
|
44
|
+
response = await read(credentials);
|
|
45
|
+
}
|
|
39
46
|
if (!response.ok) throw new Error(`OpenAI quota lookup failed: HTTP ${response.status}`);
|
|
40
47
|
const body = (await response.json()) as Record<string, unknown>;
|
|
41
48
|
const data = normalizeWhamUsage(body);
|
|
@@ -126,4 +126,22 @@ describe("OpenAI refresh", () => {
|
|
|
126
126
|
"openai:refresh_token_reused",
|
|
127
127
|
);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it("recognizes nested permanent error codes without exposing the provider body", async () => {
|
|
131
|
+
findOneMock.mockResolvedValue(token(12));
|
|
132
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
133
|
+
error: { code: "refresh_token_invalidated", message: "private provider detail" },
|
|
134
|
+
}), { status: 401 })));
|
|
135
|
+
await expect(handleChatgptAuthTokensRefresh(12)).rejects.toThrow(
|
|
136
|
+
"OpenAI refresh failed: 401 refresh_token_invalidated",
|
|
137
|
+
);
|
|
138
|
+
expect(deactivateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 12 }), "openai:refresh_token_invalidated");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it.each([429, 500, 503])("does not deactivate a token on transient refresh HTTP %s", async (status) => {
|
|
142
|
+
findOneMock.mockResolvedValue(token(status));
|
|
143
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("upstream failure", { status })));
|
|
144
|
+
await expect(handleChatgptAuthTokensRefresh(status)).rejects.toThrow(`OpenAI refresh failed: ${status} unknown`);
|
|
145
|
+
expect(deactivateMock).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
129
147
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI chatgptAuthTokens refresh handler.
|
|
3
3
|
*
|
|
4
|
-
* - direct
|
|
4
|
+
* - direct transport와 quota 조회의 401에서 같은 refresh 경로를 사용함
|
|
5
5
|
* - qgrid 가 DB 의 refresh_token 으로 새 access_token 을 발급받아 응답
|
|
6
6
|
* - per-token inflight promise 로 concurrent refresh dedup
|
|
7
7
|
* - rotation 감지: 새 refresh_token 있으면 DB 즉시 업데이트
|
|
@@ -104,11 +104,18 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
104
104
|
// 위 3개는 모두 영구 실패. 재등록 외 복구 불가.
|
|
105
105
|
let errorCode: string | undefined;
|
|
106
106
|
try {
|
|
107
|
-
|
|
107
|
+
const error = (JSON.parse(body) as { error?: unknown }).error;
|
|
108
|
+
const code =
|
|
109
|
+
typeof error === "string"
|
|
110
|
+
? error
|
|
111
|
+
: error && typeof error === "object" && "code" in error
|
|
112
|
+
? error.code
|
|
113
|
+
: undefined;
|
|
114
|
+
if (typeof code === "string" && /^[a-z_]{1,80}$/.test(code)) errorCode = code;
|
|
108
115
|
} catch {}
|
|
109
116
|
logger.error(
|
|
110
117
|
`OpenAI refresh FAILED token=${token.name}(id=${tokenId}) status=${resp.status} ` +
|
|
111
|
-
`code=${errorCode ?? "
|
|
118
|
+
`code=${errorCode ?? "unknown"}`,
|
|
112
119
|
);
|
|
113
120
|
// 영구 실패는 errorCode 로만 판정한다 — status 를 함께 요구하면 provider 가
|
|
114
121
|
// 상태코드를 바꿀 때 판정이 조용히 멎는다.
|
|
@@ -118,7 +125,7 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
118
125
|
`openai:${errorCode}`,
|
|
119
126
|
);
|
|
120
127
|
}
|
|
121
|
-
throw new Error(`OpenAI refresh failed: ${resp.status} ${
|
|
128
|
+
throw new Error(`OpenAI refresh failed: ${resp.status} ${errorCode ?? "unknown"}`);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
const data = (await resp.json()) as {
|
|
@@ -19,6 +19,20 @@ const noSettings = () => undefined;
|
|
|
19
19
|
const WORKING_HOURS = seoul("2026-08-05T14:00:00");
|
|
20
20
|
|
|
21
21
|
describe("notifySlack", () => {
|
|
22
|
+
it.each([
|
|
23
|
+
["not_in_channel", () => Promise.resolve(new Response(JSON.stringify({ ok: false, error: "not_in_channel" })))],
|
|
24
|
+
["HTTP 503", () => Promise.resolve(new Response("unavailable", { status: 503 }))],
|
|
25
|
+
["network", () => Promise.reject(new Error("private network detail"))],
|
|
26
|
+
] as const)("reports %s to manual send callers", async (_name, response) => {
|
|
27
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(response);
|
|
28
|
+
await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reports missing configuration to manual send callers", async () => {
|
|
32
|
+
settingMock.mockReturnValue(undefined);
|
|
33
|
+
await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
|
|
34
|
+
});
|
|
35
|
+
|
|
22
36
|
beforeEach(() => {
|
|
23
37
|
settingMock.mockImplementation((key: string) =>
|
|
24
38
|
key === "slack.botToken" ? "xoxb-test" : key === "slack.channelId" ? "C123" : undefined,
|
|
@@ -15,6 +15,8 @@ const logger = getLogger(["qgrid", "slack"]);
|
|
|
15
15
|
const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
|
|
16
16
|
const SLACK_TIMEOUT_MS = 5_000;
|
|
17
17
|
|
|
18
|
+
export class SlackNotificationError extends Error {}
|
|
19
|
+
|
|
18
20
|
/** 상태를 색으로 먼저 읽히게 하는 attachment 색상 바. */
|
|
19
21
|
export const SLACK_COLOR = {
|
|
20
22
|
good: "#2eb886",
|
|
@@ -34,6 +36,8 @@ export type SlackNotification = {
|
|
|
34
36
|
* 커지는 사건에만 쓴다 — 남용하면 조용 시간 자체가 무의미해진다.
|
|
35
37
|
*/
|
|
36
38
|
urgent?: boolean;
|
|
39
|
+
/** Manual sends must report delivery failures; background notifications remain fail-open. */
|
|
40
|
+
throwOnFailure?: boolean;
|
|
37
41
|
/** 조용 시간 판정 기준 시각. 테스트에서 시계를 고정할 때만 넘긴다. */
|
|
38
42
|
now?: Date;
|
|
39
43
|
};
|
|
@@ -47,6 +51,9 @@ export async function notifySlack(
|
|
|
47
51
|
const { title, subject, context, color, urgent, now } = notification;
|
|
48
52
|
if (!botToken || !channel) {
|
|
49
53
|
logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
|
|
54
|
+
if (notification.throwOnFailure) {
|
|
55
|
+
throw new SlackNotificationError("Slack 봇 토큰과 채널 ID를 설정해 주세요");
|
|
56
|
+
}
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -99,10 +106,19 @@ export async function notifySlack(
|
|
|
99
106
|
// bot 미초대(not_in_channel)·잘못된 채널 등 가장 흔한 오설정은 HTTP 200 + ok:false 로 온다.
|
|
100
107
|
// status 만 보면 조용히 성공 처리돼 알림이 통째로 사라진다.
|
|
101
108
|
const body = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
|
|
102
|
-
if (!body?.ok) {
|
|
103
|
-
|
|
109
|
+
if (!res.ok || body?.ok !== true) {
|
|
110
|
+
const code =
|
|
111
|
+
typeof body?.error === "string" && /^[a-z_]{1,80}$/.test(body.error)
|
|
112
|
+
? body.error
|
|
113
|
+
: `HTTP ${res.status}`;
|
|
114
|
+
throw new SlackNotificationError(`Slack 알림 발송 실패: ${code}`);
|
|
104
115
|
}
|
|
105
116
|
} catch (e) {
|
|
106
|
-
|
|
117
|
+
const error =
|
|
118
|
+
e instanceof SlackNotificationError
|
|
119
|
+
? e
|
|
120
|
+
: new SlackNotificationError("Slack 알림 발송 실패: 네트워크 오류 또는 응답 시간 초과");
|
|
121
|
+
logger.warn(error.message);
|
|
122
|
+
if (notification.throwOnFailure) throw error;
|
|
107
123
|
}
|
|
108
124
|
}
|