@cartanova/qgrid-cli 2.9.1 → 2.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +4 -3
  2. package/bundle/dist/application/qgrid/token-death.js +6 -22
  3. package/bundle/dist/application/qgrid/token-subscriber.js +2 -1
  4. package/bundle/dist/application/token/token.model.js +22 -17
  5. package/bundle/dist/startup-migrations.js +8 -5
  6. package/bundle/dist/utils/providers/anthropic/claude-session.js +3 -2
  7. package/bundle/dist/utils/providers/common/effort.js +51 -0
  8. package/bundle/dist/utils/providers/common/model-cost.js +29 -14
  9. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
  10. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -3
  11. package/bundle/src/application/qgrid/qgrid.frame.test.ts +20 -0
  12. package/bundle/src/application/qgrid/qgrid.frame.ts +3 -2
  13. package/bundle/src/application/qgrid/token-death.test.ts +11 -10
  14. package/bundle/src/application/qgrid/token-death.ts +9 -21
  15. package/bundle/src/application/qgrid/token-subscriber.test.ts +5 -1
  16. package/bundle/src/application/qgrid/token-subscriber.ts +1 -0
  17. package/bundle/src/application/token/token.model.test.ts +24 -5
  18. package/bundle/src/application/token/token.model.ts +30 -23
  19. package/bundle/src/startup-migrations.test.ts +19 -15
  20. package/bundle/src/startup-migrations.ts +11 -3
  21. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +1 -1
  22. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +13 -0
  23. package/bundle/src/utils/providers/anthropic/claude-session.ts +3 -1
  24. package/bundle/src/utils/providers/common/effort.test.ts +61 -0
  25. package/bundle/src/utils/providers/common/effort.ts +53 -0
  26. package/bundle/src/utils/providers/common/model-cost.test.ts +25 -12
  27. package/bundle/src/utils/providers/common/model-cost.ts +43 -14
  28. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +3 -1
  29. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +21 -0
  30. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +5 -4
  31. package/bundle/web-dist/client/assets/{index-DiioTXCk.js → index-fogJvajr.js} +3 -3
  32. package/bundle/web-dist/client/assets/{logs-Cmh7iNUl.js → logs-CsY2cqvt.js} +1 -1
  33. package/bundle/web-dist/client/assets/{monit-BxqRbzYv.js → monit-ChgaLEcD.js} +1 -1
  34. package/bundle/web-dist/client/assets/{routes-DrNdqoD1.js → routes-BvyiTnOZ.js} +2 -2
  35. package/bundle/web-dist/client/assets/{settings-ROuRMHwU.js → settings-ZR2J9rWb.js} +1 -1
  36. package/bundle/web-dist/client/assets/{show-Ct97ts6E.js → show-CjwO4-So.js} +1 -1
  37. package/bundle/web-dist/client/assets/{tokens-Cw2eWi9I.js → tokens-Bx-hG1gm.js} +1 -1
  38. package/bundle/web-dist/client/assets/{use-oauth-login-flow-D4-P9yOJ.js → use-oauth-login-flow-Bqax_icS.js} +1 -1
  39. package/bundle/web-dist/client/index.html +1 -1
  40. package/bundle/web-dist/server/assets/{routes-CxMHXfdH.js → routes-B408iqh1.js} +29 -14
  41. package/bundle/web-dist/server/entry-server.generated.js +3 -2
  42. package/package.json +1 -1
  43. package/skills/qgrid/SKILL.md +1 -0
  44. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -2
  45. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -0
  46. package/skills/qgrid/references/openai-codex-runtime.md +71 -0
  47. package/skills/qgrid/references/provider-runtime-differences.md +2 -0
  48. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
  49. package/skills/qgrid/references/token-auth-quota-lifecycle.md +3 -2
