@cartanova/qgrid-cli 2.10.0 → 2.10.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.
Files changed (69) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +4 -14
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +10 -5
  3. package/bundle/dist/application/request-log/request-log.model.js +14 -12
  4. package/bundle/dist/application/request-log-step/request-log-step.model.js +7 -11
  5. package/bundle/dist/application/setting/setting.model.js +13 -27
  6. package/bundle/dist/application/token/token.model.js +52 -47
  7. package/bundle/dist/server-error-handler.js +1 -1
  8. package/bundle/dist/sonamu.config.js +3 -15
  9. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +4 -2
  10. package/bundle/dist/utils/providers/common/model-cost.js +10 -3
  11. package/bundle/src/application/qgrid/conv-routing.test.ts +0 -32
  12. package/bundle/src/application/qgrid/conv-routing.ts +7 -26
  13. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +65 -121
  14. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +12 -6
  15. package/bundle/src/application/qgrid/qgrid.frame.test.ts +10 -24
  16. package/bundle/src/application/request-log/request-log.model.test.ts +37 -2
  17. package/bundle/src/application/request-log/request-log.model.ts +14 -7
  18. package/bundle/src/application/request-log-step/request-log-step.model.ts +3 -15
  19. package/bundle/src/application/setting/setting.constant.test.ts +1 -13
  20. package/bundle/src/application/setting/setting.model.ts +10 -26
  21. package/bundle/src/application/setting/setting.persistence.test.ts +27 -0
  22. package/bundle/src/application/token/token.model.test.ts +86 -19
  23. package/bundle/src/application/token/token.model.ts +59 -44
  24. package/bundle/src/server-error-handler.ts +1 -2
  25. package/bundle/src/sonamu.config.ts +4 -31
  26. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +6 -1
  27. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +5 -2
  28. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +1 -26
  29. package/bundle/src/utils/providers/common/model-cost.test.ts +22 -1
  30. package/bundle/src/utils/providers/common/model-cost.ts +22 -5
  31. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -11
  32. package/bundle/web-dist/client/assets/index-Cq4_ExLO.js +106 -0
  33. package/bundle/web-dist/client/assets/logs-kg1KOMBk.js +1 -0
  34. package/bundle/web-dist/client/assets/{monit-DrYZkisv.js → monit-DmtHTmSM.js} +1 -1
  35. package/bundle/web-dist/client/assets/{routes-C8Y6olae.js → routes-CXotE1OF.js} +1 -1
  36. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +51 -0
  37. package/bundle/web-dist/client/assets/{settings-CdVqtDQ5.js → settings-CfJDz4AC.js} +1 -1
  38. package/bundle/web-dist/client/assets/show-gBpWK4z2.js +2 -0
  39. package/bundle/web-dist/client/assets/{tokens-CxXtdk9o.js → tokens-CbFJjIyC.js} +1 -1
  40. package/bundle/web-dist/client/assets/use-oauth-login-flow-jJo7GV3r.js +1 -0
  41. package/bundle/web-dist/client/assets/{use-update-token-mutation-Dkv_HG-C.js → use-update-token-mutation-D-Y8OySK.js} +1 -1
  42. package/bundle/web-dist/client/index.html +2 -2
  43. package/bundle/web-dist/server/assets/{lazyRouteComponent-DyizoQKi.js → lazyRouteComponent-Bj1cPq6x.js} +2 -2
  44. package/bundle/web-dist/server/assets/{logs-DO8zBdBR.js → logs-DMlOXcDS.js} +20 -12
  45. package/bundle/web-dist/server/assets/{monit-COH4MckI.js → monit-Dg1wZCdx.js} +3 -3
  46. package/bundle/web-dist/server/assets/{plus-C1D8z9o0.js → plus-DVobMrag.js} +1 -1
  47. package/bundle/web-dist/server/assets/{react-dom-PfWvArTX.js → react-dom-BjR1Iaz8.js} +1 -1
  48. package/bundle/web-dist/server/assets/{routes-Btja-741.js → routes-C7lB2Q85.js} +5 -5
  49. package/bundle/web-dist/server/assets/{services.generated-BsKOUSXl.js → services.generated-BM4X3Ecj.js} +102 -6
  50. package/bundle/web-dist/server/assets/{settings-DPcWGvy-.js → settings-BaIBt_Vs.js} +4 -4
  51. package/bundle/web-dist/server/assets/{show-CMXbb3TJ.js → show-CDxGr4rS.js} +9 -8
  52. package/bundle/web-dist/server/assets/{tokens-DAUmTrgO.js → tokens-DJlfeGcx.js} +5 -5
  53. package/bundle/web-dist/server/assets/{use-oauth-login-flow-GsGwqefs.js → use-oauth-login-flow-DYKnG4A2.js} +32 -15
  54. package/bundle/web-dist/server/assets/{use-update-token-mutation-RujWERvj.js → use-update-token-mutation-DqGMopII.js} +1 -1
  55. package/bundle/web-dist/server/entry-server.generated.js +381 -21
  56. package/package.json +1 -1
  57. package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
  58. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -2
  59. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  60. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
  61. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  62. package/skills/qgrid/references/verification-and-debugging.md +24 -13
  63. package/bundle/dist/utils/providers/common/token-picker.js +0 -14
  64. package/bundle/src/utils/providers/common/token-picker.ts +0 -18
  65. package/bundle/web-dist/client/assets/index-BfwrIBHs.js +0 -106
  66. package/bundle/web-dist/client/assets/logs-Brit-LqB.js +0 -1
  67. package/bundle/web-dist/client/assets/services.generated-Bg2XkD-D.js +0 -51
  68. package/bundle/web-dist/client/assets/show-2B2X9MkO.js +0 -2
  69. package/bundle/web-dist/client/assets/use-oauth-login-flow-B9EqnwY9.js +0 -1
