@cartanova/qgrid-cli 2.8.0 → 2.9.1
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/startup-migrations.js +42 -6
- 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/anthropic/claude-session.js +1 -1
- 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/startup-migrations.test.ts +116 -3
- package/bundle/src/startup-migrations.ts +70 -3
- 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-DiioTXCk.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-noN5Aka6.js → logs-Cmh7iNUl.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-Ber13xcA.js → monit-BxqRbzYv.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-3_xetpSK.js → routes-DrNdqoD1.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-ROuRMHwU.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BhTgGnkc.js → show-Ct97ts6E.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-R7obwdAV.js → tokens-Cw2eWi9I.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-CRAqRZXX.js → use-oauth-login-flow-D4-P9yOJ.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 +4 -2
- 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
|
@@ -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 조합도 거부되지 않게 한다", () => {
|
|
@@ -30,6 +30,8 @@ describe("calculateCostUsd", () => {
|
|
|
30
30
|
);
|
|
31
31
|
|
|
32
32
|
it.each([
|
|
33
|
+
// Fable 5.1 은 cache read 만 0.025x 특례($0.25), 나머지 배율은 표준과 같다.
|
|
34
|
+
["claude-fable-5-1", 10, 50, 0.25, 20],
|
|
33
35
|
["claude-fable-5", 10, 50, 1, 20],
|
|
34
36
|
["claude-haiku-4-5", 1, 5, 0.1, 2],
|
|
35
37
|
["claude-sonnet-4", 3, 15, 0.3, 6],
|
|
@@ -43,6 +45,7 @@ describe("calculateCostUsd", () => {
|
|
|
43
45
|
["claude-opus-4-7", 5, 25, 0.5, 10],
|
|
44
46
|
["claude-opus-4-8", 5, 25, 0.5, 10],
|
|
45
47
|
["claude-opus-5", 5, 25, 0.5, 10],
|
|
48
|
+
["claude-sonnet-5", 2, 10, 0.2, 4],
|
|
46
49
|
])(
|
|
47
50
|
"%s official Anthropic rates for 5m/1h cache writes",
|
|
48
51
|
(model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens) => {
|
|
@@ -57,8 +60,8 @@ describe("calculateCostUsd", () => {
|
|
|
57
60
|
},
|
|
58
61
|
);
|
|
59
62
|
|
|
60
|
-
it("Claude Sonnet 5 introductory
|
|
61
|
-
expect(getModelCosts("claude-sonnet-5"
|
|
63
|
+
it("Claude Sonnet 5 는 introductory $2/$10 이 정식 단가라 날짜 분기 없이 고정된다", () => {
|
|
64
|
+
expect(getModelCosts("claude-sonnet-5")).toEqual({
|
|
62
65
|
inputTokens: 2,
|
|
63
66
|
outputTokens: 10,
|
|
64
67
|
cachedInputTokens: 0.2,
|
|
@@ -66,18 +69,11 @@ describe("calculateCostUsd", () => {
|
|
|
66
69
|
cacheCreationInputTokens5m: 2.5,
|
|
67
70
|
cacheCreationInputTokens1h: 4,
|
|
68
71
|
});
|
|
69
|
-
expect(getModelCosts("claude-sonnet-5", Date.UTC(2026, 8, 1))).toEqual({
|
|
70
|
-
inputTokens: 3,
|
|
71
|
-
outputTokens: 15,
|
|
72
|
-
cachedInputTokens: 0.3,
|
|
73
|
-
cacheCreationInputTokens: 6,
|
|
74
|
-
cacheCreationInputTokens5m: 3.75,
|
|
75
|
-
cacheCreationInputTokens1h: 6,
|
|
76
|
-
});
|
|
77
72
|
});
|
|
78
73
|
|
|
79
74
|
it("provider prefix 와 [1m] suffix 를 제거한 canonical model 로 가격을 찾는다", () => {
|
|
80
75
|
expect(getModelCosts("anthropic/claude-fable-5")).toBe(getModelCosts("claude-fable-5"));
|
|
76
|
+
expect(getModelCosts("anthropic/claude-fable-5-1[1m]")).toBe(getModelCosts("claude-fable-5-1"));
|
|
81
77
|
expect(getModelCosts("anthropic/claude-sonnet-4-6[1m]")).toBe(
|
|
82
78
|
getModelCosts("claude-sonnet-4-6"),
|
|
83
79
|
);
|
|
@@ -184,6 +180,17 @@ describe("calculateCostUsd", () => {
|
|
|
184
180
|
).toBeCloseTo(5.8, 10);
|
|
185
181
|
});
|
|
186
182
|
|
|
183
|
+
it("Fable 5.1 은 cache read 만 $0.25 로 계산하고 나머지 단가는 Fable 5 와 같다", () => {
|
|
184
|
+
const usage = {
|
|
185
|
+
inputTokens: 100_000,
|
|
186
|
+
outputTokens: 100_000,
|
|
187
|
+
cachedInputTokens: 50_000,
|
|
188
|
+
cacheCreationInputTokens: 25_000,
|
|
189
|
+
};
|
|
190
|
+
// Fable 5: 5.8. cache read 50K 가 $1 → $0.25 로 바뀌면 0.05 → 0.0125 만큼 줄어든다.
|
|
191
|
+
expect(calculateCostUsd("claude-fable-5-1", usage)).toBeCloseTo(5.7625, 10);
|
|
192
|
+
});
|
|
193
|
+
|
|
187
194
|
it("Fable 의 5분/1시간 cache write 단가도 구분한다", () => {
|
|
188
195
|
expect(
|
|
189
196
|
calculateCostUsd("claude-fable-5", {
|
|
@@ -197,21 +204,14 @@ describe("calculateCostUsd", () => {
|
|
|
197
204
|
).toBeCloseTo(5.725, 10);
|
|
198
205
|
});
|
|
199
206
|
|
|
200
|
-
it("Sonnet 5
|
|
207
|
+
it("Sonnet 5 는 $2/$10 단가로 cost 를 계산한다", () => {
|
|
201
208
|
const usage = {
|
|
202
209
|
inputTokens: 100_000,
|
|
203
210
|
outputTokens: 100_000,
|
|
204
211
|
cachedInputTokens: 50_000,
|
|
205
212
|
cacheCreationInputTokens: 25_000,
|
|
206
213
|
};
|
|
207
|
-
expect(calculateCostUsd("claude-sonnet-5", usage
|
|
208
|
-
1.16,
|
|
209
|
-
10,
|
|
210
|
-
);
|
|
211
|
-
expect(calculateCostUsd("claude-sonnet-5", usage, Date.UTC(2026, 8, 1))).toBeCloseTo(
|
|
212
|
-
1.74,
|
|
213
|
-
10,
|
|
214
|
-
);
|
|
214
|
+
expect(calculateCostUsd("claude-sonnet-5", usage)).toBeCloseTo(1.16, 10);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
it("legacy/원시 Anthropic usage 처럼 cache 가 input 보다 커도 음수 비용을 만들지 않는다", () => {
|
|
@@ -79,6 +79,9 @@ const OPENAI_COSTS: Record<string, ModelCosts> = {
|
|
|
79
79
|
cachedInputTokens: 0.5,
|
|
80
80
|
longContext: LONG_CONTEXT_272K,
|
|
81
81
|
},
|
|
82
|
+
// 아래 gpt-5.4 / gpt-5.4-mini / gpt-5.3-codex / gpt-5.2 는 qgrid 가 쓰는 ChatGPT 구독 Codex 경로에서
|
|
83
|
+
// 더 이상 제공되지 않는다(5.4 계열은 2026-08-31 retired, 대체 gpt-5.6-terra / gpt-5.6-luna).
|
|
84
|
+
// 과거 request log 재산정(cost_source 없는 legacy 행)에 필요하므로 단가 행은 유지한다.
|
|
82
85
|
// https://openai.com/index/introducing-gpt-5-4/ (2026-03 출시)
|
|
83
86
|
"gpt-5.4": {
|
|
84
87
|
inputTokens: 2.5,
|
|
@@ -95,11 +98,16 @@ const OPENAI_COSTS: Record<string, ModelCosts> = {
|
|
|
95
98
|
|
|
96
99
|
// ── Anthropic ───────────────────────────────────────────────────────
|
|
97
100
|
|
|
98
|
-
function anthropicCosts(
|
|
101
|
+
function anthropicCosts(
|
|
102
|
+
inputTokens: number,
|
|
103
|
+
outputTokens: number,
|
|
104
|
+
overrides: Pick<Partial<ModelCosts>, "cachedInputTokens"> = {},
|
|
105
|
+
): ModelCosts {
|
|
99
106
|
return {
|
|
100
107
|
inputTokens,
|
|
101
108
|
outputTokens,
|
|
102
|
-
|
|
109
|
+
// 표준 cache read 는 base input 의 0.1x. Fable 5.1 처럼 공식 특례 단가가 있으면 override 한다.
|
|
110
|
+
cachedInputTokens: overrides.cachedInputTokens ?? inputTokens / 10,
|
|
103
111
|
// Claude Code subscription OAuth 는 1h TTL 을 자동 선택한다. 구버전처럼 응답에
|
|
104
112
|
// TTL breakdown 이 없을 때만 이 1h 단가를 fallback 으로 사용한다.
|
|
105
113
|
cacheCreationInputTokens: inputTokens * 2,
|
|
@@ -108,7 +116,11 @@ function anthropicCosts(inputTokens: number, outputTokens: number): ModelCosts {
|
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
// 가격 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-09-02 확인)
|
|
111
120
|
const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
|
|
121
|
+
// Fable 5.1 (2026-09-01 출시): input/output 은 Fable 5 와 같고 cache read 만 0.025x($0.25) 특례.
|
|
122
|
+
// @see https://platform.claude.com/docs/en/models/fable-5-1/overview
|
|
123
|
+
"claude-fable-5-1": anthropicCosts(10, 50, { cachedInputTokens: 0.25 }),
|
|
112
124
|
"claude-fable-5": anthropicCosts(10, 50),
|
|
113
125
|
sonnet: anthropicCosts(3, 15),
|
|
114
126
|
"claude-3-5-haiku": anthropicCosts(0.8, 4),
|
|
@@ -126,26 +138,18 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
|
|
|
126
138
|
"claude-opus-4-7": anthropicCosts(5, 25),
|
|
127
139
|
"claude-opus-4-8": anthropicCosts(5, 25),
|
|
128
140
|
"claude-opus-5": anthropicCosts(5, 25),
|
|
141
|
+
// Sonnet 5 의 introductory $2/$10 이 정식 단가로 확정됐다. 2026-09-01 에 예정됐던 $3/$15 인상은
|
|
142
|
+
// 취소됐으므로 날짜 분기 없이 고정 단가로 계산한다.
|
|
143
|
+
"claude-sonnet-5": anthropicCosts(2, 10),
|
|
129
144
|
};
|
|
130
145
|
|
|
131
|
-
// Anthropic 공식 introductory pricing: 2026-08-31까지 $2/$10, 이후 $3/$15.
|
|
132
|
-
// qgrid 는 요청 시점에 계산하므로 배포를 다시 하지 않아도 2026-09-01 UTC부터 표준 단가로 전환한다.
|
|
133
|
-
const CLAUDE_SONNET_5_INTRO_PRICING_END_MS = Date.UTC(2026, 8, 1);
|
|
134
|
-
const CLAUDE_SONNET_5_INTRO_COSTS = anthropicCosts(2, 10);
|
|
135
|
-
const CLAUDE_SONNET_5_STANDARD_COSTS = anthropicCosts(3, 15);
|
|
136
|
-
|
|
137
146
|
// gpt-5.3-codex-spark 는 research preview 로 공식 token 단가가 아직 final 이 아니다.
|
|
138
147
|
// 지원 타입은 유지하되, 공식 단가가 공개될 때까지 아래 generic estimate 로 계산한다.
|
|
139
148
|
// @see https://help.openai.com/en/articles/20001106-codex-rate-card
|
|
140
149
|
const DEFAULT_COSTS: ModelCosts = { inputTokens: 3, outputTokens: 15, cachedInputTokens: 0.3 };
|
|
141
150
|
|
|
142
|
-
export function getModelCosts(model: string
|
|
151
|
+
export function getModelCosts(model: string): ModelCosts {
|
|
143
152
|
const normalizedModel = (model.split("/").pop() ?? model).replace(/\[1m\]$/i, "");
|
|
144
|
-
if (normalizedModel === "claude-sonnet-5") {
|
|
145
|
-
return atMs < CLAUDE_SONNET_5_INTRO_PRICING_END_MS
|
|
146
|
-
? CLAUDE_SONNET_5_INTRO_COSTS
|
|
147
|
-
: CLAUDE_SONNET_5_STANDARD_COSTS;
|
|
148
|
-
}
|
|
149
153
|
return OPENAI_COSTS[normalizedModel] ?? ANTHROPIC_COSTS[normalizedModel] ?? DEFAULT_COSTS;
|
|
150
154
|
}
|
|
151
155
|
|
|
@@ -159,9 +163,8 @@ export function calculateCostUsd(
|
|
|
159
163
|
cacheCreationInputTokens5m?: number;
|
|
160
164
|
cacheCreationInputTokens1h?: number;
|
|
161
165
|
},
|
|
162
|
-
atMs = Date.now(),
|
|
163
166
|
): number {
|
|
164
|
-
const costs = getModelCosts(model
|
|
167
|
+
const costs = getModelCosts(model);
|
|
165
168
|
const cachedInput = usage.cachedInputTokens ?? 0;
|
|
166
169
|
const cacheCreationInput5m = Math.max(usage.cacheCreationInputTokens5m ?? 0, 0);
|
|
167
170
|
const cacheCreationInput1h = Math.max(usage.cacheCreationInputTokens1h ?? 0, 0);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
const { findOneMock, saveMock, deactivateMock } = vi.hoisted(() =>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
const { findOneMock, saveMock, deactivateMock } = vi.hoisted(() => {
|
|
4
|
+
// isolate:false 공유 레지스트리에 실제 TokenModel 이 먼저 올라와도 이 파일의 mock을 보장한다.
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
return {
|
|
7
|
+
findOneMock: vi.fn(),
|
|
8
|
+
saveMock: vi.fn(),
|
|
9
|
+
deactivateMock: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
8
12
|
|
|
9
13
|
vi.mock("../../../application/token/token.model", () => ({
|
|
10
14
|
TokenModel: { findOne: findOneMock, save: saveMock },
|
|
@@ -80,6 +84,7 @@ describe("OpenAI refresh", () => {
|
|
|
80
84
|
expect(saveMock).toHaveBeenCalledWith([
|
|
81
85
|
expect.objectContaining({
|
|
82
86
|
id: 10,
|
|
87
|
+
reauth_required: false,
|
|
83
88
|
credentials: expect.objectContaining({
|
|
84
89
|
accessToken: "new-access",
|
|
85
90
|
refreshToken: "new-refresh",
|
|
@@ -112,8 +117,12 @@ describe("OpenAI refresh", () => {
|
|
|
112
117
|
|
|
113
118
|
await expect(handleChatgptAuthTokensRefresh(11)).rejects.toThrow("OpenAI refresh failed: 401");
|
|
114
119
|
expect(deactivateMock).toHaveBeenCalledWith(
|
|
115
|
-
{
|
|
116
|
-
|
|
120
|
+
{
|
|
121
|
+
id: 11,
|
|
122
|
+
name: "token-11",
|
|
123
|
+
provider: "openai",
|
|
124
|
+
credentials: expect.objectContaining({ refreshToken: "old-refresh" }),
|
|
125
|
+
},
|
|
117
126
|
"openai:refresh_token_reused",
|
|
118
127
|
);
|
|
119
128
|
});
|
|
@@ -114,8 +114,7 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
114
114
|
// 상태코드를 바꿀 때 판정이 조용히 멎는다.
|
|
115
115
|
if (errorCode && PERMANENT_FAILURE_CODES.has(errorCode)) {
|
|
116
116
|
await deactivateAuthDeadToken(
|
|
117
|
-
{ id: tokenId, name: token.name, provider: "openai" },
|
|
118
|
-
creds.refreshToken,
|
|
117
|
+
{ id: tokenId, name: token.name, provider: "openai", credentials: creds },
|
|
119
118
|
`openai:${errorCode}`,
|
|
120
119
|
);
|
|
121
120
|
}
|
|
@@ -158,6 +157,7 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
158
157
|
provider: "openai",
|
|
159
158
|
credentials: updatedCreds,
|
|
160
159
|
name: token.name,
|
|
160
|
+
reauth_required: false,
|
|
161
161
|
},
|
|
162
162
|
]);
|
|
163
163
|
} catch (e) {
|