@@ -24,7 +24,7 @@ export async function deactivateAuthDeadToken(
24
24
  token: DeadTokenRef,
25
25
  reasonCode: string,
26
26
  ): Promise<boolean> {
27
- const { marked, keptAsLastActive, staleCredentials } = await TokenModel.markReauthRequired(
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: [mention, token.provider, reasonCode, "재로그인이 필요합니다"]
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 토큰 기준으로 재동기화.
@@ -213,7 +213,25 @@ describe("TokenModel.markReauthRequired", () => {
213
213
  await Sonamu.initForTesting();
214
214
  });
215
215
 
216
- it("serializes provider deaths, preserves one active token, and rejects stale credentials", async () => {
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
- keptAsLastActive: false,
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(1);
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
- keptAsLastActive: false,
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
- keptAsLastActive: false,
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
- * 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
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; keptAsLastActive: boolean; staleCredentials: 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, keptAsLastActive: false, staleCredentials: false };
290
+ return { marked: false, wasLastActive: false, staleCredentials: false };
292
291
  }
293
292
  if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
294
- return { marked: false, keptAsLastActive: false, staleCredentials: true };
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: { active: boolean }[] }>(
302
- `UPDATE tokens
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 = 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
+ active = false
310
311
  WHERE id = ? AND reauth_required = false
311
- RETURNING active`,
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, keptAsLastActive: false, staleCredentials: false };
316
+ if (!marked) return { marked: false, wasLastActive: false, staleCredentials: false };
316
317
 
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
- );
318
+ if (marked.was_last_active) {
319
+ logger.error(`deactivated expired token ${id}: no usable provider tokens remain`);
322
320
  }
323
- return { marked: true, keptAsLastActive: marked.active, staleCredentials: false };
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");
@@ -4,20 +4,25 @@ import path from "node:path";
4
4
 
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
 
7
- const { latest } = vi.hoisted(() => ({ latest: vi.fn() }));
8
-
9
- vi.mock("./application/token/token.model", () => ({
10
- TokenModel: {
11
- getDB: () => ({ migrate: { latest } }),
12
- },
13
- }));
14
-
15
7
  import {
16
8
  createStartupMigrationSource,
17
9
  resolveStartupMigrationDirs,
18
10
  runRequiredMigrations,
11
+ type StartupMigrationDeps,
19
12
  } from "./startup-migrations";
20
13
 
14
+ // 실제 TokenModel/process.exit 대신 주입한다. 전체 스위트에서 모듈 목이 새면 진짜 exit(1) 이
15
+ // vitest worker 를 죽여 이 파일의 나머지 테스트가 조용히 pending 으로 빠지기 때문이다.
16
+ function makeDeps() {
17
+ const latest = vi.fn();
18
+ const exitError = new Error("process.exit called");
19
+ const exit = vi.fn((code: number) => {
20
+ throw Object.assign(exitError, { code });
21
+ }) as unknown as StartupMigrationDeps["exit"];
22
+ const deps: StartupMigrationDeps = { getKnex: () => ({ migrate: { latest } }) as never, exit };
23
+ return { latest, exit, exitError, deps };
24
+ }
25
+
21
26
  const tempRoots: string[] = [];
22
27
 