@@ -50,7 +50,7 @@ class TokenModelClass extends BaseModelClass<
50
50
 
51
51
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "Token" })
52
52
  async findById<T extends TokenSubsetKey>(subset: T, id: number): Promise<TokenSubsetMapping[T]> {
53
- const { rows } = await this.findMany(subset, { id, num: 1, page: 1 });
53
+ const { rows } = await this.findMany(subset, { id, num: 1, page: 1, queryMode: "list" });
54
54
  if (!rows[0]) {
55
55
  throw new NotFoundException(SD("error.entityNotFound")("Token", id));
56
56
  }
@@ -61,7 +61,12 @@ class TokenModelClass extends BaseModelClass<
61
61
  subset: T,
62
62
  listParams: TokenListParams,
63
63
  ): Promise<TokenSubsetMapping[T] | null> {
64
- const { rows } = await this.findMany(subset, { ...listParams, num: 1, page: 1 });
64
+ const { rows } = await this.findMany(subset, {
65
+ ...listParams,
66
+ num: 1,
67
+ page: 1,
68
+ queryMode: "list",
69
+ });
65
70
  return rows[0] ?? null;
66
71
  }
67
72
 
@@ -78,7 +83,7 @@ class TokenModelClass extends BaseModelClass<
78
83
  ...rawParams,
79
84
  } satisfies TokenListParams;
80
85
 
81
- const { qb, onSubset: _ } = this.getSubsetQueries(subset);
86
+ const { qb } = this.getSubsetQueries(subset);
82
87
 
83
88
  if (params.id) {
84
89
  qb.whereIn("tokens.id", asArray(params.id));
@@ -105,11 +110,7 @@ class TokenModelClass extends BaseModelClass<
105
110
  }
106
111
  }
107
112
 
108
- const enhancers = this.createEnhancers({
109
- A: (row) => ({ ...row }),
110
- });
111
-
112
- return this.executeSubsetQuery({ subset, qb, params, enhancers, debug: false });
113
+ return this.executeSubsetQuery({ subset, qb, params });
113
114
  }
114
115
 
