@cartanova/qgrid-cli 2.9.2 → 2.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/qgrid/expired-token-reminder.js +6 -5
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
- package/bundle/dist/application/qgrid/qgrid-response-format.js +8 -6
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +4 -3
- package/bundle/dist/application/qgrid/qgrid.types.js +31 -4
- package/bundle/dist/application/qgrid/token-death.js +6 -22
- package/bundle/dist/application/qgrid/token-subscriber.js +2 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +3 -2
- package/bundle/dist/application/setting/setting.model.js +8 -2
- package/bundle/dist/application/token/token.model.js +22 -17
- package/bundle/dist/utils/providers/common/model-cost.js +21 -13
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +41 -20
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +145 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
- package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
- package/bundle/dist/utils/slack-notify.js +8 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
- package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
- package/bundle/src/application/qgrid/qgrid-response-format.test.ts +26 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +21 -13
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +20 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +3 -2
- package/bundle/src/application/qgrid/qgrid.types.ts +32 -0
- package/bundle/src/application/qgrid/token-death.test.ts +11 -10
- package/bundle/src/application/qgrid/token-death.ts +9 -21
- package/bundle/src/application/qgrid/token-subscriber.test.ts +5 -1
- package/bundle/src/application/qgrid/token-subscriber.ts +1 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +18 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +3 -0
- package/bundle/src/application/setting/setting.model.test.ts +20 -0
- package/bundle/src/application/setting/setting.model.ts +10 -2
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/token/token.model.test.ts +24 -5
- package/bundle/src/application/token/token.model.ts +30 -23
- package/bundle/src/utils/providers/common/effort.test.ts +8 -0
- package/bundle/src/utils/providers/common/model-cost.test.ts +25 -12
- package/bundle/src/utils/providers/common/model-cost.ts +28 -14
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +5 -1
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +9 -1
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +137 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +44 -18
- package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +135 -0
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +177 -0
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
- package/bundle/src/utils/slack-notify.test.ts +14 -0
- package/bundle/src/utils/slack-notify.ts +19 -3
- package/bundle/web-dist/client/assets/{index-DH1n8kgG.js → index-BcCytvFf.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D36O-NET.js → logs-BkOilk5x.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-DhZAaz9K.js → monit-8oCSLCdL.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-C7q1CsSv.js → routes-DFIF4IoW.js} +2 -2
- package/bundle/web-dist/client/assets/{settings-M_jfoIAe.js → settings-nUI-DR3U.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CORjMGQs.js → show-Bzx6Xkoc.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-C7-j4VXD.js → tokens-DFWCL7Ts.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Fi62xbBc.js → use-oauth-login-flow-B5MAjpVc.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{routes-CxMHXfdH.js → routes-B408iqh1.js} +29 -14
- package/bundle/web-dist/server/entry-server.generated.js +3 -2
- package/package.json +2 -1
- package/skills/qgrid/SKILL.md +2 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -2
- package/skills/qgrid/references/decision-rationale.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +79 -0
- package/skills/qgrid/references/provider-runtime-differences.md +1 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +4 -3
|
@@ -39,7 +39,7 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
39
39
|
it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
|
|
40
40
|
deactivateMock.mockResolvedValue({
|
|
41
41
|
marked: true,
|
|
42
|
-
|
|
42
|
+
wasLastActive: false,
|
|
43
43
|
staleCredentials: false,
|
|
44
44
|
});
|
|
45
45
|
|
|
@@ -60,7 +60,7 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
60
60
|
it("skips deactivation when credentials rotated since the failed attempt", async () => {
|
|
61
61
|
deactivateMock.mockResolvedValue({
|
|
62
62
|
marked: false,
|
|
63
|
-
|
|
63
|
+
wasLastActive: false,
|
|
64
64
|
staleCredentials: true,
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -73,7 +73,7 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
73
73
|
it("does not notify when another process won the deactivation race", async () => {
|
|
74
74
|
deactivateMock.mockResolvedValue({
|
|
75
75
|
marked: false,
|
|
76
|
-
|
|
76
|
+
wasLastActive: false,
|
|
77
77
|
staleCredentials: false,
|
|
78
78
|
});
|
|
79
79
|
|
|
@@ -82,18 +82,19 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
82
82
|
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it("
|
|
85
|
+
it("deactivates the last active token and sends an urgent expiry alert", async () => {
|
|
86
86
|
deactivateMock.mockResolvedValue({
|
|
87
87
|
marked: true,
|
|
88
|
-
|
|
88
|
+
wasLastActive: true,
|
|
89
89
|
staleCredentials: false,
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(
|
|
92
|
+
await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(true);
|
|
93
93
|
|
|
94
94
|
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
95
95
|
const notification = notifySlackMock.mock.calls[0]![0] as { title: string; context: string };
|
|
96
|
-
expect(notification.title).
|
|
96
|
+
expect(notification.title).toBe("세션 만료");
|
|
97
|
+
expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ urgent: true });
|
|
97
98
|
expect(notification.context).toContain("anthropic:400");
|
|
98
99
|
});
|
|
99
100
|
|
|
@@ -101,12 +102,12 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
101
102
|
deactivateMock
|
|
102
103
|
.mockResolvedValueOnce({
|
|
103
104
|
marked: true,
|
|
104
|
-
|
|
105
|
+
wasLastActive: true,
|
|
105
106
|
staleCredentials: false,
|
|
106
107
|
})
|
|
107
108
|
.mockResolvedValue({
|
|
108
109
|
marked: false,
|
|
109
|
-
|
|
110
|
+
wasLastActive: false,
|
|
110
111
|
staleCredentials: false,
|
|
111
112
|
});
|
|
112
113
|
|
|
@@ -120,7 +121,7 @@ describe("deactivateAuthDeadToken", () => {
|
|
|
120
121
|
it("never puts the raw provider error body in the notification", async () => {
|
|
121
122
|
deactivateMock.mockResolvedValue({
|
|
122
123
|
marked: true,
|
|
123
|
-
|
|
124
|
+
wasLastActive: false,
|
|
124
125
|
staleCredentials: false,
|
|
125
126
|
});
|
|
126
127
|
|
|
@@ -24,7 +24,7 @@ export async function deactivateAuthDeadToken(
|
|
|
24
24
|
token: DeadTokenRef,
|
|
25
25
|
reasonCode: string,
|
|
26
26
|
): Promise<boolean> {
|
|
27
|
-
const { marked,
|
|
27
|
+
const { marked, wasLastActive, staleCredentials } = await TokenModel.markReauthRequired(
|
|
28
28
|
token.id,
|
|
29
29
|
token.credentials,
|
|
30
30
|
);
|
|
@@ -35,10 +35,6 @@ export async function deactivateAuthDeadToken(
|
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
37
37
|
if (!marked) return false;
|
|
38
|
-
if (keptAsLastActive) {
|
|
39
|
-
notifyLastActiveTokenDying(token, reasonCode);
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
38
|
|
|
43
39
|
logger.warn(`token requires re-login: ${token.name}(id=${token.id}) reason=${reasonCode}`);
|
|
44
40
|
// 만료 직후가 재로그인하기 가장 좋은 타이밍이다. 소유자를 멘션해 그 순간 당사자에게
|
|
@@ -47,29 +43,21 @@ export async function deactivateAuthDeadToken(
|
|
|
47
43
|
void notifySlack({
|
|
48
44
|
title: "세션 만료",
|
|
49
45
|
subject: token.name,
|
|
50
|
-
context: [
|
|
46
|
+
context: [
|
|
47
|
+
mention,
|
|
48
|
+
token.provider,
|
|
49
|
+
reasonCode,
|
|
50
|
+
"재로그인이 필요합니다",
|
|
51
|
+
wasLastActive ? "사용 가능한 토큰이 없습니다" : null,
|
|
52
|
+
]
|
|
51
53
|
.filter(Boolean)
|
|
52
54
|
.join(" · "),
|
|
53
55
|
color: SLACK_COLOR.bad,
|
|
56
|
+
urgent: wasLastActive,
|
|
54
57
|
});
|
|
55
58
|
return true;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): void {
|
|
59
|
-
const mention = mentionFor(token.name, getSlackUserMap());
|
|
60
|
-
void notifySlack({
|
|
61
|
-
title: "마지막 토큰 사망",
|
|
62
|
-
subject: token.name,
|
|
63
|
-
context:
|
|
64
|
-
[mention, token.provider, reasonCode].filter(Boolean).join(" · ") +
|
|
65
|
-
` · 풀이 비지 않도록 유지 중입니다. ` +
|
|
66
|
-
`전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
|
|
67
|
-
color: SLACK_COLOR.bad,
|
|
68
|
-
// provider 전체가 죽었다는 신호다. 조용 시간에 묻히면 다음 근무일까지 서비스가 멈춘다.
|
|
69
|
-
urgent: true,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
61
|
/** 로그인이 완료돼 토큰이 저장됐을 때. 신규·재로그인을 가리지 않는다. */
|
|
74
62
|
export function notifyTokenAdded(name: string, provider: string): void {
|
|
75
63
|
logger.info(`token added: ${name} (${provider})`);
|
|
@@ -2,15 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { TokenSubscriber } from "./token-subscriber";
|
|
4
4
|
|
|
5
|
-
const { findOneMock, findActiveMock } = vi.hoisted(() => ({
|
|
5
|
+
const { findOneMock, findActiveMock, deactivateExpiredTokensMock } = vi.hoisted(() => ({
|
|
6
6
|
findOneMock: vi.fn(),
|
|
7
7
|
findActiveMock: vi.fn(),
|
|
8
|
+
deactivateExpiredTokensMock: vi.fn(),
|
|
8
9
|
}));
|
|
9
10
|
|
|
10
11
|
vi.mock("../token/token.model", () => ({
|
|
11
12
|
TokenModel: {
|
|
12
13
|
findOne: findOneMock,
|
|
13
14
|
findActive: findActiveMock,
|
|
15
|
+
deactivateExpiredTokens: deactivateExpiredTokensMock,
|
|
14
16
|
},
|
|
15
17
|
}));
|
|
16
18
|
|
|
@@ -264,6 +266,8 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
264
266
|
|
|
265
267
|
await subscriber.reconcile();
|
|
266
268
|
|
|
269
|
+
expect(deactivateExpiredTokensMock).toHaveBeenCalled();
|
|
270
|
+
|
|
267
271
|
expect(anthropicDispatcher.replaceTokens).toHaveBeenCalledWith([
|
|
268
272
|
expect.objectContaining({ id: 1, quotaThreshold: 80, weight: 4 }),
|
|
269
273
|
]);
|
|
@@ -271,6 +271,7 @@ export class TokenSubscriber {
|
|
|
271
271
|
.map((row) => row.id),
|
|
272
272
|
)
|
|
273
273
|
: null;
|
|
274
|
+
await TokenModel.deactivateExpiredTokens();
|
|
274
275
|
const rows = await TokenModel.findActive("A");
|
|
275
276
|
this.dispatcher.replaceCache(rows);
|
|
276
277
|
// NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
|
|
@@ -56,6 +56,24 @@ describe("applyToolCallEmulation image parts", () => {
|
|
|
56
56
|
});
|
|
57
57
|
expect(out.content).toEqual([{ type: "text", text: "here is your image" }]);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
it("preserves observed image settings and attaches response usage only once", () => {
|
|
61
|
+
const generation = {
|
|
62
|
+
route: "codex-images" as const, model: "gpt-image-2" as const,
|
|
63
|
+
size: "1254x1254", quality: "medium", background: "transparent",
|
|
64
|
+
};
|
|
65
|
+
const usage = { input_tokens: 10, output_tokens: 100, total_tokens: 110 };
|
|
66
|
+
const out = applyToolCallEmulation(baseResult, undefined, {
|
|
67
|
+
answerKind: "text",
|
|
68
|
+
images: [
|
|
69
|
+
{ ...img, generation: { ...generation, usage } },
|
|
70
|
+
{ ...img, generation },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
expect(out.content.filter((item) => item.type === "image").map((item) => item.generation))
|
|
74
|
+
.toEqual([{ ...generation, usage }, generation]);
|
|
75
|
+
expect(out.usage).toEqual(baseResult.usage);
|
|
76
|
+
});
|
|
59
77
|
});
|
|
60
78
|
|
|
61
79
|
describe("applyToolCallEmulation envelope validation", () => {
|
|
@@ -12,6 +12,7 @@ const STRUCTURED_ENVELOPE_LIMITS = {
|
|
|
12
12
|
maxDepth: 132,
|
|
13
13
|
} as const;
|
|
14
14
|
import {
|
|
15
|
+
type ImageGenerationMetadata,
|
|
15
16
|
type QgridContent,
|
|
16
17
|
type QgridThreadCoord,
|
|
17
18
|
type QgridTool,
|
|
@@ -70,6 +71,7 @@ export class ToolCallEmulationError extends Error {
|
|
|
70
71
|
interface EmulationImage {
|
|
71
72
|
data: string;
|
|
72
73
|
revisedPrompt?: string | null;
|
|
74
|
+
generation?: ImageGenerationMetadata;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// answer 인코딩 종류. envelope 디코더는 한 벌이고 이 값은 answer 브랜치의 타입과
|
|
@@ -212,6 +214,7 @@ function appendImages(content: QgridContent[], images?: EmulationImage[]): Qgrid
|
|
|
212
214
|
type: "image",
|
|
213
215
|
data: image.data,
|
|
214
216
|
revisedPrompt: image.revisedPrompt ?? null,
|
|
217
|
+
...(image.generation ? { generation: image.generation } : {}),
|
|
215
218
|
}),
|
|
216
219
|
),
|
|
217
220
|
];
|
|
@@ -6,6 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
|
|
7
7
|
import { resetServerRestartForTests } from "../../utils/server-restart";
|
|
8
8
|
import { SettingModel } from "./setting.model";
|
|
9
|
+
import * as reminders from "../qgrid/expired-token-reminder";
|
|
10
|
+
import { SlackNotificationError } from "../../utils/slack-notify";
|
|
11
|
+
|
|
12
|
+
describe("SettingModel.triggerExpiryReminder", () => {
|
|
13
|
+
afterEach(() => vi.restoreAllMocks());
|
|
14
|
+
|
|
15
|
+
it("returns the sent count after successful delivery", async () => {
|
|
16
|
+
vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockResolvedValue(3);
|
|
17
|
+
await expect(SettingModel.triggerExpiryReminder()).resolves.toEqual({ sent: 3 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns a readable API error when Slack rejects a manual send", async () => {
|
|
21
|
+
vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockRejectedValue(
|
|
22
|
+
new SlackNotificationError("Slack 알림 발송 실패: not_in_channel"),
|
|
23
|
+
);
|
|
24
|
+
await expect(SettingModel.triggerExpiryReminder()).rejects.toMatchObject({
|
|
25
|
+
statusCode: 400, message: "Slack 알림 발송 실패: not_in_channel",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
9
29
|
|
|
10
30
|
function installContext(headers: Record<string, string>) {
|
|
11
31
|
const raw = Object.assign(new EventEmitter(), {
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
exhaustive,
|
|
12
12
|
} from "sonamu";
|
|
13
13
|
|
|
14
|
-
import { SD } from "../../i18n/sd.generated";
|
|
14
|
+
import { SD, type LocalizedString } from "../../i18n/sd.generated";
|
|
15
15
|
import { detectSupervisor } from "../../utils/process-supervisor";
|
|
16
16
|
import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
|
|
17
|
+
import { SlackNotificationError } from "../../utils/slack-notify";
|
|
17
18
|
import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
|
|
18
19
|
import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
|
|
19
20
|
import {
|
|
@@ -315,7 +316,14 @@ class SettingModelClass extends BaseModelClass<
|
|
|
315
316
|
// setting.model → expired-token-reminder → token.model 경로가 생겨,
|
|
316
317
|
// token.model 을 mock 하는 테스트가 실제 모듈을 먼저 로드하게 된다.
|
|
317
318
|
const { sendExpiredTokenReminderNow } = await import("../qgrid/expired-token-reminder");
|
|
318
|
-
|
|
319
|
+
try {
|
|
320
|
+
return { sent: await sendExpiredTokenReminderNow() };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof SlackNotificationError) {
|
|
323
|
+
throw new BadRequestException(error.message as LocalizedString);
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
319
327
|
}
|
|
320
328
|
|
|
321
329
|
/**
|
|
@@ -326,7 +326,8 @@ Content-Type: application/json
|
|
|
326
326
|
"imageGeneration": false,
|
|
327
327
|
"imageGenerationOptions": {
|
|
328
328
|
"quality": "low",
|
|
329
|
-
"size": "1024x1024"
|
|
329
|
+
"size": "1024x1024",
|
|
330
|
+
"background": "auto"
|
|
330
331
|
}
|
|
331
332
|
}
|
|
332
333
|
}
|
|
@@ -388,7 +389,8 @@ Content-Type: application/json
|
|
|
388
389
|
"imageGeneration": false,
|
|
389
390
|
"imageGenerationOptions": {
|
|
390
391
|
"quality": "low",
|
|
391
|
-
"size": "1024x1024"
|
|
392
|
+
"size": "1024x1024",
|
|
393
|
+
"background": "auto"
|
|
392
394
|
}
|
|
393
395
|
}
|
|
394
396
|
}
|
|
@@ -213,7 +213,25 @@ describe("TokenModel.markReauthRequired", () => {
|
|
|
213
213
|
await Sonamu.initForTesting();
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("
|
|
216
|
+
it("repairs legacy active expired tokens without changing healthy or manually inactive tokens", async () => {
|
|
217
|
+
const ids = await TokenModel.save([
|
|
218
|
+
{ ...baseToken, name: "legacy-expired", active: true, reauth_required: true },
|
|
219
|
+
{ ...baseToken, name: "healthy", active: true, reauth_required: false },
|
|
220
|
+
{ ...baseToken, name: "manual-inactive", active: false, reauth_required: false },
|
|
221
|
+
]);
|
|
222
|
+
try {
|
|
223
|
+
await TokenModel.deactivateExpiredTokens();
|
|
224
|
+
await TokenModel.deactivateExpiredTokens();
|
|
225
|
+
const { rows } = await TokenModel.findMany("A", { id: ids, num: 3 });
|
|
226
|
+
expect(rows.find((row) => row.id === ids[0])).toMatchObject({ active: false, reauth_required: true });
|
|
227
|
+
expect(rows.find((row) => row.id === ids[1])).toMatchObject({ active: true, reauth_required: false });
|
|
228
|
+
expect(rows.find((row) => row.id === ids[2])).toMatchObject({ active: false, reauth_required: false });
|
|
229
|
+
} finally {
|
|
230
|
+
await TokenModel.del(ids);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("deactivates every expired token and rejects stale credentials", async () => {
|
|
217
235
|
const suffix = randomUUID();
|
|
218
236
|
const tokens = [
|
|
219
237
|
{
|
|
@@ -237,7 +255,7 @@ describe("TokenModel.markReauthRequired", () => {
|
|
|
237
255
|
}),
|
|
238
256
|
).resolves.toEqual({
|
|
239
257
|
marked: false,
|
|
240
|
-
|
|
258
|
+
wasLastActive: false,
|
|
241
259
|
staleCredentials: true,
|
|
242
260
|
});
|
|
243
261
|
|
|
@@ -246,16 +264,17 @@ describe("TokenModel.markReauthRequired", () => {
|
|
|
246
264
|
TokenModel.markReauthRequired(ids[1]!, tokens[1]!.credentials),
|
|
247
265
|
]);
|
|
248
266
|
expect(results.every((result) => result.marked)).toBe(true);
|
|
267
|
+
expect(results.filter((result) => result.wasLastActive)).toHaveLength(1);
|
|
249
268
|
|
|
250
269
|
const { rows } = await TokenModel.findMany("A", { id: ids, num: 2 });
|
|
251
270
|
expect(rows.every((row) => row.reauth_required)).toBe(true);
|
|
252
|
-
expect(rows.filter((row) => row.active)).toHaveLength(
|
|
271
|
+
expect(rows.filter((row) => row.active)).toHaveLength(0);
|
|
253
272
|
|
|
254
273
|
await expect(
|
|
255
274
|
TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
|
|
256
275
|
).resolves.toEqual({
|
|
257
276
|
marked: false,
|
|
258
|
-
|
|
277
|
+
wasLastActive: false,
|
|
259
278
|
staleCredentials: false,
|
|
260
279
|
});
|
|
261
280
|
|
|
@@ -278,7 +297,7 @@ describe("TokenModel.markReauthRequired", () => {
|
|
|
278
297
|
TokenModel.markReauthRequired(inactive.id, inactive.credentials),
|
|
279
298
|
).resolves.toEqual({
|
|
280
299
|
marked: false,
|
|
281
|
-
|
|
300
|
+
wasLastActive: false,
|
|
282
301
|
staleCredentials: true,
|
|
283
302
|
});
|
|
284
303
|
|
|
@@ -267,14 +267,13 @@ class TokenModelClass extends BaseModelClass<
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
|
|
270
|
+
* Record confirmed expiry and remove the token from routing atomically.
|
|
271
|
+
* The last active token is also deactivated; its notification is urgent.
|
|
273
272
|
*/
|
|
274
273
|
async markReauthRequired(
|
|
275
274
|
id: number,
|
|
276
275
|
expectedCredentials: TokenCredentials,
|
|
277
|
-
): Promise<{ marked: boolean;
|
|
276
|
+
): Promise<{ marked: boolean; wasLastActive: boolean; staleCredentials: boolean }> {
|
|
278
277
|
const wdb = this.getPuri("w");
|
|
279
278
|
return wdb.transaction(async (trx) => {
|
|
280
279
|
const token = await trx
|
|
@@ -288,42 +287,50 @@ class TokenModelClass extends BaseModelClass<
|
|
|
288
287
|
.forUpdate()
|
|
289
288
|
.first();
|
|
290
289
|
if (!token || token.reauth_required) {
|
|
291
|
-
return { marked: false,
|
|
290
|
+
return { marked: false, wasLastActive: false, staleCredentials: false };
|
|
292
291
|
}
|
|
293
292
|
if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
|
|
294
|
-
return { marked: false,
|
|
293
|
+
return { marked: false, wasLastActive: false, staleCredentials: true };
|
|
295
294
|
}
|
|
296
295
|
|
|
297
296
|
await trx.knex.raw("SELECT pg_advisory_xact_lock(?, hashtext(?))", [
|
|
298
297
|
TOKEN_AUTH_DEATH_LOCK_CLASS_ID,
|
|
299
298
|
token.provider,
|
|
300
299
|
]);
|
|
301
|
-
const updated = await trx.knex.raw<{ rows: {
|
|
302
|
-
`
|
|
300
|
+
const updated = await trx.knex.raw<{ rows: { was_last_active: boolean }[] }>(
|
|
301
|
+
`WITH previous AS (
|
|
302
|
+
SELECT active AND NOT EXISTS (
|
|
303
|
+
SELECT 1 FROM tokens peer
|
|
304
|
+
WHERE peer.provider = tokens.provider AND peer.active
|
|
305
|
+
AND NOT peer.reauth_required AND peer.id <> tokens.id
|
|
306
|
+
) AS was_last_active FROM tokens WHERE id = ?
|
|
307
|
+
)
|
|
308
|
+
UPDATE tokens
|
|
303
309
|
SET reauth_required = true,
|
|
304
|
-
active =
|
|
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
|
+
active = false
|
|
310
311
|
WHERE id = ? AND reauth_required = false
|
|
311
|
-
RETURNING
|
|
312
|
-
[id],
|
|
312
|
+
RETURNING (SELECT was_last_active FROM previous) AS was_last_active`,
|
|
313
|
+
[id, id],
|
|
313
314
|
);
|
|
314
315
|
const marked = updated.rows[0];
|
|
315
|
-
if (!marked) return { marked: false,
|
|
316
|
+
if (!marked) return { marked: false, wasLastActive: false, staleCredentials: false };
|
|
316
317
|
|
|
317
|
-
if (marked.
|
|
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
|
-
);
|
|
318
|
+
if (marked.was_last_active) {
|
|
319
|
+
logger.error(`deactivated expired token ${id}: no usable provider tokens remain`);
|
|
322
320
|
}
|
|
323
|
-
return { marked: true,
|
|
321
|
+
return { marked: true, wasLastActive: marked.was_last_active, staleCredentials: false };
|
|
324
322
|
});
|
|
325
323
|
}
|
|
326
324
|
|
|
325
|
+
/** Repair rows retained by the former last-active exception before pool reconciliation. */
|
|
326
|
+
async deactivateExpiredTokens(): Promise<void> {
|
|
327
|
+
await this.getPuri("w")
|
|
328
|
+
.table("tokens")
|
|
329
|
+
.where("reauth_required", true)
|
|
330
|
+
.where("active", true)
|
|
331
|
+
.update({ active: false });
|
|
332
|
+
}
|
|
333
|
+
|
|
327
334
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
328
335
|
async reorder(ids: number[]): Promise<{ done: boolean }> {
|
|
329
336
|
const wdb = this.getPuri("w");
|
|
@@ -8,6 +8,14 @@ import {
|
|
|
8
8
|
} from "./effort";
|
|
9
9
|
|
|
10
10
|
describe("resolveOpenAIEffort", () => {
|
|
11
|
+
it("preserves every Astra Codex catalog effort including ultra", () => {
|
|
12
|
+
for (const effort of OPENAI_EFFORTS) {
|
|
13
|
+
expect(resolveOpenAIEffort("gpt-6-astra", effort)).toBe(effort);
|
|
14
|
+
}
|
|
15
|
+
expect(resolveOpenAIEffort("gpt-6-astra", "none")).toBeUndefined();
|
|
16
|
+
expect(resolveOpenAIEffort("gpt-6-astra", "minimal")).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
11
19
|
it("Codex 카탈로그 어휘는 그대로 통과한다", () => {
|
|
12
20
|
for (const effort of ["low", "medium", "high", "xhigh"]) {
|
|
13
21
|
expect(resolveOpenAIEffort("gpt-5.5", effort)).toBe(effort);
|
|
@@ -4,9 +4,18 @@ import { calculateCostUsd, getModelCosts } from "./model-cost";
|
|
|
4
4
|
|
|
5
5
|
describe("calculateCostUsd", () => {
|
|
6
6
|
it.each([
|
|
7
|
-
[
|
|
8
|
-
[
|
|
9
|
-
|
|
7
|
+
[272_000, 2.77],
|
|
8
|
+
[272_001, 5.51502],
|
|
9
|
+
])("Astra applies the long-context rate only above 272K (%i input)", (inputTokens, expected) => {
|
|
10
|
+
expect(
|
|
11
|
+
calculateCostUsd("openai/gpt-6-astra", { inputTokens, outputTokens: 1_000 }),
|
|
12
|
+
).toBeCloseTo(expected, 10);
|
|
13
|
+
});
|
|
14
|
+
it.each([
|
|
15
|
+
["gpt-6-astra", 10, 50, 1, 12.5],
|
|
16
|
+
["gpt-5.6-sol", 4, 20, 0.4, 5],
|
|
17
|
+
["gpt-5.6-terra", 2, 12, 0.2, 2.5],
|
|
18
|
+
["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25],
|
|
10
19
|
["gpt-5.5", 5, 30, 0.5, undefined],
|
|
11
20
|
["gpt-5.4", 2.5, 15, 0.25, undefined],
|
|
12
21
|
["gpt-5.4-mini", 0.75, 4.5, 0.075, undefined],
|
|
@@ -77,13 +86,15 @@ describe("calculateCostUsd", () => {
|
|
|
77
86
|
expect(getModelCosts("anthropic/claude-sonnet-4-6[1m]")).toBe(
|
|
78
87
|
getModelCosts("claude-sonnet-4-6"),
|
|
79
88
|
);
|
|
89
|
+
expect(getModelCosts("openai/gpt-6-astra")).toBe(getModelCosts("gpt-6-astra"));
|
|
80
90
|
expect(getModelCosts("openai/gpt-5.6-sol")).toBe(getModelCosts("gpt-5.6-sol"));
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
it.each([
|
|
84
|
-
["gpt-
|
|
85
|
-
["gpt-5.6-
|
|
86
|
-
["gpt-5.6-
|
|
94
|
+
["gpt-6-astra", 10, 50, 1, 12.5, 5.6125],
|
|
95
|
+
["gpt-5.6-sol", 4, 20, 0.4, 5, 2.245],
|
|
96
|
+
["gpt-5.6-terra", 2, 12, 0.2, 2.5, 1.3225],
|
|
97
|
+
["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25, 0.13225],
|
|
87
98
|
])(
|
|
88
99
|
"%s applies published input, output, cache-read, and cache-write pricing",
|
|
89
100
|
(model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens, expectedCost) => {
|
|
@@ -106,9 +117,10 @@ describe("calculateCostUsd", () => {
|
|
|
106
117
|
);
|
|
107
118
|
|
|
108
119
|
it.each([
|
|
109
|
-
["gpt-
|
|
110
|
-
["gpt-5.6-
|
|
111
|
-
["gpt-5.6-
|
|
120
|
+
["gpt-6-astra", 2.475],
|
|
121
|
+
["gpt-5.6-sol", 0.99],
|
|
122
|
+
["gpt-5.6-terra", 0.498],
|
|
123
|
+
["gpt-5.6-luna", 0.0498],
|
|
112
124
|
])("%s applies the published long-context surcharge", (model, expectedCost) => {
|
|
113
125
|
expect(
|
|
114
126
|
calculateCostUsd(model, {
|
|
@@ -120,9 +132,10 @@ describe("calculateCostUsd", () => {
|
|
|
120
132
|
});
|
|
121
133
|
|
|
122
134
|
it.each([
|
|
123
|
-
["gpt-
|
|
124
|
-
["gpt-5.6-
|
|
125
|
-
["gpt-5.6-
|
|
135
|
+
["gpt-6-astra", 2.725],
|
|
136
|
+
["gpt-5.6-sol", 1.09],
|
|
137
|
+
["gpt-5.6-terra", 0.548],
|
|
138
|
+
["gpt-5.6-luna", 0.0548],
|
|
126
139
|
])("%s applies the long-context input multiplier to cache writes", (model, expectedCost) => {
|
|
127
140
|
expect(
|
|
128
141
|
calculateCostUsd(model, {
|
|
@@ -34,7 +34,7 @@ export interface ModelCosts {
|
|
|
34
34
|
|
|
35
35
|
// ── OpenAI ─────────────────────────────────────────────────────────
|
|
36
36
|
//
|
|
37
|
-
//
|
|
37
|
+
// Standard pricing verified 2026-09-07: https://developers.openai.com/api/docs/pricing
|
|
38
38
|
// 신모델 출시마다 단가가 바뀌므로(5.2→5.4→5.5) 모델 추가 시 반드시 공식 페이지 재확인해야함
|
|
39
39
|
|
|
40
40
|
// GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini/nano는 공식 표에서 long-context 단가 없음)
|
|
@@ -48,38 +48,52 @@ const LONG_CONTEXT_272K: NonNullable<ModelCosts["longContext"]> = {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
// OpenAI 모델 카탈로그. 단가와 함께 Codex 백엔드가 모델별로 받는 최대 reasoning effort 를 한 곳에 둔다.
|
|
51
|
-
// maxEffort 출처: ~/.codex/models_cache.json 의 supported_reasoning_levels (2026-09-
|
|
51
|
+
// maxEffort 출처: ~/.codex/models_cache.json 의 supported_reasoning_levels (2026-09-07 확인).
|
|
52
52
|
// 미기재 모델은 xhigh 까지만 받는다.
|
|
53
53
|
type OpenAIModelSpec = ModelCosts & { maxEffort?: OpenAIEffort };
|
|
54
54
|
|
|
55
55
|
const OPENAI_COSTS: Record<string, OpenAIModelSpec> = {
|
|
56
|
+
// GPT-6 Astra: Standard API-equivalent pricing, verified 2026-09-07.
|
|
57
|
+
// Codex subscription catalog supports ultra; the public API lists only through max.
|
|
58
|
+
// @see https://developers.openai.com/api/docs/models/gpt-6-astra
|
|
59
|
+
// @see https://developers.openai.com/api/docs/pricing
|
|
60
|
+
"gpt-6-astra": {
|
|
61
|
+
maxEffort: "ultra",
|
|
62
|
+
inputTokens: 10,
|
|
63
|
+
outputTokens: 50,
|
|
64
|
+
cachedInputTokens: 1,
|
|
65
|
+
cacheCreationInputTokens: 12.5,
|
|
66
|
+
longContext: LONG_CONTEXT_272K,
|
|
67
|
+
},
|
|
56
68
|
// GPT-5.6 Sol, Terra, Luna. cache write 단가는 외부 logger/manual usage 입력을 위해
|
|
57
69
|
// 유지한다. 현재 OpenAI 응답 usage 에 cache write 토큰 필드가 없으면 비용은 0으로 계산된다.
|
|
58
70
|
// @see https://developers.openai.com/api/docs/models/gpt-5.6-sol
|
|
59
71
|
// @see https://developers.openai.com/api/docs/models/gpt-5.6-terra
|
|
60
72
|
// @see https://developers.openai.com/api/docs/models/gpt-5.6-luna
|
|
73
|
+
// Sol promotional rates are available at least through 2026-11-21.
|
|
74
|
+
// No confirmed end date/replacement rate: recheck official pricing instead of auto-reverting.
|
|
61
75
|
"gpt-5.6-sol": {
|
|
62
76
|
maxEffort: "ultra",
|
|
63
|
-
inputTokens:
|
|
64
|
-
outputTokens:
|
|
65
|
-
cachedInputTokens: 0.
|
|
66
|
-
cacheCreationInputTokens:
|
|
77
|
+
inputTokens: 4,
|
|
78
|
+
outputTokens: 20,
|
|
79
|
+
cachedInputTokens: 0.4,
|
|
80
|
+
cacheCreationInputTokens: 5,
|
|
67
81
|
longContext: LONG_CONTEXT_272K,
|
|
68
82
|
},
|
|
69
83
|
"gpt-5.6-terra": {
|
|
70
84
|
maxEffort: "ultra",
|
|
71
|
-
inputTokens: 2
|
|
72
|
-
outputTokens:
|
|
73
|
-
cachedInputTokens: 0.
|
|
74
|
-
cacheCreationInputTokens:
|
|
85
|
+
inputTokens: 2,
|
|
86
|
+
outputTokens: 12,
|
|
87
|
+
cachedInputTokens: 0.2,
|
|
88
|
+
cacheCreationInputTokens: 2.5,
|
|
75
89
|
longContext: LONG_CONTEXT_272K,
|
|
76
90
|
},
|
|
77
91
|
"gpt-5.6-luna": {
|
|
78
92
|
maxEffort: "max",
|
|
79
|
-
inputTokens:
|
|
80
|
-
outputTokens:
|
|
81
|
-
cachedInputTokens: 0.
|
|
82
|
-
cacheCreationInputTokens:
|
|
93
|
+
inputTokens: 0.2,
|
|
94
|
+
outputTokens: 1.2,
|
|
95
|
+
cachedInputTokens: 0.02,
|
|
96
|
+
cacheCreationInputTokens: 0.25,
|
|
83
97
|
longContext: LONG_CONTEXT_272K,
|
|
84
98
|
},
|
|
85
99
|
// https://openai.com/index/introducing-gpt-5-5/ (2026-04 출시)
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* MVP 에서는 stream() 만. getRateLimits(), listModels() 는 future.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type ImageGenerationMetadata,
|
|
9
|
+
type ImageGenerationOptions,
|
|
10
|
+
} from "../../../application/qgrid/qgrid.types";
|
|
8
11
|
import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "./provider-types";
|
|
9
12
|
|
|
10
13
|
// qgrid provider 내부 표준 usage. provider 전용 cache write 세부 필드는 여기서 확장해 보존한다.
|
|
@@ -27,6 +30,7 @@ export interface ReuseThreadCoord {
|
|
|
27
30
|
export interface GeneratedImage {
|
|
28
31
|
data: string; // base64 PNG
|
|
29
32
|
revisedPrompt: string | null;
|
|
33
|
+
generation?: ImageGenerationMetadata;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
// Provider/런타임이 요청 모델 대신 다른 모델로 실제 응답을 생성한 이력.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
|
|
3
|
+
import { type ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
|
|
3
4
|
import { type OpenAIEffort } from "../common/effort";
|
|
4
5
|
|
|
5
6
|
export const CHATGPT_CODEX_RESPONSES_URL =
|
|
@@ -170,7 +171,14 @@ export interface OpenAIUsage {
|
|
|
170
171
|
export type OpenAINormalizedEvent =
|
|
171
172
|
| { type: "created"; responseId?: string }
|
|
172
173
|
| { type: "text-delta"; text: string }
|
|
173
|
-
| {
|
|
174
|
+
| {
|
|
175
|
+
type: "image";
|
|
176
|
+
id?: string;
|
|
177
|
+
base64: string;
|
|
178
|
+
mimeType: "image/png";
|
|
179
|
+
revisedPrompt?: string;
|
|
180
|
+
generation?: ImageGenerationMetadata;
|
|
181
|
+
}
|
|
174
182
|
| { type: "output-item"; item: OpenAIResponseItem }
|
|
175
183
|
| { type: "completed"; responseId: string; usage?: OpenAIUsage; model?: string }
|
|
176
184
|
| { type: "error"; error: OpenAIProtocolError };
|