23
28
  async function makeDirs() {
@@ -114,24 +119,23 @@ describe("createStartupMigrationSource", () => {
114
119
  describe("required startup migrations", () => {
115
120
  it("knex 기본 directory 대신 migrationSource 로 latest 를 실행한다", async () => {
116
121
  const dirs = await makeDirs();
122
+ const { latest, exit, deps } = makeDeps();
117
123
  latest.mockResolvedValueOnce([3, ["20260901163614_alter_tokens_add1_alter1.ts"]]);
118
124
 
119
- await runRequiredMigrations(dirs);
125
+ await runRequiredMigrations(dirs, deps);
120
126
 
121
127
  expect(latest).toHaveBeenCalledWith({ migrationSource: expect.any(Object) });
122
128
  const call = latest.mock.calls.at(-1)?.[0] as { migrationSource: unknown; directory?: string };
123
129
  expect(call.directory).toBeUndefined();
130
+ expect(exit).not.toHaveBeenCalled();
124
131
  });
125
132
 
126
133
  it("terminates with a failure status when migration fails", async () => {
127
134
  const dirs = await makeDirs();
128
- const failure = new Error("tokens.weight migration failed");
129
- const exit = vi.spyOn(process, "exit").mockImplementation(() => {
130
- throw failure;
131
- });
132
- latest.mockRejectedValueOnce(failure);
135
+ const { latest, exit, exitError, deps } = makeDeps();
136
+ latest.mockRejectedValueOnce(new Error("tokens.weight migration failed"));
133
137
 
134
- await expect(runRequiredMigrations(dirs)).rejects.toBe(failure);
138
+ await expect(runRequiredMigrations(dirs, deps)).rejects.toBe(exitError);
135
139
 
136
140
  expect(exit).toHaveBeenCalledWith(1);
137
141
  });
@@ -50,7 +50,7 @@ export function createStartupMigrationSource(
50
50
  const entries = await readdir(dirs.sourceDir);
51
51
  return entries
52
52
  .filter((name) => MIGRATION_FILE_RE.test(name) && !name.endsWith(".d.ts"))
53
- .sort();
53
+ .toSorted();
54
54
  },
55
55
  getMigrationName(name) {
56
56
  return name;
@@ -68,12 +68,20 @@ export function createStartupMigrationSource(
68
68
  };
69
69
  }
70
70
 
71
+ export type StartupMigrationDeps = {
72
+ getKnex: () => Pick<Knex, "migrate">;
73
+ exit: (code: number) => never;
74
+ };
75
+
76
+ // 테스트는 deps 를 주입한다. 모듈 목으로 TokenModel 을 바꾸는 방식은 전체 스위트에서 목이 새면
77
+ // 실제 process.exit(1) 이 worker 를 죽여 남은 테스트가 조용히 pending 으로 빠진다.
71
78
  export async function runRequiredMigrations(
72
79
  dirs: StartupMigrationDirs = resolveStartupMigrationDirs(),
80
+ deps: StartupMigrationDeps = { getKnex: () => TokenModel.getDB("w"), exit: process.exit },
73
81
  ): Promise<void> {
74
82
  const log = getLogger(["qgrid", "startup"]);
75
83
  try {
76
- const knex = TokenModel.getDB("w");
84
+ const knex = deps.getKnex();
77
85
  const [batch, migrations] = await knex.migrate.latest({
78
86
  migrationSource: createStartupMigrationSource(dirs),
79
87
  });
@@ -82,6 +90,6 @@ export async function runRequiredMigrations(
82
90
  }
83
91
  } catch (error) {
84
92
  log.error(`migration failed: ${(error as Error).message}`);
85
- process.exit(1);
93
+ deps.exit(1);
86
94
  }
87
95
  }
@@ -25,7 +25,7 @@ const {
25
25
  }));
26
26
 
27
27
  vi.mock("@logtape/logtape", () => ({
28
- getLogger: () => ({ info: loggerInfoMock, warn: loggerWarnMock }),
28
+ getLogger: () => ({ debug: vi.fn(), info: loggerInfoMock, warn: loggerWarnMock }),
29
29
  }));
30
30
 
31
31
  vi.mock("./claude-session", async (importActual) => {
@@ -402,3 +402,16 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
402
402
  expect(parsed[0]!.uuid).not.toBe(parsed[1]!.uuid);
403
403
  });
404
404
  });
405
+
406
+ describe("effort 어휘 해석 (Claude Code --effort 기준)", () => {
407
+ it("허용값은 그대로, OpenAI 전용·옛 어휘는 조용히 qgrid 기본(low)으로 바꾼다", () => {
408
+ const effortOf = (effort: string) => {
409
+ const args = buildClaudeArgs({ model: "claude-opus-5", sessionId: "u", effort });
410
+ return args[args.indexOf("--effort") + 1];
411
+ };
412
+ expect(effortOf("max")).toBe("max");
413
+ expect(effortOf("xhigh")).toBe("xhigh");
414
+ expect(effortOf("ultra")).toBe("low");
415
+ expect(effortOf("minimal")).toBe("low");
416
+ });
417
+ });
@@ -18,6 +18,7 @@ import { spawn } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
 
21
+ import { resolveAnthropicEffort } from "../common/effort";
21
22
  import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "../common/provider-types";
22
23
  import {
23
24
  ARGV_SAFE_MAX_UTF8_BYTES,
@@ -245,8 +246,9 @@ export function buildClaudeArgs(opts: {
245
246
  ...systemArgs,
246
247
  // Fable 5 는 adaptive thinking 이 필수이고, Opus 5 는 공식 기본 adaptive 동작을 보존한다.
247
248
  ...(usesAdaptiveThinking(opts.model) ? [] : ["--thinking", "disabled"]),
249
+ // Claude Code 어휘 밖의 effort(OpenAI 전용 ultra, 옛 none/minimal 등)는 조용히 버리고 qgrid 기본을 쓴다.
248
250
  "--effort",
249
- opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
251
+ resolveAnthropicEffort(opts.effort) ?? ANTHROPIC_DEFAULT_EFFORT,
250
252
  "--disable-slash-commands",
251
253
  "--session-id",
252
254
  opts.sessionId,
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ANTHROPIC_EFFORTS,
5
+ OPENAI_EFFORTS,
6
+ resolveAnthropicEffort,
7
+ resolveOpenAIEffort,
8
+ } from "./effort";
9
+
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
+
19
+ it("Codex 카탈로그 어휘는 그대로 통과한다", () => {
20
+ for (const effort of ["low", "medium", "high", "xhigh"]) {
21
+ expect(resolveOpenAIEffort("gpt-5.5", effort)).toBe(effort);
22
+ }
23
+ expect(resolveOpenAIEffort("gpt-5.6-terra", "max")).toBe("max");
24
+ expect(resolveOpenAIEffort("gpt-5.6-terra", "ultra")).toBe("ultra");
25
+ expect(resolveOpenAIEffort("gpt-5.6-luna", "max")).toBe("max");
26
+ });
27
+
28
+ it("공개 API 어휘(none/minimal)와 다른 provider 어휘, 오타는 조용히 미지정으로 바꾼다", () => {
29
+ expect(resolveOpenAIEffort("gpt-5.6-terra", "none")).toBeUndefined();
30
+ expect(resolveOpenAIEffort("gpt-5.6-terra", "minimal")).toBeUndefined();
31
+ expect(resolveOpenAIEffort("gpt-5.6-terra", "hgih")).toBeUndefined();
32
+ expect(resolveOpenAIEffort("gpt-5.6-terra", undefined)).toBeUndefined();
33
+ });
34
+
35
+ it("모델 상한을 넘는 값은 클램프하지 않고 미지정으로 바꾼다", () => {
36
+ expect(resolveOpenAIEffort("gpt-5.5", "max")).toBeUndefined();
37
+ expect(resolveOpenAIEffort("gpt-5.4", "ultra")).toBeUndefined();
38
+ expect(resolveOpenAIEffort("gpt-5.6-luna", "ultra")).toBeUndefined();
39
+ expect(resolveOpenAIEffort("gpt-5.3-codex-spark", "xhigh")).toBe("xhigh");
40
+ });
41
+
42
+ it("어휘 순서가 클램프 판정의 기준이다", () => {
43
+ expect([...OPENAI_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]);
44
+ expect([...ANTHROPIC_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"]);
45
+ });
46
+ });
47
+
48
+ describe("resolveAnthropicEffort", () => {
49
+ it("Claude Code --effort 허용값은 그대로 통과한다", () => {
50
+ for (const effort of ANTHROPIC_EFFORTS) {
51
+ expect(resolveAnthropicEffort(effort)).toBe(effort);
52
+ }
53
+ });
54
+
55
+ it("OpenAI 전용 값과 모르는 값은 조용히 미지정으로 바꿔 qgrid 기본(low)이 적용되게 한다", () => {
56
+ expect(resolveAnthropicEffort("ultra")).toBeUndefined();
57
+ expect(resolveAnthropicEffort("none")).toBeUndefined();
58
+ expect(resolveAnthropicEffort("minimal")).toBeUndefined();
59
+ expect(resolveAnthropicEffort(undefined)).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,53 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+
3
+ import { openaiModelMaxEffort } from "./model-cost";
4
+
5
+ const logger = getLogger(["qgrid", "effort"]);
6
+
7
+ /**
8
+ * provider 별 reasoning effort 어휘.
9
+ *
10
+ * SDK 가 보낸 effort 문자열을 그대로 provider 에 넘기지 않는다. provider 마다 받는 값이 다르고,
11
+ * 모르는 값을 받았을 때의 반응도 다르다(Codex 백엔드는 400 이 예상되고, Claude Code 는 경고만
12
+ * 남기고 모델 기본 effort 로 실행한다). 각 provider dispatcher 가 요청을 만들 때 여기서 판정해,
13
+ * 맞지 않는 값은 오류 없이 버리고 "미지정" 과 같게 취급한다.
14
+ */
15
+
16
+ // Codex 구독 백엔드 모델 카탈로그(~/.codex/models_cache.json) 의 supported_reasoning_levels 합집합.
17
+ // 공개 OpenAI API 의 `none`/`minimal` 은 이 경로에 없다. 모델별 상한은 model-cost.ts 의 카탈로그에 있다.
18
+ export const OPENAI_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"] as const;
19
+ export type OpenAIEffort = (typeof OPENAI_EFFORTS)[number];
20
+
21
+ // Claude Code `--effort` 허용값. 모델별 상한(xhigh_effort / max_effort capability)은 Claude Code 가
22
+ // 스스로 처리하므로 qgrid 는 provider 집합만 검사한다.
23
+ export const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
24
+ export type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number];
25
+
26
+ // 어휘 안에 있고 상한(있다면) 이하인 값만 통과시킨다. 나머지는 debug 로그 후 미지정.
27
+ function resolveWithin<T extends string>(
28
+ levels: readonly T[],
29
+ effort: string | undefined,
30
+ max: T | undefined,
31
+ target: string,
32
+ ): T | undefined {
33
+ if (effort === undefined) return undefined;
34
+ const rank = levels.indexOf(effort as T);
35
+ if (rank < 0) {
36
+ logger.debug(`ignoring effort "${effort}" for ${target}: not in ${levels.join("/")}`);
37
+ return undefined;
38
+ }
39
+ if (max !== undefined && rank > levels.indexOf(max)) {
40
+ logger.debug(`ignoring effort "${effort}" for ${target}: model supports up to "${max}"`);
41
+ return undefined;
42
+ }
43
+ return effort as T;
44
+ }
45
+
46
+ /** canonical OpenAI model(`openai/` prefix 제거 후) 기준으로 effort 를 판정한다. */
47
+ export function resolveOpenAIEffort(model: string, effort?: string): OpenAIEffort | undefined {
48
+ return resolveWithin(OPENAI_EFFORTS, effort, openaiModelMaxEffort(model), `openai/${model}`);
49
+ }
50
+
51
+ export function resolveAnthropicEffort(effort?: string): AnthropicEffort | undefined {
52
+ return resolveWithin(ANTHROPIC_EFFORTS, effort, undefined, "anthropic");
53
+ }
@@ -4,9 +4,18 @@ import { calculateCostUsd, getModelCosts } from "./model-cost";
4
4
 
5
5
  describe("calculateCostUsd", () => {
6
6
  it.each([
7
- ["gpt-5.6-sol", 5, 30, 0.5, 6.25],
8
- ["gpt-5.6-terra", 2.5, 15, 0.25, 3.125],
9
- ["gpt-5.6-luna", 1, 6, 0.1, 1.25],
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-5.6-sol", 5, 30, 0.5, 6.25, 3.30625],
85
- ["gpt-5.6-terra", 2.5, 15, 0.25, 3.125, 1.653125],
86
- ["gpt-5.6-luna", 1, 6, 0.1, 1.25, 0.66125],
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-5.6-sol", 1.245],
110
- ["gpt-5.6-terra", 0.6225],
111
- ["gpt-5.6-luna", 0.249],
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-5.6-sol", 1.37],
124
- ["gpt-5.6-terra", 0.685],
125
- ["gpt-5.6-luna", 0.274],
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, {