115
116
  async findByAccountIdentifier<T extends TokenSubsetKey>(
@@ -121,13 +122,10 @@ class TokenModelClass extends BaseModelClass<
121
122
  qb.where("tokens.provider", provider);
122
123
  const jsonKey = provider === "anthropic" ? "accountUuid" : "accountId";
123
124
  qb.whereRaw(`tokens.credentials->>'${jsonKey}' = ?`, [accountId]);
124
- const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
125
125
  const result = await this.executeSubsetQuery({
126
126
  subset,
127
127
  qb,
128
- params: { num: 100, page: 1 },
129
- enhancers,
130
- debug: false,
128
+ params: { num: 0, page: 1, queryMode: "list" },
131
129
  });
132
130
  return result.rows;
133
131
  }
@@ -139,13 +137,10 @@ class TokenModelClass extends BaseModelClass<
139
137
  const { qb } = this.getSubsetQueries(subset);
140
138
  qb.where("tokens.active", true);
141
139
  qb.where("tokens.provider", provider);
142
- const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
143
140
  const result = await this.executeSubsetQuery({
144
141
  subset,
145
142
  qb,
146
- params: { num: 100, page: 1 },
147
- enhancers,
148
- debug: false,
143
+ params: { num: 0, page: 1, queryMode: "list" },
149
144
  });
150
145
  return result.rows;
151
146
  }
@@ -159,13 +154,10 @@ class TokenModelClass extends BaseModelClass<
159
154
  qb.where("tokens.active", true);
160
155
  qb.where("tokens.provider", provider);
161
156
  qb.where("tokens.name", name);
162
- const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
163
157
  const result = await this.executeSubsetQuery({
164
158
  subset,
165
159
  qb,
166
- params: { num: 1, page: 1 },
167
- enhancers,
168
- debug: false,
160
+ params: { num: 1, page: 1, queryMode: "list" },
169
161
  });
170
162
  return result.rows[0];
171
163
  }
@@ -175,13 +167,10 @@ class TokenModelClass extends BaseModelClass<
175
167
  const { qb } = this.getSubsetQueries(subset);
176
168
  qb.where("tokens.reauth_required", true);
177
169
  qb.orderBy("tokens.name", "asc");
178
- const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
179
170
  const result = await this.executeSubsetQuery({
180
171
  subset,
181
172
  qb,
182
- params: { num: 100, page: 1 },
183
- enhancers,
184
- debug: false,
173
+ params: { num: 0, page: 1, queryMode: "list" },
185
174
  });
186
175
  return result.rows;
187
176
  }
@@ -189,13 +178,10 @@ class TokenModelClass extends BaseModelClass<
189
178
  async findActive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
190
179
  const { qb } = this.getSubsetQueries(subset);
191
180
  qb.where("tokens.active", true);
192
- const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
193
181
  const result = await this.executeSubsetQuery({
194
182
  subset,
195
183
  qb,
196
- params: { num: 100, page: 1 },
197
- enhancers,
198
- debug: false,
184
+ params: { num: 0, page: 1, queryMode: "list" },
199
185
  });
200
186
  return result.rows;
201
187
  }
@@ -246,24 +232,53 @@ class TokenModelClass extends BaseModelClass<
246
232
  accountId: string | undefined,
247
233
  saveParams: TokenSaveParams,
