@cartanova/qgrid-cli 2.4.7 → 2.4.8

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 (73) hide show
  1. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +15 -1
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -4
  3. package/bundle/dist/application/qgrid/qgrid.frame.js +20 -1
  4. package/bundle/dist/application/qgrid/qgrid.types.js +38 -2
  5. package/bundle/dist/application/request-log/request-log.model.js +49 -22
  6. package/bundle/dist/application/request-log-step/request-log-step.types.js +8 -1
  7. package/bundle/dist/application/sonamu.generated.js +37 -1
  8. package/bundle/dist/application/sonamu.generated.sso.js +25 -1
  9. package/bundle/dist/i18n/sd.generated.js +13 -1
  10. package/bundle/dist/migrations/20260718180824_alter_request_logs_add5_alter1.js +20 -0
  11. package/bundle/dist/migrations/20260718180825_alter_request_log_steps_add7_alter1.js +22 -0
  12. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +8 -2
  13. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +6 -3
  14. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -7
  15. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +117 -7
  16. package/bundle/dist/utils/providers/common/model-cost.js +45 -98
  17. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +68 -0
  18. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +14 -0
  19. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +81 -0
  20. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -9
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +19 -0
  22. package/bundle/src/application/qgrid/qgrid.types.ts +36 -0
  23. package/bundle/src/application/qgrid/tool-emulation.test.ts +1 -0
  24. package/bundle/src/application/request-log/request-log.entity.json +42 -0
  25. package/bundle/src/application/request-log/request-log.model.test.ts +58 -0
  26. package/bundle/src/application/request-log/request-log.model.ts +107 -18
  27. package/bundle/src/application/request-log-step/request-log-step.entity.json +54 -0
  28. package/bundle/src/application/request-log-step/request-log-step.types.ts +7 -0
  29. package/bundle/src/application/sonamu.generated.http +26 -0
  30. package/bundle/src/application/sonamu.generated.sso.ts +24 -0
  31. package/bundle/src/application/sonamu.generated.ts +48 -0
  32. package/bundle/src/i18n/sd.generated.ts +12 -0
  33. package/bundle/src/migrations/20260718180824_alter_request_logs_add5_alter1.ts +25 -0
  34. package/bundle/src/migrations/20260718180825_alter_request_log_steps_add7_alter1.ts +29 -0
  35. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +15 -0
  36. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +11 -2
  37. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +7 -2
  38. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +26 -0
  39. package/bundle/src/utils/providers/anthropic/claude-session.ts +16 -4
  40. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +131 -0
  41. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +194 -11
  42. package/bundle/src/utils/providers/common/model-cost.test.ts +136 -2
  43. package/bundle/src/utils/providers/common/model-cost.ts +76 -99
  44. package/bundle/src/utils/providers/common/provider-dispatcher.ts +18 -0
  45. package/bundle/web-dist/client/assets/{index-CYnw2kNG.js → index-CmuPjbCl.js} +3 -3
  46. package/bundle/web-dist/client/assets/{logs-CnXIN-jp.js → logs-cXOsDli3.js} +1 -1
  47. package/bundle/web-dist/client/assets/{monit-DRuDGJj4.js → monit-B5Edhvy-.js} +1 -1
  48. package/bundle/web-dist/client/assets/{routes-CuyNd-ya.js → routes-UPWScORY.js} +1 -1
  49. package/bundle/web-dist/client/assets/{sd.generated-D2Y9QeiN.js → sd.generated-DYuKzBY6.js} +1 -1
  50. package/bundle/web-dist/client/assets/{services.generated-CmxXl220.js → services.generated-D_SkWOo-.js} +1 -1
  51. package/bundle/web-dist/client/assets/show-pRp-eZIK.js +30 -0
  52. package/bundle/web-dist/client/assets/{tokens-DxFeKC8Q.js → tokens-DJlttxCM.js} +1 -1
  53. package/bundle/web-dist/client/assets/{use-update-token-mutation-6J1T9U2-.js → use-update-token-mutation-CpFGLWUl.js} +1 -1
  54. package/bundle/web-dist/client/index.html +2 -2
  55. package/bundle/web-dist/server/assets/{lazyRouteComponent-BlfOh9S3.js → lazyRouteComponent-Cu6IkL8A.js} +1 -1
  56. package/bundle/web-dist/server/assets/{logs-DYly4ipQ.js → logs-Dx2knN2T.js} +10 -7
  57. package/bundle/web-dist/server/assets/{monit-CYyY2S2f.js → monit-DU6PLatf.js} +2 -2
  58. package/bundle/web-dist/server/assets/{routes-Dqu3osXt.js → routes-CTco1oJZ.js} +3 -3
  59. package/bundle/web-dist/server/assets/{sd.generated-CE6D5clm.js → sd.generated-qzuRe35X.js} +12 -0
  60. package/bundle/web-dist/server/assets/{services.generated-DyZUzXiq.js → services.generated-C8q1M41a.js} +1 -1
  61. package/bundle/web-dist/server/assets/{show-BDul8xLb.js → show-B3q8_GDh.js} +47 -4
  62. package/bundle/web-dist/server/assets/{tokens-fzMW4G74.js → tokens-Bvjn15h9.js} +3 -3
  63. package/bundle/web-dist/server/assets/{use-update-token-mutation-BejUW5sw.js → use-update-token-mutation-BlZo6swF.js} +1 -1
  64. package/bundle/web-dist/server/entry-server.generated.js +8 -8
  65. package/package.json +1 -1
  66. package/skills/qgrid/SKILL.md +4 -0
  67. package/skills/qgrid/references/ai-sdk-provider-contract.md +30 -2
  68. package/skills/qgrid/references/anthropic-claude-code-runtime.md +43 -5
  69. package/skills/qgrid/references/decision-rationale.md +2 -0
  70. package/skills/qgrid/references/prompt-cache-and-usage.md +4 -1
  71. package/skills/qgrid/references/request-log-run-lifecycle.md +6 -2
  72. package/skills/qgrid/references/sonamu-api-web-flow.md +13 -0
  73. package/bundle/web-dist/client/assets/show-EleAOzJv.js +0 -30
