@cartanova/qgrid-cli 2.8.0 → 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/qgrid.frame.js +8 -14
- 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-quota.js +3 -2
- 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/qgrid.frame.test.ts +22 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
- 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-quota.test.ts +7 -1
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +3 -1
- 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-D208L33c.js → index-DDSQ_6GL.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-noN5Aka6.js → logs-D2OMhBlx.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-Ber13xcA.js → monit-CYLYHrc-.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-3_xetpSK.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-C5sHCg8X.js → settings-BeCBndh_.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BhTgGnkc.js → show-BdmzN1As.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-R7obwdAV.js → tokens-KCfMOeTH.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-CRAqRZXX.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 +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +3 -3
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +13 -9
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +2 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +17 -13
|
@@ -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);
|
|
@@ -34,7 +34,7 @@ describe("readAnthropicQuotaUsage", () => {
|
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
it("uses the separate Fable
|
|
37
|
+
it("uses the separate Fable 7-day limit only for Fable family requests", async () => {
|
|
38
38
|
const usage = {
|
|
39
39
|
data: {
|
|
40
40
|
five_hour: { utilization: 10, resets_at: null },
|
|
@@ -48,6 +48,12 @@ describe("readAnthropicQuotaUsage", () => {
|
|
|
48
48
|
kind: "ok",
|
|
49
49
|
utilizationPct: 100,
|
|
50
50
|
});
|
|
51
|
+
await expect(
|
|
52
|
+
readAnthropicQuotaUsage("access-token", "claude-fable-5-1"),
|
|
53
|
+
).resolves.toMatchObject({
|
|
54
|
+
kind: "ok",
|
|
55
|
+
utilizationPct: 100,
|
|
56
|
+
});
|
|
51
57
|
await expect(
|
|
52
58
|
readAnthropicQuotaUsage("access-token", "claude-sonnet-4-6"),
|
|
53
59
|
).resolves.toMatchObject({
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
fetchUsageWithMeta,
|
|
3
3
|
invalidateAnthropicQuotaUsage,
|
|
4
4
|
} from "../../../application/qgrid/oauth";
|
|
5
|
+
import { isFableFamilyModel } from "./anthropic-constants";
|
|
5
6
|
|
|
6
7
|
export { invalidateAnthropicQuotaUsage };
|
|
7
8
|
|
|
@@ -18,7 +19,8 @@ export async function readAnthropicQuotaUsage(
|
|
|
18
19
|
if (data.error) return { kind: "lookup_failed", reason: data.error };
|
|
19
20
|
|
|
20
21
|
const utilizations = [data.five_hour?.utilization];
|
|
21
|
-
|
|
22
|
+
// Fable 계열(5, 5.1)은 5시간 창과 별개로 usage credits 기반 7일 버킷도 소진될 수 있다.
|
|
23
|
+
if (isFableFamilyModel(model)) {
|
|
22
24
|
utilizations.push(data.seven_day_overage_included?.utilization);
|
|
23
25
|
}
|
|
24
26
|
const utilization = Math.max(
|
|
@@ -134,7 +134,9 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
|
134
134
|
expect(oneMillionEnv(false)).toEqual({ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" });
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
it("thinkingEnv: Fable 5/Opus 5 는 adaptive thinking 을 보존하고 나머지는 비활성화", () => {
|
|
137
|
+
it("thinkingEnv: Fable 5/5.1/Opus 5 는 adaptive thinking 을 보존하고 나머지는 비활성화", () => {
|
|
138
|
+
expect(thinkingEnv("claude-fable-5-1")).toEqual({});
|
|
139
|
+
expect(thinkingEnv("anthropic/claude-fable-5-1")).toEqual({});
|
|
138
140
|
expect(thinkingEnv("claude-fable-5")).toEqual({});
|
|
139
141
|
expect(thinkingEnv("anthropic/claude-fable-5")).toEqual({});
|
|
140
142
|
expect(thinkingEnv("claude-opus-5")).toEqual({});
|
|
@@ -262,15 +264,17 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
|
262
264
|
expect(args[args.indexOf("--model") + 1]).toBe("claude-opus-5");
|
|
263
265
|
});
|
|
264
266
|
|
|
265
|
-
it("Fable 5
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
267
|
+
it("Fable 5/5.1 은 항상 켜진 adaptive thinking 을 끄지 않고 effort 만 전달한다", () => {
|
|
268
|
+
for (const model of ["claude-fable-5", "claude-fable-5-1"]) {
|
|
269
|
+
const args = buildClaudeArgs({
|
|
270
|
+
model,
|
|
271
|
+
sessionId: "u",
|
|
272
|
+
effort: "low",
|
|
273
|
+
});
|
|
274
|
+
expect(args[args.indexOf("--model") + 1]).toBe(model);
|
|
275
|
+
expect(args).not.toContain("--thinking");
|
|
276
|
+
expect(args[args.indexOf("--effort") + 1]).toBe("low");
|
|
277
|
+
}
|
|
274
278
|
});
|
|
275
279
|
|
|
276
280
|
it("Opus 5 는 adaptive thinking 을 보존해 xhigh/max effort 조합도 거부되지 않게 한다", () => {
|