248
234
  ): Promise<void> {
249
- let keepaliveEnabled = saveParams.keepalive_enabled;
250
- if (accountId) {
251
- const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
252
- if (keepaliveEnabled === undefined && oldEntries.length > 0) {
253
- keepaliveEnabled = oldEntries.some((entry) => entry.keepalive_enabled);
235
+ await this.getPuri("w").transaction(async (trx) => {
236
+ let replacement = saveParams;
237
+ if (accountId) {
238
+ const jsonKey = provider === "anthropic" ? "accountUuid" : "accountId";
239
+ const oldEntries = await trx
240
+ .from("tokens")
241
+ .select({
242
+ id: "id",
243
+ quota_threshold: "quota_threshold",
244
+ weight: "weight",
245
+ ord: "ord",
246
+ keepalive_enabled: "keepalive_enabled",
247
+ })
248
+ .where("provider", provider)
249
+ .whereRaw(`credentials->>'${jsonKey}' = ?`, [accountId])
250
+ .orderBy("id", "asc")
251
+ .forUpdate();
252
+ const first = oldEntries[0];
253
+ if (first) {
254
+ replacement = {
255
+ ...saveParams,
256
+ quota_threshold:
257
+ saveParams.quota_threshold === undefined
258
+ ? first.quota_threshold
259
+ : saveParams.quota_threshold,
260
+ weight: saveParams.weight ?? first.weight,
261
+ ord: saveParams.ord ?? first.ord,
262
+ keepalive_enabled:
263
+ saveParams.keepalive_enabled ?? oldEntries.some((entry) => entry.keepalive_enabled),
264
+ };
265
+ await trx
266
+ .from("tokens")
267
+ .whereIn(
268
+ "id",
269
+ oldEntries.map((entry) => entry.id),
270
+ )
271
+ .delete();
272
+ }
273
+ } else {
274
+ logger.warn(
275
+ `${provider} login without account identifier: dedup skipped for ${saveParams.name ?? "unnamed"}`,
276
+ );
254
277
  }
255
- if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
256
- } else {
257
- logger.warn(
258
- `${provider} login without account identifier: dedup skipped for ${saveParams.name ?? "unnamed"}`,
259
- );
260
- }
261
278
 
262
- await this.save([
263
- keepaliveEnabled === undefined
264
- ? saveParams
265
- : { ...saveParams, keepalive_enabled: keepaliveEnabled },
266
- ]);
279
+ trx.ubRegister("tokens", applyCreateDefaults(replacement));
280
+ await trx.ubUpsert("tokens");
281
+ });
267
282
  }
268
283
 
269
284
  /**
@@ -2,8 +2,7 @@ import { getLogger } from "@logtape/logtape";
2
2
  import { type FastifyReply } from "fastify";
3
3
  import { SoException } from "sonamu";
4
4
 
5
- // dispatcher 기동은 dev0 기준 1~2분 걸린다(워커 25개 × spawn 간격). 그보다 짧게 잡으면
6
- // 재시도가 같은 503 을 다시 받는다.
5
+ // provider 초기화 중 즉시 재요청이 몰리지 않도록 재시도 간격을 안내한다.
7
6
  const RETRY_AFTER_SECONDS = "30";
8
7
 
9
8
  // statusCode 를 신뢰하는 범위를 Sonamu 예외로 한정한다. qgrid 는 프록시라 upstream HTTP
@@ -130,37 +130,10 @@ export default defineConfig({
130
130
  },
131
131
  guardHandler: () => {},
132
132
  cacheControlHandler: (req) => {
133
- switch (req.type) {
134
- case "assets":
135
- // Hash 포함된 파일: 영구 캐시
136
- if (req.path.match(/-[a-f0-9]+\./)) {
137
- return CachePresets.immutable;
138
- }
139
- return CachePresets.longLived;
140
-
141
- case "api":
142
- // GET 요청만 캐싱 고려
143
- if (req.method === "GET") {
144
- // 특정 경로는 짧은 캐시
145
- if (req.path.startsWith("/api/static-data")) {
146
- return CachePresets.shortLived;
147
- }
148
- if (req.path.startsWith("/api/terms")) {
149
- return CachePresets.mediumLived;
150
- }
151
- }
152
- // 기본: 캐시 없음
153
- return CachePresets.noCache;
154
-
155
- case "ssr":
156
- // SSR 페이지: 10초 캐시
157
- return CachePresets.ssr;
158
-
159
- case "csr":
160
- // CSR fallback (index.html): 1분 캐시
161
- return CachePresets.shortLived;
162
- }
163
- return CachePresets.noCache;
133
+ if (req.type === "api") return CachePresets.noCache;
134
+ if (req.type === "csr") return CachePresets.shortLived;
135
+ // Sonamu serves fingerprinted assets with its default immutable policy.
136
+ return undefined;
164
137
  },
165
138
  },
166
139
  cache: {
@@ -36,6 +36,7 @@ describe("Anthropic 1M context policy", () => {
36
36
  expect(supports1MContext("claude-opus-5-5")).toBe(true);
37
37
  expect(supports1MContext("claude-sonnet-5")).toBe(true);
38
38
  expect(supports1MContext("claude-sonnet-5-5")).toBe(true);
39
+ expect(supports1MContext("claude-haiku-5-5")).toBe(true);
39
40
  expect(supports1MContext("claude-sonnet-4-6")).toBe(true);
40
41
  expect(supports1MContext("claude-opus-4-6")).toBe(true);
41
42
  expect(supports1MContext("claude-opus-4-8")).toBe(true);
@@ -53,6 +54,7 @@ describe("Anthropic 1M context policy", () => {
53
54
  expect(needsCli1mSuffix("claude-opus-5-5")).toBe(false);
54
55
  expect(needsCli1mSuffix("claude-sonnet-5")).toBe(false);
55
56
  expect(needsCli1mSuffix("claude-sonnet-5-5")).toBe(false);
57
+ expect(needsCli1mSuffix("claude-haiku-5-5")).toBe(false);
56
58
  expect(needsCli1mSuffix("claude-sonnet-4-6")).toBe(true);
57
59
  expect(needsCli1mSuffix("claude-opus-4-6")).toBe(true);
58
60
  expect(needsCli1mSuffix("claude-opus-4-8")).toBe(false);
@@ -82,7 +84,7 @@ describe("Anthropic 1M context policy", () => {
82
84
  });
83
85
 
84
86
  describe("Anthropic thinking policy", () => {
85
- it("Fable 5/5.1, Opus 5, Opus 5.5, Sonnet 5.5 는 adaptive thinking 을 보존한다", () => {
87
+ it("Fable 5/5.1, Opus 5, Opus 5.5, Sonnet 5.5, Haiku 5.5 는 adaptive thinking 을 보존한다", () => {
86
88
  expect(usesAdaptiveThinking("claude-fable-5-1")).toBe(true);
87
89
  expect(usesAdaptiveThinking("anthropic/claude-fable-5-1")).toBe(true);
88
90
  expect(usesAdaptiveThinking("claude-fable-5")).toBe(true);
@@ -93,6 +95,9 @@ describe("Anthropic thinking policy", () => {
93
95
  expect(usesAdaptiveThinking("anthropic/claude-opus-5-5[1m]")).toBe(true);
94
96
  expect(usesAdaptiveThinking("claude-sonnet-5-5")).toBe(true);
95
97
  expect(usesAdaptiveThinking("anthropic/claude-sonnet-5-5[1m]")).toBe(true);
98
+ expect(usesAdaptiveThinking("claude-haiku-5-5")).toBe(true);
99
+ expect(usesAdaptiveThinking("anthropic/claude-haiku-5-5")).toBe(true);
100
+ expect(usesAdaptiveThinking("claude-haiku-4-5")).toBe(false);
96
101
  expect(usesAdaptiveThinking("claude-sonnet-5")).toBe(false);
97
102
  expect(usesAdaptiveThinking("claude-opus-4-8")).toBe(false);
98
103
  });
@@ -51,14 +51,16 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
51
51
  "claude-opus-5-5",
52
52
  "claude-sonnet-5-5",
53
53
  "claude-sonnet-5",
54
+ "claude-haiku-5-5",
54
55
  "claude-sonnet-4-6",
55
56
  "claude-opus-4-6",
56
57
  "claude-opus-4-8",
57
58
  ]);
58
59
 
59
60
  const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
60
- // Fable 5/5.1, Opus 5.5, Sonnet 5.5 는 thinking disabled 를 거부한다(Fable: CLI 카탈로그 `rejects_disabled_thinking`,
61
- // Opus 5.5: 공식 사양 "can't be turned off", 2026-09-22 출시. Sonnet 5.5: API `{type: "disabled"}` 400, CLI 카탈로그 `rejects_disabled_thinking`, 2026-09-29 출시). Opus 5 는
61
+ // Fable 5/5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5 는 thinking disabled 를 거부한다(Fable: CLI 카탈로그 `rejects_disabled_thinking`,
62
+ // Opus 5.5: 공식 사양 "can't be turned off", 2026-09-22 출시. Sonnet 5.5: API `{type: "disabled"}` 400, CLI 카탈로그 `rejects_disabled_thinking`, 2026-09-28 출시.
63
+ // Haiku 5.5: API 는 high 이하에서만 disabled 허용·xhigh/max 400, Claude Code 2.1.293 카탈로그는 `rejects_disabled_thinking`, 2026-10-07 출시). Opus 5 는
62
64
  // disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
63
65
  // 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
64
66
  // tool/XML 출력 오염도 피한다.
@@ -68,6 +70,7 @@ const ADAPTIVE_THINKING_MODELS = new Set([
68
70
  "claude-opus-5",
69
71
  "claude-opus-5-5",
70
72
  "claude-sonnet-5-5",
73
+ "claude-haiku-5-5",
71
74
  ]);
72
75
 
73
76
  // Fable 계열: 구독 플랜에서 usage credits 가 필요한 최상위 티어. 구독 usage API 의
@@ -153,24 +153,6 @@ describe("AnthropicDispatcher", () => {
153
153
  expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
154
154
  });
155
155
 
156
- it("reuse/reuseInput 이 실려 와도 무시하고 coldInput/coldHistory 로 실행한다", async () => {
157
- const d = new AnthropicDispatcher();
158
- d.onTokenAdded(1, "tok-A", creds(), null, 1);
159
-
160
- await d.generate(
161
- baseReq({
162
- coldHistory: [{ type: "message", role: "assistant", content: [] }],
163
- reuse: { workerId: 1, threadId: "S1", epoch: 0 },
164
- reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
165
- }),
166
- );
167
-
168
- const call = firstRunRequest();
169
- expect(call).not.toHaveProperty("resumeSessionId");
170
- expect(call.coldHistory).toBeDefined();
171
- expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
172
- });
173
-
174
156
  it("structured(outputSchema): jsonSchema 로 직렬화되어 전달", async () => {
175
157
  const d = new AnthropicDispatcher();
176
158
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -245,14 +227,7 @@ describe("AnthropicDispatcher", () => {
245
227
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
246
228
  runClaudeSessionMock.mockRejectedValueOnce(new Error("closed without result"));
247
229
 
248
- await expect(
249
- d.generate(
250
- baseReq({
251
- reuse: { workerId: 1, threadId: "S1", epoch: 0 },
252
- reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
253
- }),
254
- ),
255
- ).rejects.toThrow("closed without result");
230
+ await expect(d.generate(baseReq())).rejects.toThrow("closed without result");
256
231
  expect(runClaudeSessionMock).toHaveBeenCalledTimes(1);
257
232
  });
258
233
 
@@ -11,6 +11,25 @@ describe("calculateCostUsd", () => {
11
11
  calculateCostUsd("openai/gpt-6-astra", { inputTokens, outputTokens: 1_000 }),
12
12
  ).toBeCloseTo(expected, 10);
13
13
  });
14
+ it.each([
15
+ [100_000, 0.0105],
16
+ [100_001, 0.0525005],
17
+ ])("Haiku 5.5 applies the over-100K prompt tier to the whole request (%i input)", (inputTokens, expected) => {
18
+ expect(
19
+ calculateCostUsd("anthropic/claude-haiku-5-5", { inputTokens, outputTokens: 1_000 }),
20
+ ).toBeCloseTo(expected, 10);
21
+ });
22
+ it("Haiku 5.5 over-100K tier multiplies cache read and cache write rates by 5x", () => {
23
+ // 50K uncached × $0.50 + 1K output × $2.50 + 200K cache read × $0.05 + 50K 1h cache write × $1.
24
+ expect(
25
+ calculateCostUsd("anthropic/claude-haiku-5-5", {
26
+ inputTokens: 300_000,
27
+ outputTokens: 1_000,
28
+ cachedInputTokens: 200_000,
29
+ cacheCreationInputTokens: 50_000,
30
+ }),
31
+ ).toBeCloseTo(0.0875, 10);
32
+ });
14
33
  it.each([
15
34
  ["gpt-6-astra", 10, 50, 1, 12.5],
16
35
  ["gpt-6.1-sol", 2, 10, 0.1, 2.5],
@@ -60,7 +79,9 @@ describe("calculateCostUsd", () => {
60
79
  // Opus 5.5 는 cache read 만 0.05x 특례($0.20), cache write 배율은 표준(5m $5 / 1h $8).
61
80
  ["claude-opus-5-5", 4, 20, 0.2, 8],
62
81
  ["claude-sonnet-5", 2, 10, 0.2, 4],
63
- ["claude-sonnet-5-5", 2, 10, 0.2, 4],
82
+ // Sonnet 5.5 는 2026-10-07 부터 cache read 0.05x($0.10), cache write 배율은 표준(5m $2.50 / 1h $4).
83
+ ["claude-sonnet-5-5", 2, 10, 0.1, 4],
84
+ ["claude-haiku-5-5", 0.1, 0.5, 0.01, 0.2],
64
85
  ])(
65
86
  "%s official Anthropic rates for 5m/1h cache writes",
66
87
  (model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens) => {
@@ -157,7 +157,7 @@ const OPENAI_COSTS: Record<string, OpenAIModelSpec> = {
157
157
  function anthropicCosts(
158
158
  inputTokens: number,
159
159
  outputTokens: number,
160
- overrides: Pick<Partial<ModelCosts>, "cachedInputTokens"> = {},
160
+ overrides: Pick<Partial<ModelCosts>, "cachedInputTokens" | "longContext"> = {},
161
161
  ): ModelCosts {
162
162
  return {
163
163
  inputTokens,
@@ -169,10 +169,13 @@ function anthropicCosts(
169
169
  cacheCreationInputTokens: inputTokens * 2,
170
170
  cacheCreationInputTokens5m: inputTokens * 1.25,
171
171
  cacheCreationInputTokens1h: inputTokens * 2,
172
+ // Haiku 5.5 처럼 프롬프트 길이 티어가 있는 모델만 long-context 배율을 가진다.
173
+ ...(overrides.longContext ? { longContext: overrides.longContext } : {}),
172
174
  };
173
175
  }
174
176
 
175
- // 가격 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-09-23 확인, 기존 모델 단가 변동 없음)
177
+ // 가격 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-10-08 확인. 2026-10-07 에 Sonnet 5.5
178
+ // cache read 가 $0.20→$0.10(0.05x)으로 인하됐고, 그 외 기존 모델 단가는 변동 없음)
176
179
  const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
177
180
  // Fable 5.1 (2026-09-01 출시): input/output 은 Fable 5 와 같고 cache read 만 0.025x($0.25) 특례.
178
181
  // @see https://platform.claude.com/docs/en/models/fable-5-1/overview
@@ -200,9 +203,23 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
200
203
  // Sonnet 5 의 introductory $2/$10 이 정식 단가로 확정됐다. 2026-09-01 에 예정됐던 $3/$15 인상은
201
204
  // 취소됐으므로 날짜 분기 없이 고정 단가로 계산한다.
202
205
  "claude-sonnet-5": anthropicCosts(2, 10),
203
- // Sonnet 5.5 (2026-09-29 출시): Sonnet 5 와 같은 $2/$10, cache read 는 표준 0.1x($0.20).
204
- // @see https://platform.claude.com/docs/en/about-claude/pricing
205
- "claude-sonnet-5-5": anthropicCosts(2, 10),
206
+ // Sonnet 5.5 (2026-09-28 출시): Sonnet 5 와 같은 $2/$10. cache read 는 출시 시 표준 0.1x($0.20)였다가
207
+ // 2026-10-07 Haiku 5.5 출시와 함께 Opus 5.5 와 같은 0.05x($0.10)로 인하됐다(API 릴리스 노트). 비용은 요청 시점
208
+ // 단가로 기록되므로 날짜 분기 없이 현행 단가만 둔다. cache write 는 표준 배율($2.50/$4).
209
+ // @see https://platform.claude.com/docs/en/release-notes/overview (2026-10-07)
210
+ "claude-sonnet-5-5": anthropicCosts(2, 10, { cachedInputTokens: 0.1 }),
211
+ // Haiku 5.5 (2026-10-07 출시): 프롬프트 길이 티어 단가. 100K 이하 $0.10/$0.50, cache read 0.1x($0.01),
212
+ // cache write 5m $0.125 / 1h $0.20. 입력이 100K 를 넘으면 input/cache read/cache write/output 모두 5x
213
+ // ($0.50/$0.05/$0.625·$1/$2.50)가 요청 전체에 적용된다. 1M 컨텍스트 모델 중 유일하게 길이별 단가를 쓴다.
214
+ // @see https://platform.claude.com/docs/en/models/haiku-5-5/overview
215
+ "claude-haiku-5-5": anthropicCosts(0.1, 0.5, {
216
+ longContext: {
217
+ threshold: 100_000,
218
+ inputMultiplier: 5,
219
+ cachedInputMultiplier: 5,
220
+ outputMultiplier: 5,
221
+ },
222
+ }),
206
223
  };
207
224
 
208
225
  // gpt-5.3-codex-spark 는 research preview 로 공식 token 단가가 아직 final 이 아니다.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ProviderDispatcher — provider 별 LLM 요청 실행 인터페이스.
3
3
  *
4
- * MVP 에서는 stream() 만. getRateLimits(), listModels() 는 future.
4
+ * Provider 실행 결과와 stream callback의 공통 계약.
5
5
  */
6
6
 
7
7
  import {
@@ -18,8 +18,7 @@ export type ProviderTokenUsageBreakdown = TokenUsageBreakdown & {
18
18
  cacheCreationInputTokens1h?: number;
19
19
  };
20
20
 
21
- // thread 재사용 라우팅 좌표. 상위(qgrid.dispatcher)에서 conv 핸들 검증을 통과한 경우에만 전달.
22
- // dispatcher 는 이 좌표가 가리키는 worker 의 기존 thread 에 turn 만 실행한다.
21
+ // 공개 thread 좌표의 provider 결과 부분. 이름은 호환성을 위해 유지한다.
23
22
  export interface ReuseThreadCoord {
24
23
  workerId: number;
25
24
  threadId: string;
@@ -61,9 +60,8 @@ export interface GenerateRequest {
61
60
  // provider 실행 제한(ms). Queue selection 이후의 active provider request 전체에 적용한다.
62
61
  timeoutMs?: number;
63
62
  abortSignal?: AbortSignal;
64
- // 첫 turn / 재사용 폴백 시 보낼 input — 전체 prompt. 항상 설정.
63
+ // 매 turn에 전달하는 현재 input과 전체 history.
65
64
  coldInput: Array<UserInput>;
66
- // 첫 turn / 폴백 시 inject 할 전체 history.
67
65
  coldHistory?: Array<JsonValue>;
68
66
  // Provider-neutral cache affinity hints. Direct OpenAI currently receives these while still
69
67
  // replaying coldInput + coldHistory on every turn; a dispatcher may use them for token choice
@@ -72,13 +70,8 @@ export interface GenerateRequest {
72
70
  preferredTokenId?: number;
73
71
  // 사람이 지정한 exact target. Cache affinity 선호와 달리 부적격 시 다른 토큰으로 대체하지 않는다.
74
72
  requirePreferredToken?: boolean;
75
- // 재사용 좌표 + delta input. 둘은 한 쌍 — 검증 통과 시에만 설정한다.
76
- // dispatcher 가 worker/thread 생존을 재검증해 성공하면 reuseInput(delta)을, 실패하면
77
- // coldInput + coldHistory 로 폴백한다(전체 history 로 문맥 복구).
78
- reuse?: ReuseThreadCoord;
79
- reuseInput?: Array<UserInput>;
80
73
  // OpenAI image_generation tool 을 켠다(OpenAI 경로 전용, opt-in).
81
- // 이 플래그가 있으면 dispatcher 는 항상 cold thread 로 실행하고 재사용 라우팅을 건너뛴다.
74
+ // 이 플래그가 있으면 OpenAI prompt-cache affinity 라우팅을 건너뛴다.
82
75
  imageGeneration?: boolean | "auto";
83
76
  imageGenerationOptions?: ImageGenerationOptions;
84
77
  }