@@ -30,6 +30,7 @@ import {
30
30
  assertSupportedOneMillionSuffix,
31
31
  canonicalAnthropicModel,
32
32
  needsCli1mSuffix,
33
+ requiresAdaptiveThinking,
33
34
  supports1MContext,
34
35
  } from "./anthropic-constants";
35
36
  import {
@@ -145,6 +146,18 @@ export function oneMillionEnv(supported: boolean): { CLAUDE_CODE_DISABLE_1M_CONT
145
146
  return supported ? {} : { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" };
146
147
  }
147
148
 
149
+ export function thinkingEnv(model: string): {
150
+ CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING?: "1";
151
+ MAX_THINKING_TOKENS?: "0";
152
+ } {
153
+ return requiresAdaptiveThinking(model)
154
+ ? {}
155
+ : {
156
+ CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
157
+ MAX_THINKING_TOKENS: "0",
158
+ };
159
+ }
160
+
148
161
  // SON-495: structured output retry 를 1 로 고정한다. CC 가 attempt 를 reject 하고 retry 하면
149
162
  // `{rejected}{accepted}` 누적이 AI-SDK 의 JSON.parse 를 깨뜨리므로, retry 자체를 막아 그 경로를
150
163
  // 원천 차단한다. `0` 은 CC query loop(`callsThisQuery >= maxRetries`)가 첫 attempt emit 전에
@@ -211,8 +224,8 @@ export function buildClaudeArgs(opts: {
211
224
  // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
212
225
  // 크기에 따라 --system-prompt(작음) / --system-prompt-file(큼)로 분기(systemArgs).
213
226
  ...systemArgs,
214
- "--thinking",
215
- "disabled",
227
+ // Fable 5 는 adaptive thinking 이 항상 켜져 있어 disabled 를 거부한다.
228
+ ...(requiresAdaptiveThinking(opts.model) ? [] : ["--thinking", "disabled"]),
216
229
  "--effort",
217
230
  opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
218
231
  "--disable-slash-commands",
@@ -291,14 +304,13 @@ export function runClaudeSession(
291
304
  CLAUDE_CODE_OAUTH_TOKEN: req.token,
292
305
  CLAUDE_CONFIG_DIR: configDir,
293
306
  CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
294
- CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
307
+ ...thinkingEnv(model),
295
308
  ...oneMillionEnv(supportsOneMillion),
296
309
  CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
297
310
  CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
298
311
  CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
299
312
  CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
300
313
  CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
301
- MAX_THINKING_TOKENS: "0",
302
314
  // structured output 일 때만 retry 를 고정한다(SON-495). text 모드는 영향 없음.
303
315
  ...(useStructured ? structuredOutputRetriesEnv() : {}),
304
316
  },
@@ -340,6 +340,10 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
340
340
  input_tokens: 10,
341
341
  cache_creation_input_tokens: 2274,
342
342
  cache_read_input_tokens: 14853,
343
+ cache_creation: {
344
+ ephemeral_5m_input_tokens: 274,
345
+ ephemeral_1h_input_tokens: 2000,
346
+ },
343
347
  output_tokens: 111,
344
348
  },
345
349
  }),
@@ -349,6 +353,8 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
349
353
  inputTokens: 10 + 2274 + 14853,
350
354
  cachedInputTokens: 14853,
351
355
  cacheCreationInputTokens: 2274,
356
+ cacheCreationInputTokens5m: 274,
357
+ cacheCreationInputTokens1h: 2000,
352
358
  outputTokens: 111,
353
359
  reasoningOutputTokens: 0,
354
360
  });
