@cartanova/qgrid-cli 2.7.9 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/qgrid/expired-token-reminder.js +9 -8
- package/bundle/dist/application/qgrid/oauth.js +5 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +8 -14
- package/bundle/dist/application/qgrid/qgrid.types.js +1 -1
- package/bundle/dist/application/qgrid/token-death.js +12 -31
- package/bundle/dist/application/qgrid/token-trigger-setup.js +2 -1
- package/bundle/dist/application/sonamu.generated.js +3 -1
- package/bundle/dist/application/sonamu.generated.sso.js +2 -1
- package/bundle/dist/application/token/token.model.js +66 -47
- package/bundle/dist/application/token/token.types.js +2 -1
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260901163614_alter_tokens_add1_alter1.js +15 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +13 -3
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +12 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +8 -5
- package/bundle/dist/utils/providers/common/model-cost.js +9 -11
- package/bundle/dist/utils/providers/openai/openai-refresh.js +6 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +8 -6
- package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -8
- package/bundle/src/application/qgrid/oauth.test.ts +28 -0
- package/bundle/src/application/qgrid/oauth.ts +5 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +22 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -1
- package/bundle/src/application/qgrid/token-death.test.ts +62 -58
- package/bundle/src/application/qgrid/token-death.ts +15 -39
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +1 -0
- package/bundle/src/application/sonamu.generated.http +0 -27
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +3 -0
- package/bundle/src/application/token/token.entity.json +7 -0
- package/bundle/src/application/token/token.model.test.ts +112 -37
- package/bundle/src/application/token/token.model.ts +76 -48
- package/bundle/src/application/token/token.types.ts +1 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260901163614_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +25 -1
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +23 -4
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +20 -7
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +28 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +18 -2
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +14 -10
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -19
- package/bundle/src/utils/providers/common/model-cost.ts +19 -16
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +16 -7
- package/bundle/src/utils/providers/openai/openai-refresh.ts +2 -2
- package/bundle/web-dist/client/assets/{index-D59Q_9Z4.js → index-DDSQ_6GL.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-BHVfI8ut.js → logs-D2OMhBlx.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-EkLxH1oN.js → monit-CYLYHrc-.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Kbvuclip.js → routes-DlG0sJHR.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-Da1Eq5_R.js → services.generated-C6jfEwfK.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-B4uTFP3J.js → settings-BeCBndh_.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BjIdSPTd.js → show-BdmzN1As.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-BbCSxbFu.js → tokens-KCfMOeTH.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-4OltfVAd.js → use-oauth-login-flow-2dSjIvBk.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CZ57EKA1.js → use-update-token-mutation-BIvO70-M.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-V1S8gqMS.js → lazyRouteComponent-C_k4R5n6.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-2Tkqcncq.js → logs-lBOU5eUP.js} +2 -2
- package/bundle/web-dist/server/assets/{monit-DrJ15xJL.js → monit-BW2X1D23.js} +2 -2
- package/bundle/web-dist/server/assets/{plus-DCai02Na.js → plus-DilmHifT.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-BFiG5Daf.js → react-dom-CHRRsKwb.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-Ct3I9263.js → routes-CxMHXfdH.js} +5 -5
- package/bundle/web-dist/server/assets/{services.generated-y7xVOTCo.js → services.generated-buX3cJzR.js} +1 -9
- package/bundle/web-dist/server/assets/{settings-JznSjwGQ.js → settings-CFoXQaAg.js} +3 -3
- package/bundle/web-dist/server/assets/{show-D8kuAFbc.js → show-CcZJO6WF.js} +2 -2
- package/bundle/web-dist/server/assets/{tokens-PR42jF1J.js → tokens-C32lycTe.js} +6 -5
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-C-33Z5HA.js → use-oauth-login-flow-DCjxIa80.js} +1 -1
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BNaRPVaX.js → use-update-token-mutation-CqK8i8Fa.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +46 -23
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +8 -3
- package/skills/qgrid/references/ai-sdk-provider-contract.md +8 -3
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +17 -9
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +5 -2
- package/skills/qgrid/references/docs-workflow.md +3 -3
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +6 -2
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +20 -13
- package/skills/qgrid/references/verification-and-debugging.md +19 -16
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { Sonamu } from "sonamu";
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
5
|
|
|
3
6
|
import { TokenModel } from "./token.model";
|
|
4
7
|
|
|
@@ -122,6 +125,7 @@ describe("TokenModel.replaceByAccount", () => {
|
|
|
122
125
|
id: 1,
|
|
123
126
|
created_at: new Date(),
|
|
124
127
|
active: true,
|
|
128
|
+
reauth_required: false,
|
|
125
129
|
ord: 0,
|
|
126
130
|
quota_threshold: 80,
|
|
127
131
|
weight: 1,
|
|
@@ -177,51 +181,122 @@ describe("TokenModel.findActiveByProviderAndName", () => {
|
|
|
177
181
|
});
|
|
178
182
|
});
|
|
179
183
|
|
|
180
|
-
describe("TokenModel.
|
|
184
|
+
describe("TokenModel.findReauthRequired", () => {
|
|
181
185
|
afterEach(() => {
|
|
182
186
|
vi.restoreAllMocks();
|
|
183
187
|
});
|
|
184
188
|
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
it("queries auth-dead tokens instead of every inactive token", async () => {
|
|
190
|
+
const where = vi.fn();
|
|
191
|
+
const orderBy = vi.fn();
|
|
192
|
+
const qb = { where, orderBy };
|
|
193
|
+
vi.spyOn(
|
|
194
|
+
TokenModel as unknown as { getSubsetQueries: (subset: "A") => { qb: typeof qb } },
|
|
195
|
+
"getSubsetQueries",
|
|
196
|
+
).mockReturnValue({ qb });
|
|
197
|
+
vi.spyOn(
|
|
198
|
+
TokenModel as unknown as {
|
|
199
|
+
executeSubsetQuery: (input: unknown) => Promise<{ rows: unknown[] }>;
|
|
200
|
+
},
|
|
201
|
+
"executeSubsetQuery",
|
|
202
|
+
).mockResolvedValue({ rows: [] });
|
|
193
203
|
|
|
194
|
-
|
|
195
|
-
const raw = mockKnex([{ rowCount: 1 }]);
|
|
204
|
+
await TokenModel.findReauthRequired("A");
|
|
196
205
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
keptAsLastActive: false,
|
|
200
|
-
});
|
|
201
|
-
expect(raw).toHaveBeenCalledTimes(1);
|
|
202
|
-
const sql = raw.mock.calls[0]![0] as string;
|
|
203
|
-
expect(sql).toContain("UPDATE tokens SET active = false");
|
|
204
|
-
expect(sql).toContain("count(*)");
|
|
206
|
+
expect(where).toHaveBeenCalledWith("tokens.reauth_required", true);
|
|
207
|
+
expect(where).not.toHaveBeenCalledWith("tokens.active", false);
|
|
205
208
|
});
|
|
209
|
+
});
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
|
|
211
|
-
deactivated: false,
|
|
212
|
-
keptAsLastActive: false,
|
|
213
|
-
});
|
|
214
|
-
expect(raw).toHaveBeenCalledTimes(2);
|
|
211
|
+
describe("TokenModel.markReauthRequired", () => {
|
|
212
|
+
beforeAll(async () => {
|
|
213
|
+
await Sonamu.initForTesting();
|
|
215
214
|
});
|
|
216
215
|
|
|
217
|
-
it("
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
216
|
+
it("serializes provider deaths, preserves one active token, and rejects stale credentials", async () => {
|
|
217
|
+
const suffix = randomUUID();
|
|
218
|
+
const tokens = [
|
|
219
|
+
{
|
|
220
|
+
...baseToken,
|
|
221
|
+
name: `auth-state-a-${suffix}`,
|
|
222
|
+
credentials: { ...baseToken.credentials, accountUuid: `account-a-${suffix}` },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
...baseToken,
|
|
226
|
+
name: `auth-state-b-${suffix}`,
|
|
227
|
+
credentials: { ...baseToken.credentials, accountUuid: `account-b-${suffix}` },
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
const ids = await TokenModel.save(tokens);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await expect(
|
|
234
|
+
TokenModel.markReauthRequired(ids[0]!, {
|
|
235
|
+
...tokens[0]!.credentials,
|
|
236
|
+
accessToken: "stale-access-token",
|
|
237
|
+
}),
|
|
238
|
+
).resolves.toEqual({
|
|
239
|
+
marked: false,
|
|
240
|
+
keptAsLastActive: false,
|
|
241
|
+
staleCredentials: true,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const results = await Promise.all([
|
|
245
|
+
TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
|
|
246
|
+
TokenModel.markReauthRequired(ids[1]!, tokens[1]!.credentials),
|
|
247
|
+
]);
|
|
248
|
+
expect(results.every((result) => result.marked)).toBe(true);
|
|
249
|
+
|
|
250
|
+
const { rows } = await TokenModel.findMany("A", { id: ids, num: 2 });
|
|
251
|
+
expect(rows.every((row) => row.reauth_required)).toBe(true);
|
|
252
|
+
expect(rows.filter((row) => row.active)).toHaveLength(1);
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
|
|
256
|
+
).resolves.toEqual({
|
|
257
|
+
marked: false,
|
|
258
|
+
keptAsLastActive: false,
|
|
259
|
+
staleCredentials: false,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const inactive = rows.find((row) => !row.active)!;
|
|
263
|
+
const refreshedCredentials = {
|
|
264
|
+
...inactive.credentials,
|
|
265
|
+
accessToken: "refreshed-access-token",
|
|
266
|
+
refreshToken: "refreshed-refresh-token",
|
|
267
|
+
};
|
|
268
|
+
await TokenModel.save([
|
|
269
|
+
{
|
|
270
|
+
id: inactive.id,
|
|
271
|
+
provider: inactive.provider,
|
|
272
|
+
credentials: refreshedCredentials,
|
|
273
|
+
name: inactive.name,
|
|
274
|
+
reauth_required: false,
|
|
275
|
+
},
|
|
276
|
+
]);
|
|
277
|
+
await expect(
|
|
278
|
+
TokenModel.markReauthRequired(inactive.id, inactive.credentials),
|
|
279
|
+
).resolves.toEqual({
|
|
280
|
+
marked: false,
|
|
281
|
+
keptAsLastActive: false,
|
|
282
|
+
staleCredentials: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await expect(TokenModel.toggleActive(inactive.id)).resolves.toEqual({
|
|
286
|
+
active: true,
|
|
287
|
+
reauthRequired: false,
|
|
288
|
+
});
|
|
289
|
+
await TokenModel.toggleActive(inactive.id);
|
|
290
|
+
await Promise.all([
|
|
291
|
+
TokenModel.toggleActive(inactive.id),
|
|
292
|
+
TokenModel.markReauthRequired(inactive.id, refreshedCredentials),
|
|
293
|
+
]);
|
|
294
|
+
await expect(TokenModel.findById("A", inactive.id)).resolves.toMatchObject({
|
|
295
|
+
active: false,
|
|
296
|
+
reauth_required: true,
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
await TokenModel.del(ids);
|
|
300
|
+
}
|
|
226
301
|
});
|
|
227
302
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
|
|
1
3
|
import { getLogger } from "@logtape/logtape";
|
|
2
4
|
import {
|
|
3
5
|
api,
|
|
@@ -12,12 +14,13 @@ import {
|
|
|
12
14
|
import { SD } from "../../i18n/sd.generated";
|
|
13
15
|
import { type TokenSubsetKey, type TokenSubsetMapping } from "../sonamu.generated";
|
|
14
16
|
import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso";
|
|
15
|
-
import { type TokenListParams, type TokenSaveParams } from "./token.types";
|
|
17
|
+
import { type TokenCredentials, type TokenListParams, type TokenSaveParams } from "./token.types";
|
|
16
18
|
|
|
17
19
|
const logger = getLogger(["qgrid", "token"]);
|
|
18
20
|
|
|
19
21
|
const DEFAULT_QUOTA_THRESHOLD = 80;
|
|
20
22
|
const DEFAULT_WEIGHT = 1;
|
|
23
|
+
const TOKEN_AUTH_DEATH_LOCK_CLASS_ID = 719;
|
|
21
24
|
|
|
22
25
|
export type TokenUpdateFields = {
|
|
23
26
|
name?: string;
|
|
@@ -167,13 +170,10 @@ class TokenModelClass extends BaseModelClass<
|
|
|
167
170
|
return result.rows[0];
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
* 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
|
|
173
|
-
*/
|
|
174
|
-
async findInactive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
|
|
173
|
+
/** 실제 인증 만료가 확인돼 재로그인이 필요한 토큰들. 수동 비활성화는 제외한다. */
|
|
174
|
+
async findReauthRequired<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
|
|
175
175
|
const { qb } = this.getSubsetQueries(subset);
|
|
176
|
-
qb.where("tokens.
|
|
176
|
+
qb.where("tokens.reauth_required", true);
|
|
177
177
|
qb.orderBy("tokens.name", "asc");
|
|
178
178
|
const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
|
|
179
179
|
const result = await this.executeSubsetQuery({
|
|
@@ -200,7 +200,6 @@ class TokenModelClass extends BaseModelClass<
|
|
|
200
200
|
return result.rows;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
204
203
|
async save(spa: TokenSaveParams[]): Promise<number[]> {
|
|
205
204
|
const wdb = this.getPuri("w");
|
|
206
205
|
spa.forEach((sp) => {
|
|
@@ -216,15 +215,26 @@ class TokenModelClass extends BaseModelClass<
|
|
|
216
215
|
return wdb.transaction((trx) => trx.table("tokens").where("id", id).update(fields));
|
|
217
216
|
}
|
|
218
217
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
218
|
+
async toggleActive(id: number): Promise<{ active: boolean; reauthRequired: boolean } | null> {
|
|
219
|
+
const wdb = this.getPuri("w");
|
|
220
|
+
return wdb.transaction(async (trx) => {
|
|
221
|
+
const token = await trx
|
|
222
|
+
.from("tokens")
|
|
223
|
+
.select({ active: "active", reauth_required: "reauth_required" })
|
|
224
|
+
.where("id", id)
|
|
225
|
+
.forUpdate()
|
|
226
|
+
.first();
|
|
227
|
+
if (!token) return null;
|
|
228
|
+
if (!token.active && token.reauth_required) {
|
|
229
|
+
return { active: false, reauthRequired: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const active = !token.active;
|
|
233
|
+
await trx.from("tokens").where("id", id).update({ active });
|
|
234
|
+
return { active, reauthRequired: false };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
228
238
|
/**
|
|
229
239
|
* 같은 계정의 기존 토큰을 지우고 새로 저장한다(로그인·재로그인 공통 경로).
|
|
230
240
|
*
|
|
@@ -257,43 +267,61 @@ class TokenModelClass extends BaseModelClass<
|
|
|
257
267
|
}
|
|
258
268
|
|
|
259
269
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
270
|
+
* 인증 사망을 한 번만 기록하고, 다른 활성 토큰이 있으면 라우팅에서도 제외한다.
|
|
271
|
+
* 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
|
|
272
|
+
* 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
|
|
263
273
|
*/
|
|
264
|
-
async
|
|
274
|
+
async markReauthRequired(
|
|
265
275
|
id: number,
|
|
266
|
-
|
|
276
|
+
expectedCredentials: TokenCredentials,
|
|
277
|
+
): Promise<{ marked: boolean; keptAsLastActive: boolean; staleCredentials: boolean }> {
|
|
267
278
|
const wdb = this.getPuri("w");
|
|
279
|
+
return wdb.transaction(async (trx) => {
|
|
280
|
+
const token = await trx
|
|
281
|
+
.from("tokens")
|
|
282
|
+
.select({
|
|
283
|
+
provider: "provider",
|
|
284
|
+
credentials: "credentials",
|
|
285
|
+
reauth_required: "reauth_required",
|
|
286
|
+
})
|
|
287
|
+
.where("id", id)
|
|
288
|
+
.forUpdate()
|
|
289
|
+
.first();
|
|
290
|
+
if (!token || token.reauth_required) {
|
|
291
|
+
return { marked: false, keptAsLastActive: false, staleCredentials: false };
|
|
292
|
+
}
|
|
293
|
+
if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
|
|
294
|
+
return { marked: false, keptAsLastActive: false, staleCredentials: true };
|
|
295
|
+
}
|
|
268
296
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
await wdb.knex.raw<{ rows: { provider: string }[] }>(
|
|
285
|
-
"SELECT provider FROM tokens WHERE id = ? AND active = true",
|
|
297
|
+
await trx.knex.raw("SELECT pg_advisory_xact_lock(?, hashtext(?))", [
|
|
298
|
+
TOKEN_AUTH_DEATH_LOCK_CLASS_ID,
|
|
299
|
+
token.provider,
|
|
300
|
+
]);
|
|
301
|
+
const updated = await trx.knex.raw<{ rows: { active: boolean }[] }>(
|
|
302
|
+
`UPDATE tokens
|
|
303
|
+
SET reauth_required = true,
|
|
304
|
+
active = CASE
|
|
305
|
+
WHEN active AND (SELECT count(*) FROM tokens peer
|
|
306
|
+
WHERE peer.provider = tokens.provider AND peer.active) > 1
|
|
307
|
+
THEN false
|
|
308
|
+
ELSE active
|
|
309
|
+
END
|
|
310
|
+
WHERE id = ? AND reauth_required = false
|
|
311
|
+
RETURNING active`,
|
|
286
312
|
[id],
|
|
287
|
-
)
|
|
288
|
-
).rows[0];
|
|
289
|
-
if (survivor) {
|
|
290
|
-
logger.error(
|
|
291
|
-
`refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
|
|
292
|
-
`systemic refresh failure suspected, keeping the pool non-empty`,
|
|
293
313
|
);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
314
|
+
const marked = updated.rows[0];
|
|
315
|
+
if (!marked) return { marked: false, keptAsLastActive: false, staleCredentials: false };
|
|
316
|
+
|
|
317
|
+
if (marked.active) {
|
|
318
|
+
logger.error(
|
|
319
|
+
`refusing to auto-deactivate token ${id}: last active provider token — ` +
|
|
320
|
+
`systemic refresh failure suspected, keeping the pool non-empty`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return { marked: true, keptAsLastActive: marked.active, staleCredentials: false };
|
|
324
|
+
});
|
|
297
325
|
}
|
|
298
326
|
|
|
299
327
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
@@ -317,6 +317,7 @@ const entityLabels = {
|
|
|
317
317
|
"entity.Token.credentials": "인증 정보 (JSONB)",
|
|
318
318
|
"entity.Token.name": "토큰 이름",
|
|
319
319
|
"entity.Token.active": "활성 여부",
|
|
320
|
+
"entity.Token.reauth_required": "인증 만료로 재로그인이 필요한지 여부",
|
|
320
321
|
"entity.Token.ord": "정렬 순서",
|
|
321
322
|
"entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
|
|
322
323
|
"entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
5
|
+
// add
|
|
6
|
+
table.boolean("reauth_required").notNullable().defaultTo(knex.raw("false"));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function down(knex: Knex): Promise<void> {
|
|
11
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
12
|
+
// rollback - add
|
|
13
|
+
table.dropColumns("reauth_required");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
assertSupportedOneMillionSuffix,
|
|
6
6
|
canonicalAnthropicModel,
|
|
7
7
|
hasOneMillionSuffix,
|
|
8
|
+
isFableFamilyModel,
|
|
8
9
|
needsCli1mSuffix,
|
|
9
10
|
supports1MContext,
|
|
10
11
|
usesAdaptiveThinking,
|
|
@@ -18,6 +19,7 @@ describe("canonicalAnthropicModel", () => {
|
|
|
18
19
|
it("provider prefix 를 제거한다", () => {
|
|
19
20
|
expect(canonicalAnthropicModel("anthropic/claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
20
21
|
expect(canonicalAnthropicModel("anthropic/claude-fable-5")).toBe("claude-fable-5");
|
|
22
|
+
expect(canonicalAnthropicModel("anthropic/claude-fable-5-1")).toBe("claude-fable-5-1");
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
it("[1m] suffix 를 base canonical 에서 제거한다", () => {
|
|
@@ -28,6 +30,7 @@ describe("canonicalAnthropicModel", () => {
|
|
|
28
30
|
|
|
29
31
|
describe("Anthropic 1M context policy", () => {
|
|
30
32
|
it("지원 모델은 실측 확인된 exact set 만 true", () => {
|
|
33
|
+
expect(supports1MContext("claude-fable-5-1")).toBe(true);
|
|
31
34
|
expect(supports1MContext("claude-fable-5")).toBe(true);
|
|
32
35
|
expect(supports1MContext("claude-opus-5")).toBe(true);
|
|
33
36
|
expect(supports1MContext("claude-sonnet-5")).toBe(true);
|
|
@@ -42,6 +45,7 @@ describe("Anthropic 1M context policy", () => {
|
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
it("CLI suffix 필요 모델과 기본 1M 모델을 분리한다", () => {
|
|
48
|
+
expect(needsCli1mSuffix("claude-fable-5-1")).toBe(false);
|
|
45
49
|
expect(needsCli1mSuffix("claude-fable-5")).toBe(false);
|
|
46
50
|
expect(needsCli1mSuffix("claude-opus-5")).toBe(false);
|
|
47
51
|
expect(needsCli1mSuffix("claude-sonnet-5")).toBe(false);
|
|
@@ -65,10 +69,18 @@ describe("Anthropic 1M context policy", () => {
|
|
|
65
69
|
);
|
|
66
70
|
expect(() => assertSupportedOneMillionSuffix("anthropic/claude-sonnet-4-6[1m]")).not.toThrow();
|
|
67
71
|
});
|
|
72
|
+
|
|
73
|
+
it("native 1M 모델의 [1m] suffix 는 허용하되 CLI 에는 suffix 없이 전달한다", () => {
|
|
74
|
+
expect(() => assertSupportedOneMillionSuffix("anthropic/claude-fable-5-1[1m]")).not.toThrow();
|
|
75
|
+
expect(supports1MContext("anthropic/claude-fable-5-1[1m]")).toBe(true);
|
|
76
|
+
expect(needsCli1mSuffix("anthropic/claude-fable-5-1[1m]")).toBe(false);
|
|
77
|
+
});
|
|
68
78
|
});
|
|
69
79
|
|
|
70
80
|
describe("Anthropic thinking policy", () => {
|
|
71
|
-
it("Fable 5
|
|
81
|
+
it("Fable 5/5.1 과 Opus 5 는 adaptive thinking 을 보존한다", () => {
|
|
82
|
+
expect(usesAdaptiveThinking("claude-fable-5-1")).toBe(true);
|
|
83
|
+
expect(usesAdaptiveThinking("anthropic/claude-fable-5-1")).toBe(true);
|
|
72
84
|
expect(usesAdaptiveThinking("claude-fable-5")).toBe(true);
|
|
73
85
|
expect(usesAdaptiveThinking("anthropic/claude-fable-5")).toBe(true);
|
|
74
86
|
expect(usesAdaptiveThinking("claude-opus-5")).toBe(true);
|
|
@@ -77,3 +89,15 @@ describe("Anthropic thinking policy", () => {
|
|
|
77
89
|
expect(usesAdaptiveThinking("claude-opus-4-8")).toBe(false);
|
|
78
90
|
});
|
|
79
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("Anthropic Fable family", () => {
|
|
94
|
+
it("Fable 5/5.1 만 Fable 계열로 판정하고 prefix/[1m] 은 무시한다", () => {
|
|
95
|
+
expect(isFableFamilyModel("claude-fable-5-1")).toBe(true);
|
|
96
|
+
expect(isFableFamilyModel("claude-fable-5")).toBe(true);
|
|
97
|
+
expect(isFableFamilyModel("anthropic/claude-fable-5-1[1m]")).toBe(true);
|
|
98
|
+
|
|
99
|
+
expect(isFableFamilyModel("claude-opus-5")).toBe(false);
|
|
100
|
+
expect(isFableFamilyModel("claude-sonnet-4-6")).toBe(false);
|
|
101
|
+
expect(isFableFamilyModel(undefined)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -45,6 +45,7 @@ export function hasOneMillionSuffix(model?: string): boolean {
|
|
|
45
45
|
// 1M 지원 여부는 Claude Code 내부의 광범위한 prefix 판정이 아니라 qgrid에서 확인한 exact set 으로
|
|
46
46
|
// 고정한다. opus-4-7 은 실측 전까지 미포함. 새 모델 추가 시 공식 사양과 실제 CLI 동작을 확인한다.
|
|
47
47
|
const ONE_MILLION_CONTEXT_MODELS = new Set([
|
|
48
|
+
"claude-fable-5-1",
|
|
48
49
|
"claude-fable-5",
|
|
49
50
|
"claude-opus-5",
|
|
50
51
|
"claude-sonnet-5",
|
|
@@ -54,10 +55,16 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
|
|
|
54
55
|
]);
|
|
55
56
|
|
|
56
57
|
const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
|
|
57
|
-
// Fable 5
|
|
58
|
-
// 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
|
|
59
|
-
// 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
|
|
60
|
-
|
|
58
|
+
// Fable 5/5.1 은 adaptive thinking 이 유일한 모드다(CLI 카탈로그 `rejects_disabled_thinking`). Opus 5 는
|
|
59
|
+
// disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
|
|
60
|
+
// 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
|
|
61
|
+
// tool/XML 출력 오염도 피한다.
|
|
62
|
+
const ADAPTIVE_THINKING_MODELS = new Set(["claude-fable-5-1", "claude-fable-5", "claude-opus-5"]);
|
|
63
|
+
|
|
64
|
+
// Fable 계열: 구독 플랜에서 usage credits 가 필요한 최상위 티어. 구독 usage API 의
|
|
65
|
+
// `seven_day_overage_included` 버킷은 이 계열에만 적용되므로 quota threshold 판정이 참조한다.
|
|
66
|
+
// Mythos 는 Project Glasswing 조직 전용이라 구독 토큰 경로에서 도달할 수 없어 포함하지 않는다.
|
|
67
|
+
const FABLE_FAMILY_MODELS = new Set(["claude-fable-5-1", "claude-fable-5"]);
|
|
61
68
|
|
|
62
69
|
// 불변 계약: suffix 가 필요한 모델은 반드시 1M 지원 모델의 부분집합이어야 한다. 역방향 불일치(suffix
|
|
63
70
|
// 대상인데 지원 집합엔 없음)면 needsCli1mSuffix=true·supports1MContext=false 가 동시에 나서
|
|
@@ -68,6 +75,13 @@ for (const model of CLI_ONE_MILLION_SUFFIX_MODELS) {
|
|
|
68
75
|
throw new Error(`[invariant] CLI 1M suffix model not in context set: ${model}`);
|
|
69
76
|
}
|
|
70
77
|
}
|
|
78
|
+
// Fable 계열은 공식 사양상 1M 기본 컨텍스트 + adaptive thinking 필수다. 새 Fable 버전을 계열 Set
|
|
79
|
+
// 에만 넣고 나머지를 빠뜨리는 실수를 같은 방식으로 모듈 로드 시점에 잡는다.
|
|
80
|
+
for (const model of FABLE_FAMILY_MODELS) {
|
|
81
|
+
if (!ONE_MILLION_CONTEXT_MODELS.has(model) || !ADAPTIVE_THINKING_MODELS.has(model)) {
|
|
82
|
+
throw new Error(`[invariant] Fable family model missing from 1M/adaptive set: ${model}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
71
85
|
|
|
72
86
|
export function supports1MContext(model?: string): boolean {
|
|
73
87
|
return ONE_MILLION_CONTEXT_MODELS.has(canonicalAnthropicModel(model));
|
|
@@ -83,6 +97,11 @@ export function usesAdaptiveThinking(model?: string): boolean {
|
|
|
83
97
|
return ADAPTIVE_THINKING_MODELS.has(canonicalAnthropicModel(model));
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
// 구독 usage 의 Fable 전용 7일 버킷을 quota 판정에 포함할지 결정한다.
|
|
101
|
+
export function isFableFamilyModel(model?: string): boolean {
|
|
102
|
+
return FABLE_FAMILY_MODELS.has(canonicalAnthropicModel(model));
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
export function assertSupportedOneMillionSuffix(model?: string): void {
|
|
87
106
|
if (!hasOneMillionSuffix(model)) return;
|
|
88
107
|
const canonical = canonicalAnthropicModel(model);
|
|
@@ -12,12 +12,14 @@ const {
|
|
|
12
12
|
runClaudeSessionMock,
|
|
13
13
|
refreshTokenMock,
|
|
14
14
|
readAnthropicQuotaUsageMock,
|
|
15
|
+
invalidateAnthropicQuotaUsageMock,
|
|
15
16
|
loggerInfoMock,
|
|
16
17
|
loggerWarnMock,
|
|
17
18
|
} = vi.hoisted(() => ({
|
|
18
19
|
runClaudeSessionMock: vi.fn(),
|
|
19
20
|
refreshTokenMock: vi.fn(),
|
|
20
21
|
readAnthropicQuotaUsageMock: vi.fn(),
|
|
22
|
+
invalidateAnthropicQuotaUsageMock: vi.fn(),
|
|
21
23
|
loggerInfoMock: vi.fn(),
|
|
22
24
|
loggerWarnMock: vi.fn(),
|
|
23
25
|
}));
|
|
@@ -33,6 +35,7 @@ vi.mock("./claude-session", async (importActual) => {
|
|
|
33
35
|
|
|
34
36
|
vi.mock("./anthropic-quota", () => ({
|
|
35
37
|
readAnthropicQuotaUsage: readAnthropicQuotaUsageMock,
|
|
38
|
+
invalidateAnthropicQuotaUsage: invalidateAnthropicQuotaUsageMock,
|
|
36
39
|
}));
|
|
37
40
|
|
|
38
41
|
vi.mock("../../../application/qgrid/qgrid.frame", () => ({
|
|
@@ -112,6 +115,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
112
115
|
refreshTokenMock.mockReset();
|
|
113
116
|
refreshTokenMock.mockResolvedValue("sk-ant-oat01-refreshed");
|
|
114
117
|
readAnthropicQuotaUsageMock.mockReset();
|
|
118
|
+
invalidateAnthropicQuotaUsageMock.mockReset();
|
|
115
119
|
loggerInfoMock.mockReset();
|
|
116
120
|
loggerWarnMock.mockReset();
|
|
117
121
|
});
|
|
@@ -211,6 +215,21 @@ describe("AnthropicDispatcher", () => {
|
|
|
211
215
|
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
|
|
212
216
|
|
|
213
217
|
await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
|
|
218
|
+
expect(invalidateAnthropicQuotaUsageMock).toHaveBeenCalledWith("sk-ant-oat01-test");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("refresh 후 quota 소진은 선택 전후 access token cache를 모두 무효화한다", async () => {
|
|
222
|
+
const d = new AnthropicDispatcher();
|
|
223
|
+
d.onTokenAdded(1, "tok-A", creds(10_000), null, 1);
|
|
224
|
+
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
|
|
225
|
+
|
|
226
|
+
await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
|
|
227
|
+
|
|
228
|
+
expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(1, "sk-ant-oat01-test");
|
|
229
|
+
expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(
|
|
230
|
+
2,
|
|
231
|
+
"sk-ant-oat01-refreshed",
|
|
232
|
+
);
|
|
214
233
|
});
|
|
215
234
|
|
|
216
235
|
it("claude 에러 → 에러", async () => {
|
|
@@ -306,6 +325,23 @@ describe("AnthropicDispatcher", () => {
|
|
|
306
325
|
expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
|
|
307
326
|
});
|
|
308
327
|
|
|
328
|
+
it("Fable 요청은 Fable 전용 quota가 소진된 토큰을 건너뛴다", async () => {
|
|
329
|
+
const d = new AnthropicDispatcher();
|
|
330
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
331
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
332
|
+
readAnthropicQuotaUsageMock
|
|
333
|
+
.mockResolvedValueOnce(quotaOk(100))
|
|
334
|
+
.mockResolvedValueOnce(quotaOk(10));
|
|
335
|
+
|
|
336
|
+
const result = await d.generate(baseReq({ model: "claude-fable-5" }));
|
|
337
|
+
|
|
338
|
+
expect(result.tokenName).toBe("tok-B");
|
|
339
|
+
expect(readAnthropicQuotaUsageMock).toHaveBeenCalledWith(
|
|
340
|
+
expect.any(String),
|
|
341
|
+
"claude-fable-5",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
309
345
|
it("resets the schedule when a token weight changes", async () => {
|
|
310
346
|
const d = new AnthropicDispatcher();
|
|
311
347
|
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
@@ -26,7 +26,11 @@ import {
|
|
|
26
26
|
import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
|
|
27
27
|
import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
|
|
28
28
|
import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
invalidateAnthropicQuotaUsage,
|
|
31
|
+
readAnthropicQuotaUsage,
|
|
32
|
+
type AnthropicQuotaUsageResult,
|
|
33
|
+
} from "./anthropic-quota";
|
|
30
34
|
import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
|
|
31
35
|
|
|
32
36
|
const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
|
|
@@ -163,7 +167,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
163
167
|
|
|
164
168
|
// 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
|
|
165
169
|
// 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
|
|
166
|
-
private async selectToken(preferredTokenId?: number): Promise<PooledToken | null> {
|
|
170
|
+
private async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
|
|
167
171
|
const preferred =
|
|
168
172
|
preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
|
|
169
173
|
if (preferredTokenId !== undefined && !preferred) {
|
|
@@ -172,7 +176,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
172
176
|
|
|
173
177
|
const rows = preferred ? [preferred] : [...this.tokenPool.values()];
|
|
174
178
|
if (rows.length === 0) return null;
|
|
175
|
-
const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
|
|
179
|
+
const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows, model);
|
|
176
180
|
if (eligible.length === 0) {
|
|
177
181
|
logger.warn("quota_threshold gate: all_exceeded", {
|
|
178
182
|
provider: "anthropic",
|
|
@@ -193,7 +197,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
193
197
|
return this.tokenPool.get(selectedId) ?? null;
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
private async filterEligibleTokens(
|
|
200
|
+
private async filterEligibleTokens(
|
|
201
|
+
rows: PooledToken[],
|
|
202
|
+
model: string,
|
|
203
|
+
): Promise<{
|
|
197
204
|
eligible: PooledToken[];
|
|
198
205
|
overThresholdTokens: Array<{ tokenName: string; threshold: number }>;
|
|
199
206
|
}> {
|
|
@@ -207,7 +214,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
207
214
|
const checks = await Promise.allSettled(
|
|
208
215
|
thresholded.map(async (token) => ({
|
|
209
216
|
token,
|
|
210
|
-
result: await readAnthropicQuotaUsage(token.credentials.accessToken),
|
|
217
|
+
result: await readAnthropicQuotaUsage(token.credentials.accessToken, model),
|
|
211
218
|
})),
|
|
212
219
|
);
|
|
213
220
|
|
|
@@ -314,7 +321,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
314
321
|
const model = canonicalAnthropicModel(req.model);
|
|
315
322
|
const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
|
|
316
323
|
|
|
317
|
-
const token = await this.selectToken(req.preferredTokenId);
|
|
324
|
+
const token = await this.selectToken(model, req.preferredTokenId);
|
|
318
325
|
if (!token) throw new Error("No anthropic tokens available");
|
|
319
326
|
|
|
320
327
|
const exec = async (): Promise<GenerateResult> => {
|
|
@@ -359,7 +366,13 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
359
366
|
onDelta,
|
|
360
367
|
);
|
|
361
368
|
|
|
362
|
-
if (session.quotaExhausted)
|
|
369
|
+
if (session.quotaExhausted) {
|
|
370
|
+
invalidateAnthropicQuotaUsage(token.credentials.accessToken);
|
|
371
|
+
if (accessToken !== token.credentials.accessToken) {
|
|
372
|
+
invalidateAnthropicQuotaUsage(accessToken);
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`quota exhausted (${token.name})`);
|
|
375
|
+
}
|
|
363
376
|
if (session.isError) {
|
|
364
377
|
// 진단(SON-495): isError 판정 사유(subtype/terminal_reason)를 메시지 앞에 드러낸다.
|
|
365
378
|
// 그동안 detail 이 session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /
|