@cartanova/qgrid-cli 2.6.1 → 2.6.4
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/README.ko.md +2 -0
- package/README.md +2 -0
- package/bundle/dist/application/qgrid/oauth.js +21 -3
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
- package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
- package/bundle/dist/application/qgrid/token-death.js +77 -0
- package/bundle/dist/application/queries.generated.js +3 -2
- package/bundle/dist/application/request-log/request-log.model.js +105 -31
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +60 -5
- package/bundle/dist/application/sonamu.generated.sso.js +18 -2
- package/bundle/dist/application/token/token.model.js +53 -2
- package/bundle/dist/i18n/en.js +3 -1
- package/bundle/dist/i18n/ko.js +3 -1
- package/bundle/dist/i18n/sd.generated.js +18 -1
- package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
- package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/server-error-handler.js +5 -3
- package/bundle/dist/sonamu.config.js +5 -1
- package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
- package/bundle/dist/utils/slack-notify.js +65 -0
- package/bundle/src/application/qgrid/oauth.ts +20 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
- package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
- package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
- package/bundle/src/application/qgrid/token-death.test.ts +150 -0
- package/bundle/src/application/qgrid/token-death.ts +96 -0
- package/bundle/src/application/queries.generated.ts +5 -2
- package/bundle/src/application/request-log/request-log.entity.json +46 -7
- package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
- package/bundle/src/application/request-log/request-log.model.ts +136 -50
- package/bundle/src/application/sonamu.generated.http +19 -1
- package/bundle/src/application/sonamu.generated.sso.ts +16 -0
- package/bundle/src/application/sonamu.generated.ts +58 -3
- package/bundle/src/application/token/token.model.test.ts +49 -0
- package/bundle/src/application/token/token.model.ts +75 -0
- package/bundle/src/i18n/en.ts +3 -0
- package/bundle/src/i18n/ko.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +17 -0
- package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
- package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/server-error-handler.test.ts +58 -0
- package/bundle/src/server-error-handler.ts +16 -5
- package/bundle/src/sonamu.config.ts +6 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
- package/bundle/src/utils/slack-notify.test.ts +97 -0
- package/bundle/src/utils/slack-notify.ts +74 -0
- package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
- package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
- package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
- package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
- package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
- package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
- package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
- package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
- package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
- package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
- package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
- package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +70 -29
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
- package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
- package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
- package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
- package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
|
@@ -117,6 +117,25 @@ export async function exchangeCodeForTokens(
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* refresh 실패를 status 와 함께 전달한다. 호출부가 문자열 메시지를 파싱하지 않고
|
|
122
|
+
* 영구 실패(400/401)를 판별할 수 있어야 하기 때문이다.
|
|
123
|
+
*/
|
|
124
|
+
export class RefreshFailedError extends Error {
|
|
125
|
+
override readonly name = "RefreshFailedError";
|
|
126
|
+
constructor(
|
|
127
|
+
readonly status: number,
|
|
128
|
+
readonly body: string,
|
|
129
|
+
) {
|
|
130
|
+
super(`Token refresh failed (${status}): ${body}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 재로그인 외 복구 불가한 인증 실패인지. 일시 오류(5xx·네트워크)는 제외된다. */
|
|
134
|
+
get isAuthDead(): boolean {
|
|
135
|
+
return this.status === 400 || this.status === 401;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
|
|
121
140
|
const res = await fetch(TOKEN_URL, {
|
|
122
141
|
method: "POST",
|
|
@@ -130,7 +149,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTok
|
|
|
130
149
|
});
|
|
131
150
|
if (!res.ok) {
|
|
132
151
|
const text = await res.text();
|
|
133
|
-
throw new
|
|
152
|
+
throw new RefreshFailedError(res.status, text);
|
|
134
153
|
}
|
|
135
154
|
const data = (await res.json()) as {
|
|
136
155
|
access_token: string;
|
|
@@ -37,6 +37,18 @@ vi.mock("../token/token.model", () => ({
|
|
|
37
37
|
save: tokenSaveMock,
|
|
38
38
|
del: tokenDelMock,
|
|
39
39
|
findByAccountIdentifier: findByAccountMock,
|
|
40
|
+
// 실제 구현과 같은 순서(dedup → save)로 동작시켜 저장 payload 검증이 유지되게 한다.
|
|
41
|
+
replaceByAccount: async (
|
|
42
|
+
_provider: string,
|
|
43
|
+
accountId: string | undefined,
|
|
44
|
+
saveParams: unknown,
|
|
45
|
+
) => {
|
|
46
|
+
if (accountId) {
|
|
47
|
+
const olds = (await findByAccountMock()) as unknown as { id: number; active: boolean }[];
|
|
48
|
+
if (olds.length > 0) await tokenDelMock();
|
|
49
|
+
}
|
|
50
|
+
await (tokenSaveMock as unknown as (rows: unknown[]) => Promise<number[]>)([saveParams]);
|
|
51
|
+
},
|
|
40
52
|
},
|
|
41
53
|
}));
|
|
42
54
|
|
|
@@ -106,7 +118,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
|
|
|
106
118
|
|
|
107
119
|
it("exchanges a pasted code#state against the stored pending state and saves the token", async () => {
|
|
108
120
|
mockHttpContext({ origin: "https://qgrid.example.com" });
|
|
109
|
-
const { authUrl } = await QgridFrame.oauthStart("
|
|
121
|
+
const { authUrl } = await QgridFrame.oauthStart("test-token-2");
|
|
110
122
|
const state = authUrlParam(authUrl, "state")!;
|
|
111
123
|
|
|
112
124
|
exchangeMock.mockResolvedValueOnce({
|
|
@@ -119,7 +131,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
|
|
|
119
131
|
|
|
120
132
|
await expect(QgridFrame.oauthComplete(` the-code#${state} `)).resolves.toEqual({
|
|
121
133
|
added: true,
|
|
122
|
-
name: "
|
|
134
|
+
name: "test-token-2",
|
|
123
135
|
});
|
|
124
136
|
|
|
125
137
|
expect(exchangeMock).toHaveBeenCalledWith(
|
|
@@ -131,7 +143,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
|
|
|
131
143
|
expect(tokenSaveMock).toHaveBeenCalledWith([
|
|
132
144
|
expect.objectContaining({
|
|
133
145
|
provider: "anthropic",
|
|
134
|
-
name: "
|
|
146
|
+
name: "test-token-2",
|
|
135
147
|
credentials: expect.objectContaining({ accessToken: "at", accountUuid: "acc-1" }),
|
|
136
148
|
}),
|
|
137
149
|
]);
|
|
@@ -56,7 +56,7 @@ describe("QgridDispatcherClass", () => {
|
|
|
56
56
|
|
|
57
57
|
await expect(
|
|
58
58
|
dispatcher.query({ prompt: "hi", model: "anthropic/claude-sonnet-4-6" }),
|
|
59
|
-
).rejects.
|
|
59
|
+
).rejects.toMatchObject({ statusCode: 503 });
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("AnthropicDispatcher 미초기화 시 queryStream 은 delta 없는 query 폴백 없이 실패한다", async () => {
|
|
@@ -71,7 +71,35 @@ describe("QgridDispatcherClass", () => {
|
|
|
71
71
|
onError: vi.fn(),
|
|
72
72
|
},
|
|
73
73
|
),
|
|
74
|
-
).rejects.
|
|
74
|
+
).rejects.toMatchObject({ statusCode: 503 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 기동 중과 초기화 실패는 클라이언트의 재시도 판단이 갈리는 지점이라 상태코드로 구분한다.
|
|
78
|
+
it("기동 중 미준비는 503 으로 재시도 가능함을 알린다", async () => {
|
|
79
|
+
const dispatcher = new QgridDispatcherClass();
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
dispatcher.query({ prompt: "hi", model: "openai/gpt-5.4" }),
|
|
83
|
+
).rejects.toMatchObject({ statusCode: 503 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("초기화가 실패로 끝났으면 500 으로 재시도가 무의미함을 알린다", async () => {
|
|
87
|
+
const dispatcher = new QgridDispatcherClass();
|
|
88
|
+
dispatcher.startupState.openai = "failed";
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
dispatcher.query({ prompt: "hi", model: "openai/gpt-5.4" }),
|
|
92
|
+
).rejects.toMatchObject({ statusCode: 500 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("provider 별로 기동 상태를 따로 본다", async () => {
|
|
96
|
+
const dispatcher = new QgridDispatcherClass();
|
|
97
|
+
dispatcher.startupState.openai = "failed";
|
|
98
|
+
|
|
99
|
+
// openai 가 실패해도 anthropic 은 여전히 기동 중(재시도 가능)이다.
|
|
100
|
+
await expect(
|
|
101
|
+
dispatcher.query({ prompt: "hi", model: "anthropic/claude-sonnet-4-6" }),
|
|
102
|
+
).rejects.toMatchObject({ statusCode: 503 });
|
|
75
103
|
});
|
|
76
104
|
|
|
77
105
|
it("provider prefix 없는 모델은 AnthropicDispatcher 로 암묵 라우팅하지 않는다", async () => {
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
|
|
5
5
|
* - OpenAI/Anthropic 요청은 각 provider dispatcher 로만 실행
|
|
6
6
|
* - QuotaError 는 그대로 상위 전파
|
|
7
|
+
* - dispatcher 미준비는 기동 중(503)/초기화 실패(500)로 구분해 던진다
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
import { InternalServerErrorException, ServiceUnavailableException } from "sonamu";
|
|
11
|
+
|
|
9
12
|
import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
|
|
13
|
+
import { SD } from "../../i18n/sd.generated";
|
|
10
14
|
import { type AnthropicDispatcher } from "../../utils/providers/anthropic/anthropic-dispatcher";
|
|
11
15
|
import { getAccessToken } from "../../utils/providers/common/credentials";
|
|
12
16
|
import { calculateCostUsd } from "../../utils/providers/common/model-cost";
|
|
@@ -22,8 +26,14 @@ import { strictify } from "../../utils/providers/common/strictifier";
|
|
|
22
26
|
import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
|
|
23
27
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
24
28
|
import { decideConvRouting, issueConvContext } from "./conv-routing";
|
|
25
|
-
import {
|
|
26
|
-
|
|
29
|
+
import {
|
|
30
|
+
maskToken,
|
|
31
|
+
ProcessError,
|
|
32
|
+
type ProviderStartupState,
|
|
33
|
+
type QueryInput,
|
|
34
|
+
type QueryOutput,
|
|
35
|
+
type TokenStats,
|
|
36
|
+
} from "./qgrid.types";
|
|
27
37
|
import { type TokenSubscriber } from "./token-subscriber";
|
|
28
38
|
import { applyToolCallEmulation } from "./tool-emulation";
|
|
29
39
|
import { buildToolCallSchema } from "./tool-emulation-schema";
|
|
@@ -39,6 +49,34 @@ export class QgridDispatcherClass {
|
|
|
39
49
|
openaiDispatcher: OpenAIDispatcher | null = null;
|
|
40
50
|
anthropicDispatcher: AnthropicDispatcher | null = null;
|
|
41
51
|
|
|
52
|
+
/**
|
|
53
|
+
* provider 별 기동 상태. dispatcher 가 없는 이유를 구분하기 위해 필요하다 —
|
|
54
|
+
* HTTP 리스닝은 dispatcher 준비보다 먼저 열리므로(sonamu.config onStart),
|
|
55
|
+
* 그 사이 들어온 요청은 "잠시 후 되는" 상태와 "재시도해도 안 되는" 상태가 다르다.
|
|
56
|
+
*
|
|
57
|
+
* - `starting`: 워커 spawn 중. dev0 기준 25 워커 × 500ms 간격이라 1~2분 걸린다 → 재시도 가능
|
|
58
|
+
* - `ready`: 정상
|
|
59
|
+
* - `failed`: start() 가 예외로 끝남 → 재시도해도 같은 결과
|
|
60
|
+
*/
|
|
61
|
+
startupState: Record<"openai" | "anthropic", ProviderStartupState> = {
|
|
62
|
+
openai: "starting",
|
|
63
|
+
anthropic: "starting",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* dispatcher 가 준비되지 않았을 때 던질 예외를 만든다.
|
|
68
|
+
*
|
|
69
|
+
* 기동 중이면 503(+Retry-After) 으로 재시도 가능함을 알리고, 초기화가 실패로 끝났으면
|
|
70
|
+
* 500 으로 재시도가 무의미함을 알린다. 이전에는 둘 다 QuotaError 였는데, 쿼터 소진이
|
|
71
|
+
* 아닌 상태를 그렇게 표현하면 호출자가 토큰 문제로 오해하고 재시도 판단도 못 한다.
|
|
72
|
+
*/
|
|
73
|
+
private notReadyError(provider: "openai" | "anthropic"): Error {
|
|
74
|
+
const label = provider === "openai" ? "OpenAI" : "Anthropic";
|
|
75
|
+
return this.startupState[provider] === "failed"
|
|
76
|
+
? new InternalServerErrorException(SD("qgrid.dispatcherFailed")(label))
|
|
77
|
+
: new ServiceUnavailableException(SD("qgrid.dispatcherStarting")(label));
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
countOf(name: string): number {
|
|
43
81
|
return this.requestCounts.get(name) ?? 0;
|
|
44
82
|
}
|
|
@@ -73,7 +111,7 @@ export class QgridDispatcherClass {
|
|
|
73
111
|
|
|
74
112
|
// provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
|
|
75
113
|
if (route.provider === "openai") {
|
|
76
|
-
if (!this.openaiDispatcher) throw
|
|
114
|
+
if (!this.openaiDispatcher) throw this.notReadyError("openai");
|
|
77
115
|
|
|
78
116
|
const decision = decideConvRouting(input);
|
|
79
117
|
const result = await this.openaiDispatcher.generate({
|
|
@@ -105,7 +143,7 @@ export class QgridDispatcherClass {
|
|
|
105
143
|
answerKind,
|
|
106
144
|
});
|
|
107
145
|
} else if (route.provider === "anthropic") {
|
|
108
|
-
if (!this.anthropicDispatcher) throw
|
|
146
|
+
if (!this.anthropicDispatcher) throw this.notReadyError("anthropic");
|
|
109
147
|
|
|
110
148
|
const decision = decideConvRouting(input);
|
|
111
149
|
const result = await this.anthropicDispatcher.generate({
|
|
@@ -142,7 +180,7 @@ export class QgridDispatcherClass {
|
|
|
142
180
|
const answerKind = input.jsonSchema ? ("json" as const) : ("text" as const);
|
|
143
181
|
|
|
144
182
|
if (route.provider === "openai") {
|
|
145
|
-
if (!this.openaiDispatcher) throw
|
|
183
|
+
if (!this.openaiDispatcher) throw this.notReadyError("openai");
|
|
146
184
|
|
|
147
185
|
const decision = decideConvRouting(input);
|
|
148
186
|
await this.openaiDispatcher.generateStream(
|
|
@@ -180,7 +218,7 @@ export class QgridDispatcherClass {
|
|
|
180
218
|
);
|
|
181
219
|
return;
|
|
182
220
|
} else if (route.provider === "anthropic") {
|
|
183
|
-
if (!this.anthropicDispatcher) throw
|
|
221
|
+
if (!this.anthropicDispatcher) throw this.notReadyError("anthropic");
|
|
184
222
|
|
|
185
223
|
const decision = decideConvRouting(input);
|
|
186
224
|
await this.anthropicDispatcher.generateStream(
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../../utils/providers/common/credentials";
|
|
14
14
|
import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
|
|
15
15
|
import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
|
|
16
|
+
import { type RequestLogListParams } from "../request-log/request-log.types";
|
|
16
17
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
17
18
|
import { TokenModel, type TokenUpdateFields } from "../token/token.model";
|
|
18
19
|
import { TokenCredentials } from "../token/token.types";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
fetchUsage,
|
|
25
26
|
generatePKCE,
|
|
26
27
|
refreshAccessToken,
|
|
28
|
+
RefreshFailedError,
|
|
27
29
|
} from "./oauth";
|
|
28
30
|
import {
|
|
29
31
|
afterQuery,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
type TokenStats,
|
|
46
48
|
type UsageResponse,
|
|
47
49
|
} from "./qgrid.types";
|
|
50
|
+
import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
|
|
48
51
|
import { ToolSchemaCompositionError } from "./tool-emulation-schema";
|
|
49
52
|
|
|
50
53
|
const pendingStreams = new Map<string, QueryInput>();
|
|
@@ -197,6 +200,9 @@ function createHttpDisconnectHandle(): {
|
|
|
197
200
|
const logger = getLogger(["qgrid"]);
|
|
198
201
|
const oauthLogger = getLogger(["qgrid", "oauth"]);
|
|
199
202
|
|
|
203
|
+
// per-token refresh dedup — 회전 겹침으로 인한 사망 오판을 막는다.
|
|
204
|
+
const inflightAnthropicRefresh = new Map<number, Promise<string>>();
|
|
205
|
+
|
|
200
206
|
class QgridFrameClass extends BaseFrameClass {
|
|
201
207
|
constructor() {
|
|
202
208
|
super("Qgrid");
|
|
@@ -464,9 +470,11 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
464
470
|
return QgridDispatcher.getStats();
|
|
465
471
|
}
|
|
466
472
|
|
|
473
|
+
// 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
|
|
474
|
+
// 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
|
|
467
475
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
468
|
-
async totalCost(
|
|
469
|
-
return { usd: await RequestLogModel.totalCost(
|
|
476
|
+
async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
|
|
477
|
+
return { usd: await RequestLogModel.totalCost(params) };
|
|
470
478
|
}
|
|
471
479
|
|
|
472
480
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -474,6 +482,11 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
474
482
|
return { names: await RequestLogModel.distinctProjectNames() };
|
|
475
483
|
}
|
|
476
484
|
|
|
485
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
486
|
+
async modelNames(): Promise<{ names: string[] }> {
|
|
487
|
+
return { names: await RequestLogModel.distinctModelNames() };
|
|
488
|
+
}
|
|
489
|
+
|
|
477
490
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
478
491
|
async addToken(
|
|
479
492
|
provider: string,
|
|
@@ -601,29 +614,18 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
601
614
|
pending.redirectUri,
|
|
602
615
|
);
|
|
603
616
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
tokens.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await TokenModel.del(oldEntries.map((o) => o.id));
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
await TokenModel.save([
|
|
616
|
-
{
|
|
617
|
-
provider: "anthropic",
|
|
618
|
-
credentials: {
|
|
619
|
-
accessToken: tokens.accessToken,
|
|
620
|
-
refreshToken: tokens.refreshToken,
|
|
621
|
-
expiresAt: tokens.expiresAt ?? 0,
|
|
622
|
-
accountUuid: tokens.accountUuid ?? "",
|
|
623
|
-
},
|
|
624
|
-
name: pending.name,
|
|
617
|
+
await TokenModel.replaceByAccount("anthropic", tokens.accountUuid, {
|
|
618
|
+
provider: "anthropic",
|
|
619
|
+
credentials: {
|
|
620
|
+
accessToken: tokens.accessToken,
|
|
621
|
+
refreshToken: tokens.refreshToken,
|
|
622
|
+
expiresAt: tokens.expiresAt ?? 0,
|
|
623
|
+
accountUuid: tokens.accountUuid ?? "",
|
|
625
624
|
},
|
|
626
|
-
|
|
625
|
+
name: pending.name,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
notifyTokenAdded(pending.name, "anthropic");
|
|
627
629
|
}
|
|
628
630
|
|
|
629
631
|
async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
|
|
@@ -651,30 +653,19 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
651
653
|
QgridDispatcher.openaiDispatcher
|
|
652
654
|
.completeBrowserLogin()
|
|
653
655
|
.then(async (creds) => {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
creds.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
await TokenModel.save([
|
|
665
|
-
{
|
|
666
|
-
provider: "openai",
|
|
667
|
-
credentials: {
|
|
668
|
-
accessToken: creds.accessToken,
|
|
669
|
-
refreshToken: creds.refreshToken,
|
|
670
|
-
idToken: creds.idToken,
|
|
671
|
-
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
672
|
-
accountId: creds.accountId,
|
|
673
|
-
},
|
|
674
|
-
name,
|
|
656
|
+
await TokenModel.replaceByAccount("openai", creds.accountId, {
|
|
657
|
+
provider: "openai",
|
|
658
|
+
credentials: {
|
|
659
|
+
accessToken: creds.accessToken,
|
|
660
|
+
refreshToken: creds.refreshToken,
|
|
661
|
+
idToken: creds.idToken,
|
|
662
|
+
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
663
|
+
accountId: creds.accountId,
|
|
675
664
|
},
|
|
676
|
-
|
|
665
|
+
name,
|
|
666
|
+
});
|
|
677
667
|
logger.info(`OpenAI token saved for ${name}`);
|
|
668
|
+
notifyTokenAdded(name, "openai");
|
|
678
669
|
})
|
|
679
670
|
.catch((e) => {
|
|
680
671
|
logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
|
|
@@ -754,11 +745,36 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
754
745
|
return convertAnthropicUsage(raw);
|
|
755
746
|
}
|
|
756
747
|
|
|
748
|
+
// 동시 refresh 가 회전을 겹치면 뒤늦은 호출이 이미 폐기된 refresh token 을 쓰게 되어
|
|
749
|
+
// 멀쩡한 토큰이 사망으로 오판된다. openai-refresh.ts 와 같은 per-token dedup 을 둔다.
|
|
757
750
|
async refreshToken(token: TokenSubsetA): Promise<string> {
|
|
751
|
+
const inflight = inflightAnthropicRefresh.get(token.id);
|
|
752
|
+
if (inflight) return inflight;
|
|
753
|
+
|
|
754
|
+
const promise = this.doRefreshToken(token);
|
|
755
|
+
inflightAnthropicRefresh.set(token.id, promise);
|
|
756
|
+
try {
|
|
757
|
+
return await promise;
|
|
758
|
+
} finally {
|
|
759
|
+
inflightAnthropicRefresh.delete(token.id);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private async doRefreshToken(token: TokenSubsetA): Promise<string> {
|
|
758
764
|
const creds = token.credentials;
|
|
759
765
|
const rt = getRefreshToken(creds);
|
|
760
766
|
if (!rt) throw new Error("No refresh token");
|
|
761
|
-
|
|
767
|
+
|
|
768
|
+
let refreshed;
|
|
769
|
+
try {
|
|
770
|
+
refreshed = await refreshAccessToken(rt);
|
|
771
|
+
} catch (e) {
|
|
772
|
+
if (e instanceof RefreshFailedError && e.isAuthDead) {
|
|
773
|
+
await deactivateAuthDeadToken(token, rt, `anthropic:${e.status}`);
|
|
774
|
+
}
|
|
775
|
+
throw e;
|
|
776
|
+
}
|
|
777
|
+
|
|
762
778
|
const updated = TokenCredentials.parse({
|
|
763
779
|
...creds,
|
|
764
780
|
accessToken: refreshed.accessToken,
|
|
@@ -778,10 +794,14 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
778
794
|
|
|
779
795
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
780
796
|
async health(): Promise<HealthResponse> {
|
|
797
|
+
const providers = { ...QgridDispatcher.startupState };
|
|
781
798
|
return {
|
|
782
799
|
status: "ok",
|
|
783
800
|
activeTokens: QgridDispatcher.tokens.size,
|
|
784
801
|
subscriber: QgridDispatcher.subscriber?.status() ?? null,
|
|
802
|
+
// provider 별 라우팅이라 하나만 준비돼도 그 provider 요청은 처리된다.
|
|
803
|
+
ready: Object.values(providers).includes("ready"),
|
|
804
|
+
providers,
|
|
785
805
|
};
|
|
786
806
|
}
|
|
787
807
|
}
|
|
@@ -306,10 +306,20 @@ export const SubscriberStatus = z.object({
|
|
|
306
306
|
});
|
|
307
307
|
export type SubscriberStatus = z.infer<typeof SubscriberStatus>;
|
|
308
308
|
|
|
309
|
+
export const ProviderStartupState = z.enum(["starting", "ready", "failed"]);
|
|
310
|
+
export type ProviderStartupState = z.infer<typeof ProviderStartupState>;
|
|
311
|
+
|
|
309
312
|
export const HealthResponse = z.object({
|
|
310
313
|
status: z.string(),
|
|
311
314
|
activeTokens: z.number(),
|
|
312
315
|
subscriber: SubscriberStatus.nullable(),
|
|
316
|
+
// HTTP 리스닝은 dispatcher 기동보다 먼저 열린다. 200 응답만으로는 요청을 처리할 수
|
|
317
|
+
// 있는지 알 수 없어 provider 별 준비 상태를 함께 노출한다.
|
|
318
|
+
ready: z.boolean(),
|
|
319
|
+
providers: z.object({
|
|
320
|
+
openai: ProviderStartupState,
|
|
321
|
+
anthropic: ProviderStartupState,
|
|
322
|
+
}),
|
|
313
323
|
});
|
|
314
324
|
export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
315
325
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SLACK_COLOR } from "../../utils/slack-notify";
|
|
4
|
+
import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
|
|
5
|
+
|
|
6
|
+
const { findOneMock, deactivateMock, notifySlackMock } = vi.hoisted(() => ({
|
|
7
|
+
findOneMock: vi.fn(),
|
|
8
|
+
deactivateMock: vi.fn(),
|
|
9
|
+
notifySlackMock: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("../token/token.model", () => ({
|
|
13
|
+
TokenModel: { findOne: findOneMock, deactivateIfActive: deactivateMock },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// SLACK_COLOR 는 상수라 가릴 이유가 없다 — 전송 함수만 스텁한다.
|
|
17
|
+
vi.mock("../../utils/slack-notify", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("../../utils/slack-notify")>()),
|
|
19
|
+
notifySlack: notifySlackMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const token = { id: 7, name: "anthropic/test-token", provider: "anthropic" };
|
|
23
|
+
|
|
24
|
+
function storedRefreshToken(refreshToken: string) {
|
|
25
|
+
return {
|
|
26
|
+
id: 7,
|
|
27
|
+
provider: "anthropic",
|
|
28
|
+
credentials: { accessToken: "at", refreshToken, expiresAt: 0, accountUuid: "acc-1" },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("deactivateAuthDeadToken", () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
findOneMock.mockReset();
|
|
35
|
+
deactivateMock.mockReset();
|
|
36
|
+
notifySlackMock.mockReset();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
|
|
40
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
|
|
41
|
+
deactivateMock.mockResolvedValue({ deactivated: true, keptAsLastActive: false });
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
deactivateAuthDeadToken(token, "rt-current", "anthropic:400"),
|
|
45
|
+
).resolves.toBe(true);
|
|
46
|
+
|
|
47
|
+
expect(deactivateMock).toHaveBeenCalledWith(7);
|
|
48
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
const notification = notifySlackMock.mock.calls[0]![0] as {
|
|
50
|
+
subject: string;
|
|
51
|
+
context: string;
|
|
52
|
+
color: string;
|
|
53
|
+
};
|
|
54
|
+
expect(notification.subject).toBe("anthropic/test-token");
|
|
55
|
+
expect(notification.context).toContain("anthropic:400");
|
|
56
|
+
expect(notification.color).toBe(SLACK_COLOR.bad);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("skips deactivation when the refresh token rotated since the failed attempt", async () => {
|
|
60
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-rotated"));
|
|
61
|
+
|
|
62
|
+
await expect(deactivateAuthDeadToken(token, "rt-stale", "anthropic:400")).resolves.toBe(false);
|
|
63
|
+
|
|
64
|
+
expect(deactivateMock).not.toHaveBeenCalled();
|
|
65
|
+
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not notify when another process won the deactivation race", async () => {
|
|
69
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
|
|
70
|
+
deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: false });
|
|
71
|
+
|
|
72
|
+
await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("alerts instead of deactivating when the dying token is the last active one", async () => {
|
|
80
|
+
// 알림 쿨다운이 모듈 수준 상태라 다른 테스트의 발송 기록에 가려질 수 있다.
|
|
81
|
+
vi.resetModules();
|
|
82
|
+
const { deactivateAuthDeadToken: freshDeactivate } = await import("./token-death");
|
|
83
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
|
|
84
|
+
deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: true });
|
|
85
|
+
|
|
86
|
+
await expect(freshDeactivate(token, "rt-current", "anthropic:400")).resolves.toBe(false);
|
|
87
|
+
|
|
88
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
89
|
+
const notification = notifySlackMock.mock.calls[0]![0] as { title: string; context: string };
|
|
90
|
+
expect(notification.title).toContain("마지막");
|
|
91
|
+
expect(notification.context).toContain("anthropic:400");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("suppresses repeat last-active alerts for the same provider within the cooldown", async () => {
|
|
95
|
+
// 이 경로는 토큰을 비활성화하지 않아 상태 변화가 없다 — 억제가 없으면 요청마다 알림이 간다.
|
|
96
|
+
// 쿨다운은 모듈 수준 상태라 앞 테스트의 발송 기록이 남는다. 새로 불러 격리한다.
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
const { deactivateAuthDeadToken: freshDeactivate } = await import("./token-death");
|
|
99
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
|
|
100
|
+
deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: true });
|
|
101
|
+
|
|
102
|
+
await freshDeactivate(token, "rt-current", "anthropic:400");
|
|
103
|
+
await freshDeactivate(token, "rt-current", "anthropic:400");
|
|
104
|
+
await freshDeactivate(token, "rt-current", "anthropic:401");
|
|
105
|
+
|
|
106
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does nothing when the token no longer exists", async () => {
|
|
110
|
+
findOneMock.mockResolvedValue(undefined);
|
|
111
|
+
|
|
112
|
+
await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(deactivateMock).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("never puts the raw provider error body in the notification", async () => {
|
|
120
|
+
findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
|
|
121
|
+
deactivateMock.mockResolvedValue({ deactivated: true, keptAsLastActive: false });
|
|
122
|
+
|
|
123
|
+
await deactivateAuthDeadToken(token, "rt-current", "anthropic:400");
|
|
124
|
+
|
|
125
|
+
// 필드가 늘어도 새는 곳이 생기지 않도록 페이로드 전체를 훑는다.
|
|
126
|
+
const payload = JSON.stringify(notifySlackMock.mock.calls[0]![0]);
|
|
127
|
+
expect(payload).not.toContain("sk-ant");
|
|
128
|
+
expect(payload).not.toContain("@");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("notifyTokenAdded", () => {
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
notifySlackMock.mockReset();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("sends one added notification naming the token and provider", () => {
|
|
138
|
+
notifyTokenAdded("anthropic/test-token", "anthropic");
|
|
139
|
+
|
|
140
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
141
|
+
const notification = notifySlackMock.mock.calls[0]![0] as {
|
|
142
|
+
subject: string;
|
|
143
|
+
context: string;
|
|
144
|
+
color: string;
|
|
145
|
+
};
|
|
146
|
+
expect(notification.subject).toBe("anthropic/test-token");
|
|
147
|
+
expect(notification.context).toContain("anthropic");
|
|
148
|
+
expect(notification.color).toBe(SLACK_COLOR.good);
|
|
149
|
+
});
|
|
150
|
+
});
|