@@ -392,6 +398,131 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
392
398
  expect(ok2.result!.isError).toBe(false);
393
399
  });
394
400
 
401
+ it("Fable refusal fallback: Opus 응답은 성공으로 유지하고 실제 serving model을 보존", () => {
402
+ const { result } = runLines([
403
+ JSON.stringify({
404
+ type: "system",
405
+ subtype: "model_refusal_fallback",
406
+ trigger: "refusal",
407
+ originalModel: "claude-fable-5",
408
+ fallbackModel: "claude-opus-4-8",
409
+ apiRefusalCategory: "cyber",
410
+ apiRefusalExplanation: "classifier declined the request",
411
+ }),
412
+ JSON.stringify({
413
+ type: "assistant",
414
+ message: {
415
+ model: "claude-opus-4-8",
416
+ stop_reason: "end_turn",
417
+ content: [{ type: "text", text: "safe answer" }],
418
+ usage: {
419
+ iterations: [
420
+ { type: "message", model: "claude-fable-5", input_tokens: 100 },
421
+ {
422
+ type: "fallback_message",
423
+ model: "claude-opus-4-8",
424
+ input_tokens: 100,
425
+ output_tokens: 20,
426
+ },
427
+ ],
428
+ },
429
+ },
430
+ }),
431
+ JSON.stringify({
432
+ type: "result",
433
+ subtype: "success",
434
+ result: "safe answer",
435
+ usage: { input_tokens: 100, output_tokens: 20 },
436
+ total_cost_usd: 0.003,
437
+ }),
438
+ ]);
439
+
440
+ expect(result).toMatchObject({
441
+ isError: false,
442
+ stopReason: "end_turn",
443
+ servedModel: "claude-opus-4-8",
444
+ modelFallbacks: [
445
+ {
446
+ trigger: "refusal",
447
+ fromModel: "claude-fable-5",
448
+ toModel: "claude-opus-4-8",
449
+ category: "cyber",
450
+ explanation: "classifier declined the request",
451
+ },
452
+ ],
453
+ });
454
+ });
455
+
456
+ it("Fable final refusal: success envelope라도 fallback이 없으면 에러", () => {
457
+ const { result } = runLines([
458
+ JSON.stringify({
459
+ type: "assistant",
460
+ message: {
461
+ model: "claude-fable-5",
462
+ stop_reason: "refusal",
463
+ stop_details: { type: "refusal", category: "bio", explanation: "declined" },
464
+ content: [],
465
+ },
466
+ }),
467
+ JSON.stringify({
468
+ type: "system",
469
+ subtype: "model_refusal_no_fallback",
470
+ trigger: "refusal",
471
+ originalModel: "claude-fable-5",
472
+ apiRefusalCategory: "bio",
473
+ apiRefusalExplanation: "declined",
474
+ }),
475
+ JSON.stringify({
476
+ type: "result",
477
+ subtype: "success",
478
+ result: "",
479
+ usage: { input_tokens: 412, output_tokens: 0 },
480
+ total_cost_usd: 0,
481
+ }),
482
+ ]);
483
+
484
+ expect(result).toMatchObject({
485
+ isError: true,
486
+ stopReason: "refusal",
487
+ servedModel: "claude-fable-5",
488
+ refusal: { category: "bio", explanation: "declined" },
489
+ });
490
+ });
491
+
492
+ it("usage.iterations만 있어도 Fable → Opus fallback을 복원", () => {
493
+ const { result } = runLines([
494
+ JSON.stringify({
495
+ type: "assistant",
496
+ message: {
497
+ model: "claude-opus-4-8",
498
+ stop_reason: "end_turn",
499
+ content: [{ type: "text", text: "answer" }],
500
+ usage: {
501
+ iterations: [
502
+ { type: "message", model: "claude-fable-5" },
503
+ { type: "fallback_message", model: "claude-opus-4-8" },
504
+ ],
505
+ },
506
+ },
507
+ }),
508
+ JSON.stringify({
509
+ type: "result",
510
+ subtype: "success",
511
+ result: "answer",
512
+ usage: { input_tokens: 10, output_tokens: 2 },
513
+ }),
514
+ ]);
515
+
516
+ expect(result!.servedModel).toBe("claude-opus-4-8");
517
+ expect(result!.modelFallbacks).toEqual([
518
+ {
519
+ trigger: "refusal",
520
+ fromModel: "claude-fable-5",
521
+ toModel: "claude-opus-4-8",
522
+ },
523
+ ]);
524
+ });
525
+
395
526
  it("quota: result text가 'You've hit'로 시작 → quotaExhausted true", () => {
396
527
  const { result } = runLines([
397
528
  JSON.stringify({ type: "result", result: "You've hit your limit", usage: {} }),
@@ -10,7 +10,10 @@
10
10
 
11
11
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
12
12
  import { type UserInput } from "../../../codex-protocol/v2/UserInput";
13
- import { type ProviderTokenUsageBreakdown } from "../common/provider-dispatcher";
13
+ import {
14
+ type ModelFallback,
15
+ type ProviderTokenUsageBreakdown,
16
+ } from "../common/provider-dispatcher";
14
17
 
15
18
  // ── 입력 어댑터 ────────────────────────────────────────────────────
16
19
 
@@ -122,6 +125,14 @@ interface ClaudeUsage {
122
125
  output_tokens?: number;
123
126
  cache_creation_input_tokens?: number;
124
127
  cache_read_input_tokens?: number;
128
+ cache_creation?: {
129
+ ephemeral_5m_input_tokens?: number;
130
+ ephemeral_1h_input_tokens?: number;
131
+ };
132
+ iterations?: Array<{
133
+ type?: string;
134
+ model?: string;
135
+ }>;
125
136
  }
126
137
 
127
138
  export interface ClaudeStreamResult {
@@ -137,10 +148,26 @@ export interface ClaudeStreamResult {
137
148
  // isError 판정 사유를 에러 메시지에 드러내기 위해 보존한다(SON-495).
138
149
  subtype?: string;
139
150
  terminalReason?: string;
151
+ // 실제 최종 응답을 만든 모델. Fable refusal fallback 때 requested model 과 다르다.
152
+ servedModel?: string;
153
+ stopReason?: string;
154
+ refusal?: {
155
+ category?: string;
156
+ explanation?: string;
157
+ };
158
+ modelFallbacks?: Array<ModelFallback>;
140
159
  }
141
160
 
142
161
  export interface ClaudeStreamJsonState {
143
162
  structuredOutputText?: string;
163
+ servedModel?: string;
164
+ stopReason?: string;
165
+ refusal?: {
166
+ category?: string;
167
+ explanation?: string;
168
+ };
169
+ modelFallbacks?: Array<ModelFallback>;
170
+ refusalWithoutFallback?: boolean;
144
171
  }
145
172
 
146
173
  // Anthropic usage 4 카테고리 → TokenUsageBreakdown.
@@ -154,11 +181,15 @@ function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
154
181
  const cacheRead = u.cache_read_input_tokens ?? 0;
155
182
  const output = u.output_tokens ?? 0;
156
183
  const totalInput = input + cacheCreate + cacheRead;
184
+ const cacheCreate5m = u.cache_creation?.ephemeral_5m_input_tokens;
185
+ const cacheCreate1h = u.cache_creation?.ephemeral_1h_input_tokens;
157
186
  return {
158
187
  totalTokens: totalInput + output,
159
188
  inputTokens: totalInput,
160
189
  cachedInputTokens: cacheRead,
161
190
  cacheCreationInputTokens: cacheCreate,
191
+ ...(typeof cacheCreate5m === "number" ? { cacheCreationInputTokens5m: cacheCreate5m } : {}),
192
+ ...(typeof cacheCreate1h === "number" ? { cacheCreationInputTokens1h: cacheCreate1h } : {}),
162
193
  outputTokens: output,
163
194
  reasoningOutputTokens: 0,
164
195
  };
@@ -190,6 +221,91 @@ function structuredOutputToolUseText(j: ResponseItem): string | undefined {
190
221
  return undefined;
191
222
  }
192
223
 
224
+ function canonicalEventModel(model: string): string {
225
+ const withoutProvider = model.startsWith("anthropic/") ? model.slice("anthropic/".length) : model;
226
+ return withoutProvider.replace(/\[1m\]$/, "");
227
+ }
228
+
229
+ function optionalString(value: JsonValue | undefined): string | undefined {
230
+ return typeof value === "string" && value.length > 0 ? value : undefined;
231
+ }
232
+
233
+ function recordFallback(state: ClaudeStreamJsonState | undefined, fallback: ModelFallback): void {
234
+ if (!state) return;
235
+ const normalized: ModelFallback = {
236
+ ...fallback,
237
+ fromModel: canonicalEventModel(fallback.fromModel),
238
+ toModel: canonicalEventModel(fallback.toModel),
239
+ };
240
+ const existing = state.modelFallbacks?.find(
241
+ (item) =>
242
+ item.trigger === normalized.trigger &&
243
+ item.fromModel === normalized.fromModel &&
244
+ item.toModel === normalized.toModel,
245
+ );
246
+ if (existing) {
247
+ existing.category ??= normalized.category;
248
+ existing.explanation ??= normalized.explanation;
249
+ } else {
250
+ (state.modelFallbacks ??= []).push(normalized);
251
+ }
252
+ state.servedModel = normalized.toModel;
253
+ state.refusalWithoutFallback = false;
254
+ }
255
+
256
+ function updateRoutingFromUsage(
257
+ usage: ResponseItem | null,
258
+ state: ClaudeStreamJsonState | undefined,
259
+ ): void {
260
+ if (!state || !usage || !Array.isArray(usage.iterations)) return;
261
+
262
+ let previousModel: string | undefined;
263
+ for (const raw of usage.iterations) {
264
+ const iteration = asObject(raw);
265
+ if (!iteration) continue;
266
+ const model = optionalString(iteration.model);
267
+ const type = optionalString(iteration.type);
268
+ if (!model) continue;
269
+ const canonicalModel = canonicalEventModel(model);
270
+ if (type === "fallback_message" && previousModel) {
271
+ recordFallback(state, {
272
+ trigger: "refusal",
273
+ fromModel: previousModel,
274
+ toModel: canonicalModel,
275
+ });
276
+ }
277
+ previousModel = canonicalModel;
278
+ state.servedModel = canonicalModel;
279
+ }
280
+ }
281
+
282
+ function updateRoutingFromMessage(
283
+ message: ResponseItem | null,
284
+ state: ClaudeStreamJsonState | undefined,
285
+ ): void {
286
+ if (!state || !message) return;
287
+ const model = optionalString(message.model);
288
+ const canonicalModel = model ? canonicalEventModel(model) : undefined;
289
+ if (canonicalModel) state.servedModel = canonicalModel;
290
+ const stopReason = optionalString(message.stop_reason);
291
+ if (stopReason) {
292
+ state.stopReason = stopReason;
293
+ if (stopReason === "refusal") state.refusalWithoutFallback = true;
294
+ else if (state.modelFallbacks?.some((fallback) => fallback.toModel === canonicalModel)) {
295
+ state.refusalWithoutFallback = false;
296
+ }
297
+ }
298
+
299
+ const stopDetails = asObject(message.stop_details);
300
+ if (stopDetails?.type === "refusal" || stopReason === "refusal") {
301
+ state.refusal = {
302
+ category: optionalString(stopDetails?.category),
303
+ explanation: optionalString(stopDetails?.explanation),
304
+ };
305
+ }
306
+ updateRoutingFromUsage(asObject(message.usage), state);
307
+ }
308
+
193
309
  // Processes one Claude stdout JSONL line. Returns final result on result event.
194
310
  export function handleStreamJsonLine(
195
311
  line: string,
@@ -210,6 +326,16 @@ export function handleStreamJsonLine(
210
326
 
211
327
  if (j.type === "stream_event") {
212
328
  const event = asObject(j.event);
329
+ if (event?.type === "message_start") {
330
+ updateRoutingFromMessage(asObject(event.message), state);
331
+ } else if (event?.type === "message_delta") {
332
+ const delta = asObject(event.delta);
333
+ const stopReason = optionalString(delta?.stop_reason);
334
+ if (state && stopReason) {
335
+ state.stopReason = stopReason;
336
+ if (stopReason === "refusal") state.refusalWithoutFallback = true;
337
+ }
338
+ }
213
339
  if (event && event.type === "content_block_delta") {
214
340
  const delta = asObject(event.delta);
215
341
  if (delta) {
@@ -227,14 +353,55 @@ export function handleStreamJsonLine(
227
353
  return null;
228
354
  }
229
355
 
230
- if (
231
- j.type === "assistant" &&
232
- structuredOutput &&
233
- state &&
234
- state.structuredOutputText === undefined
235
- ) {
236
- const text = structuredOutputToolUseText(j);
237
- if (text !== undefined) state.structuredOutputText = text;
356
+ if (j.type === "system") {
357
+ const subtype = optionalString(j.subtype);
358
+ if (subtype === "model_refusal_fallback") {
359
+ const fromModel = optionalString(j.originalModel) ?? optionalString(j.original_model);
360
+ const toModel = optionalString(j.fallbackModel) ?? optionalString(j.fallback_model);
361
+ if (fromModel && toModel) {
362
+ recordFallback(state, {
363
+ trigger: "refusal",
364
+ fromModel,
365
+ toModel,
366
+ category:
367
+ optionalString(j.apiRefusalCategory) ??
368
+ optionalString(j.api_refusal_category) ??
369
+ optionalString(j.refusalCategory) ??
370
+ optionalString(j.refusal_category),
371
+ explanation:
372
+ optionalString(j.apiRefusalExplanation) ??
373
+ optionalString(j.api_refusal_explanation) ??
374
+ optionalString(j.refusalExplanation) ??
375
+ optionalString(j.refusal_explanation),
376
+ });
377
+ }
378
+ } else if (subtype === "model_refusal_no_fallback" && state) {
379
+ const originalModel = optionalString(j.originalModel) ?? optionalString(j.original_model);
380
+ if (originalModel) state.servedModel = canonicalEventModel(originalModel);
381
+ state.stopReason = "refusal";
382
+ state.refusalWithoutFallback = true;
383
+ state.refusal = {
384
+ category:
385
+ optionalString(j.apiRefusalCategory) ??
386
+ optionalString(j.api_refusal_category) ??
387
+ optionalString(j.refusalCategory) ??
388
+ optionalString(j.refusal_category),
389
+ explanation:
390
+ optionalString(j.apiRefusalExplanation) ??
391
+ optionalString(j.api_refusal_explanation) ??
392
+ optionalString(j.refusalExplanation) ??
393
+ optionalString(j.refusal_explanation),
394
+ };
395
+ }
396
+ return null;
397
+ }
398
+
399
+ if (j.type === "assistant") {
400
+ updateRoutingFromMessage(asObject(j.message), state);
401
+ if (structuredOutput && state && state.structuredOutputText === undefined) {
402
+ const text = structuredOutputToolUseText(j);
403
+ if (text !== undefined) state.structuredOutputText = text;
404
+ }
238
405
  return null;
239
406
  }
240
407
 
@@ -249,7 +416,18 @@ export function handleStreamJsonLine(
249
416
  text = stripCodeFence(typeof j.result === "string" ? j.result : "");
250
417
  }
251
418
 
252
- const usage = toUsageBreakdown((asObject(j.usage) ?? {}) as ClaudeUsage);
419
+ const rawUsage = asObject(j.usage) ?? {};
420
+ updateRoutingFromUsage(rawUsage, state);
421
+ const resultModel = optionalString(j.model);
422
+ if (state && resultModel && !state.servedModel) {
423
+ state.servedModel = canonicalEventModel(resultModel);
424
+ }
425
+ const resultStopReason = optionalString(j.stop_reason);
426
+ if (state && resultStopReason) {
427
+ state.stopReason = resultStopReason;
428
+ if (resultStopReason === "refusal") state.refusalWithoutFallback = true;
429
+ }
430
+ const usage = toUsageBreakdown(rawUsage as ClaudeUsage);
253
431
  const quotaExhausted = text.startsWith("You've hit");
254
432
  const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
255
433
  const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
@@ -264,7 +442,8 @@ export function handleStreamJsonLine(
264
442
  const isError =
265
443
  j.is_error === true ||
266
444
  j.terminal_reason === "model_error" ||
267
- (subtype !== undefined && subtype !== "success");
445
+ (subtype !== undefined && subtype !== "success") ||
446
+ state?.refusalWithoutFallback === true;
268
447
 
269
448
  return {
270
449
  text,
@@ -275,6 +454,10 @@ export function handleStreamJsonLine(
275
454
  isError,
276
455
  subtype,
277
456
  terminalReason,
457
+ servedModel: state?.servedModel,
458
+ stopReason: state?.stopReason,
459
+ refusal: state?.refusal,
460
+ modelFallbacks: state?.modelFallbacks,
278
461
  };
279
462
  }
280
463
 
@@ -3,6 +3,86 @@ import { describe, expect, it } from "vitest";
3
3
  import { calculateCostUsd, getModelCosts } from "./model-cost";
4
4
 
5
5
  describe("calculateCostUsd", () => {
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],
10
+ ["gpt-5.5", 5, 30, 0.5, undefined],
11
+ ["gpt-5.4", 2.5, 15, 0.25, undefined],
12
+ ["gpt-5.4-mini", 0.75, 4.5, 0.075, undefined],
13
+ ["gpt-5.3-codex", 1.75, 14, 0.175, undefined],
14
+ ["gpt-5.2", 1.75, 14, 0.175, undefined],
15
+ ])(
16
+ "%s official OpenAI rates",
17
+ (model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens) => {
18
+ expect(getModelCosts(model)).toEqual(
19
+ expect.objectContaining({
20
+ inputTokens,
21
+ outputTokens,
22
+ cachedInputTokens,
23
+ ...(cacheCreationInputTokens === undefined ? {} : { cacheCreationInputTokens }),
24
+ }),
25
+ );
26
+ if (cacheCreationInputTokens === undefined) {
27
+ expect(getModelCosts(model).cacheCreationInputTokens).toBeUndefined();
28
+ }
29
+ },
30
+ );
31
+
32
+ it.each([
33
+ ["claude-fable-5", 10, 50, 1, 20],
34
+ ["claude-haiku-4-5", 1, 5, 0.1, 2],
35
+ ["claude-sonnet-4", 3, 15, 0.3, 6],
36
+ ["claude-sonnet-4-5", 3, 15, 0.3, 6],
37
+ ["claude-sonnet-4-6", 3, 15, 0.3, 6],
38
+ ["claude-sonnet-4-7", 3, 15, 0.3, 6],
39
+ ["claude-opus-4", 15, 75, 1.5, 30],
40
+ ["claude-opus-4-1", 15, 75, 1.5, 30],
41
+ ["claude-opus-4-5", 5, 25, 0.5, 10],
42
+ ["claude-opus-4-6", 5, 25, 0.5, 10],
43
+ ["claude-opus-4-7", 5, 25, 0.5, 10],
44
+ ["claude-opus-4-8", 5, 25, 0.5, 10],
45
+ ])(
46
+ "%s official Anthropic rates for 5m/1h cache writes",
47
+ (model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens) => {
48
+ expect(getModelCosts(model)).toMatchObject({
49
+ inputTokens,
50
+ outputTokens,
51
+ cachedInputTokens,
52
+ cacheCreationInputTokens,
53
+ cacheCreationInputTokens5m: inputTokens * 1.25,
54
+ cacheCreationInputTokens1h: inputTokens * 2,
55
+ });
56
+ },
57
+ );
58
+
59
+ it("Claude Sonnet 5 introductory pricing switches to standard pricing on 2026-09-01", () => {
60
+ expect(getModelCosts("claude-sonnet-5", Date.UTC(2026, 7, 31))).toEqual({
61
+ inputTokens: 2,
62
+ outputTokens: 10,
63
+ cachedInputTokens: 0.2,
64
+ cacheCreationInputTokens: 4,
65
+ cacheCreationInputTokens5m: 2.5,
66
+ cacheCreationInputTokens1h: 4,
67
+ });
68
+ expect(getModelCosts("claude-sonnet-5", Date.UTC(2026, 8, 1))).toEqual({
69
+ inputTokens: 3,
70
+ outputTokens: 15,
71
+ cachedInputTokens: 0.3,
72
+ cacheCreationInputTokens: 6,
73
+ cacheCreationInputTokens5m: 3.75,
74
+ cacheCreationInputTokens1h: 6,
75
+ });
76
+ });
77
+
78
+ it("provider prefix 와 [1m] suffix 를 제거한 canonical model 로 가격을 찾는다", () => {
79
+ expect(getModelCosts("anthropic/claude-fable-5")).toBe(getModelCosts("claude-fable-5"));
80
+ expect(getModelCosts("anthropic/claude-sonnet-4-6[1m]")).toBe(
81
+ getModelCosts("claude-sonnet-4-6"),
82
+ );
83
+ expect(getModelCosts("openai/gpt-5.6-sol")).toBe(getModelCosts("gpt-5.6-sol"));
84
+ });
85
+
6
86
  it.each([
7
87
  ["gpt-5.6-sol", 5, 30, 0.5, 6.25, 3.30625],
8
88
  ["gpt-5.6-terra", 2.5, 15, 0.25, 3.125, 1.653125],
@@ -68,7 +148,7 @@ describe("calculateCostUsd", () => {
68
148
  expect(cost).toBeCloseTo(0.0054012, 10);
69
149
  });
70
150
 
71
- it("Anthropic cache creation 은 Claude Code 기본인 5분 write 단가를 적용한다", () => {
151
+ it("Anthropic cache creation 은 subscription Claude Code 기본인 1시간 write 단가를 적용한다", () => {
72
152
  const cost = calculateCostUsd("claude-sonnet-4-6", {
73
153
  inputTokens: 1_992,
74
154
  outputTokens: 187,
@@ -76,7 +156,61 @@ describe("calculateCostUsd", () => {
76
156
  cacheCreationInputTokens: 1_068,
77
157
  });
78
158
 
79
- expect(cost).toBeCloseTo(0.009582, 10);
159
+ expect(cost).toBeCloseTo(0.011985, 10);
160
+ });
161
+
162
+ it("Anthropic cache creation 의 5분/1시간 token breakdown 을 각각 계산한다", () => {
163
+ const cost = calculateCostUsd("claude-sonnet-4-6", {
164
+ inputTokens: 100_000,
165
+ outputTokens: 0,
166
+ cachedInputTokens: 0,
167
+ cacheCreationInputTokens: 80_000,
168
+ cacheCreationInputTokens5m: 30_000,
169
+ cacheCreationInputTokens1h: 50_000,
170
+ });
171
+
172
+ expect(cost).toBeCloseTo(0.4725, 10);
173
+ });
174
+
175
+ it("Fable input/output/cache read/1h cache write 단가를 각각 적용한다", () => {
176
+ expect(
177
+ calculateCostUsd("claude-fable-5", {
178
+ inputTokens: 100_000,
179
+ outputTokens: 100_000,
180
+ cachedInputTokens: 50_000,
181
+ cacheCreationInputTokens: 25_000,
182
+ }),
183
+ ).toBeCloseTo(5.8, 10);
184
+ });
185
+
186
+ it("Fable 의 5분/1시간 cache write 단가도 구분한다", () => {
187
+ expect(
188
+ calculateCostUsd("claude-fable-5", {
189
+ inputTokens: 100_000,
190
+ outputTokens: 100_000,
191
+ cachedInputTokens: 50_000,
192
+ cacheCreationInputTokens: 25_000,
193
+ cacheCreationInputTokens5m: 10_000,
194
+ cacheCreationInputTokens1h: 15_000,
195
+ }),
196
+ ).toBeCloseTo(5.725, 10);
197
+ });
198
+
199
+ it("Sonnet 5 introductory/standard 단가 전환을 cost 계산에도 적용한다", () => {
200
+ const usage = {
201
+ inputTokens: 100_000,
202
+ outputTokens: 100_000,
203
+ cachedInputTokens: 50_000,
204
+ cacheCreationInputTokens: 25_000,
205
+ };
206
+ expect(calculateCostUsd("claude-sonnet-5", usage, Date.UTC(2026, 7, 31))).toBeCloseTo(
207
+ 1.16,
208
+ 10,
209
+ );
210
+ expect(calculateCostUsd("claude-sonnet-5", usage, Date.UTC(2026, 8, 1))).toBeCloseTo(
211
+ 1.74,
212
+ 10,
213
+ );
80
214
  });
81
215
 
82
216
  it("legacy/원시 Anthropic usage 처럼 cache 가 input 보다 커도 음수 비용을 만들지 않는다", () => {