@cartanova/qgrid-cli 2.4.6 → 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.
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +15 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -4
- package/bundle/dist/application/qgrid/qgrid.frame.js +20 -1
- package/bundle/dist/application/qgrid/qgrid.types.js +38 -2
- package/bundle/dist/application/request-log/request-log.model.js +49 -22
- package/bundle/dist/application/request-log-step/request-log-step.types.js +8 -1
- package/bundle/dist/application/sonamu.generated.js +37 -1
- package/bundle/dist/application/sonamu.generated.sso.js +25 -1
- package/bundle/dist/i18n/sd.generated.js +13 -1
- package/bundle/dist/migrations/20260718180824_alter_request_logs_add5_alter1.js +20 -0
- package/bundle/dist/migrations/20260718180825_alter_request_log_steps_add7_alter1.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +8 -2
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +6 -3
- package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -7
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +117 -7
- package/bundle/dist/utils/providers/common/model-cost.js +45 -98
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +68 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +14 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +81 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -9
- package/bundle/src/application/qgrid/qgrid.frame.ts +19 -0
- package/bundle/src/application/qgrid/qgrid.types.ts +36 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +1 -0
- package/bundle/src/application/request-log/request-log.entity.json +42 -0
- package/bundle/src/application/request-log/request-log.model.test.ts +58 -0
- package/bundle/src/application/request-log/request-log.model.ts +107 -18
- package/bundle/src/application/request-log-step/request-log-step.entity.json +54 -0
- package/bundle/src/application/request-log-step/request-log-step.types.ts +7 -0
- package/bundle/src/application/sonamu.generated.http +26 -0
- package/bundle/src/application/sonamu.generated.sso.ts +24 -0
- package/bundle/src/application/sonamu.generated.ts +48 -0
- package/bundle/src/i18n/sd.generated.ts +12 -0
- package/bundle/src/migrations/20260718180824_alter_request_logs_add5_alter1.ts +25 -0
- package/bundle/src/migrations/20260718180825_alter_request_log_steps_add7_alter1.ts +29 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +15 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +11 -2
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +7 -2
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +26 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +16 -4
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +131 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +194 -11
- package/bundle/src/utils/providers/common/model-cost.test.ts +136 -2
- package/bundle/src/utils/providers/common/model-cost.ts +76 -99
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +18 -0
- package/bundle/web-dist/client/assets/{index-BOXhNo0k.js → index-CmuPjbCl.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BShH_sut.js → logs-cXOsDli3.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CoEAp5S0.js → monit-B5Edhvy-.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Dr3cGnV3.js → routes-UPWScORY.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-D2Y9QeiN.js → sd.generated-DYuKzBY6.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-CmxXl220.js → services.generated-D_SkWOo-.js} +1 -1
- package/bundle/web-dist/client/assets/show-pRp-eZIK.js +30 -0
- package/bundle/web-dist/client/assets/{tokens-hP1NecIg.js → tokens-DJlttxCM.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-6J1T9U2-.js → use-update-token-mutation-CpFGLWUl.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BlfOh9S3.js → lazyRouteComponent-Cu6IkL8A.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-DYly4ipQ.js → logs-Dx2knN2T.js} +10 -7
- package/bundle/web-dist/server/assets/{monit-CYyY2S2f.js → monit-DU6PLatf.js} +2 -2
- package/bundle/web-dist/server/assets/{routes-Dqu3osXt.js → routes-CTco1oJZ.js} +3 -3
- package/bundle/web-dist/server/assets/{sd.generated-CE6D5clm.js → sd.generated-qzuRe35X.js} +12 -0
- package/bundle/web-dist/server/assets/{services.generated-DyZUzXiq.js → services.generated-C8q1M41a.js} +1 -1
- package/bundle/web-dist/server/assets/{show-BDul8xLb.js → show-B3q8_GDh.js} +47 -4
- package/bundle/web-dist/server/assets/{tokens-fzMW4G74.js → tokens-Bvjn15h9.js} +3 -3
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BejUW5sw.js → use-update-token-mutation-BlZo6swF.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +8 -8
- package/package.json +2 -2
- package/skills/qgrid/SKILL.md +4 -0
- package/skills/qgrid/references/ai-sdk-provider-contract.md +30 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +43 -5
- package/skills/qgrid/references/decision-rationale.md +2 -0
- package/skills/qgrid/references/prompt-cache-and-usage.md +4 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +6 -2
- package/skills/qgrid/references/sonamu-api-web-flow.md +13 -0
- package/bundle/web-dist/client/assets/show-CXII6Iy7.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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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 기본인
|
|
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.
|
|
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 보다 커도 음수 비용을 만들지 않는다", () => {
|