@cartanova/qgrid-cli 2.2.1 → 2.3.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.
- package/bundle/dist/application/qgrid/conv-routing.js +2 -6
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +64 -37
- package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
- package/bundle/dist/application/request-log/request-log.model.js +54 -8
- package/bundle/dist/sonamu.config.js +11 -2
- package/bundle/dist/testing/global.js +5 -2
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
- package/bundle/dist/utils/providers/common/model-cost.js +35 -18
- package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
- package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
- package/bundle/src/application/qgrid/conv-routing.ts +3 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +37 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +99 -44
- package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
- package/bundle/src/application/request-log/request-log.model.ts +98 -13
- package/bundle/src/sonamu.config.ts +13 -2
- package/bundle/src/testing/global.ts +16 -2
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
- package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
- package/bundle/src/utils/providers/common/model-cost.ts +107 -19
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
- package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
- package/package.json +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-json 어댑터 — Anthropic(Claude) provider 의 입력/출력 변환.
|
|
3
|
+
*
|
|
4
|
+
* 이 모듈은 부활의 유일한 본질적 신규 코드다. 두 방향을 분리된 순수 함수로 둔다:
|
|
5
|
+
* - 입력: 서버 GenerateRequest payload(coldInput/coldHistory/reuseInput)
|
|
6
|
+
* → `claude -p --input-format stream-json` 의 JSONL 라인.
|
|
7
|
+
* - 출력: `claude -p --output-format stream-json --verbose` 의 JSONL 라인
|
|
8
|
+
* → qgrid 서버 delta(onDelta) + 최종 result. (U3, 같은 파일 하단)
|
|
9
|
+
*
|
|
10
|
+
* 중요한 계약:
|
|
11
|
+
* - 입력은 AI SDK LanguageModelV3Message[] 가 아니라 **서버측 GenerateRequest** 다.
|
|
12
|
+
* ai-sdk 가 이미 QueryInput.history(JsonValue[]) 로 변환했고, decideConvRouting 이
|
|
13
|
+
* coldInput/coldHistory/reuseInput 으로 만든 뒤 dispatcher 에 도달한다.
|
|
14
|
+
* - content 는 user/assistant 모두 block 배열 `[{type:"text",text}]` 로 정규화한다.
|
|
15
|
+
* 문자열 content 는 일부 user 단발만 통과하고 history replay 에서 깨진다
|
|
16
|
+
* (`W is not an Object (evaluating '"tool_use_id" in W')`).
|
|
17
|
+
* - stream-json 의 user/assistant 는 비대칭이다: assistant 는 history context 로 push 되고
|
|
18
|
+
* **user 는 새 prompt 로 enqueue(=실행)된다.** 따라서 cold 경로에서 과거 대화(user/assistant/
|
|
19
|
+
* tool)를 그대로 줄로 늘어놓으면 과거 user/tool-output 이 실행 prompt 로 재실행된다. 이를 피하려
|
|
20
|
+
* cold history 는 **단일 assistant context 텍스트로 평탄화**하고, 실행 가능한 `type:"user"` 줄은
|
|
21
|
+
* **마지막 input 하나뿐**이 되도록 한다. full-fidelity role replay 가 필요하면 session resume
|
|
22
|
+
* (reuseInput) 또는 JSONL backstop 으로 간다.
|
|
23
|
+
* - tool 결과는 native Anthropic tool_result 블록을 새로 만들지 않고, 기존 OpenAI 와
|
|
24
|
+
* 동일하게 평탄화된 text 로 처리한다(emulation 공통 계약).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
28
|
+
import { type UserInput } from "../../../codex-protocol/v2/UserInput";
|
|
29
|
+
import { type ProviderTokenUsageBreakdown } from "../common/provider-dispatcher";
|
|
30
|
+
|
|
31
|
+
// ── 입력 어댑터 ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
// Claude stream-json 입력 한 줄(JSONL). content 는 항상 block 배열.
|
|
34
|
+
//
|
|
35
|
+
// SDK envelope 필드(uuid / session_id / parent_tool_use_id)는 **여기서 만들지 않는다**.
|
|
36
|
+
// 그 값들은 세션을 발급/소유하는 claude-session 의 책임이다. 이 모듈은 role/content 변환만 하는
|
|
37
|
+
// 순수 함수로 두고, claude-session 이 직렬화 직전에 envelope 를 decorate 한다. 책임 분리.
|
|
38
|
+
export interface ClaudeStreamJsonLine {
|
|
39
|
+
type: "user" | "assistant";
|
|
40
|
+
message: {
|
|
41
|
+
role: "user" | "assistant";
|
|
42
|
+
content: Array<{ type: "text"; text: string }>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// UserInput[] 에서 text 만 뽑아 하나의 문자열로. (image/skill/mention 등은 현재 범위 밖 — text 위주)
|
|
47
|
+
function userInputToText(input: Array<UserInput>): string {
|
|
48
|
+
return input
|
|
49
|
+
.filter((i): i is Extract<UserInput, { type: "text" }> => i.type === "text")
|
|
50
|
+
.map((i) => i.text)
|
|
51
|
+
.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// content 를 항상 block 배열로 정규화(문자열 content 는 history replay 에서 깨짐).
|
|
55
|
+
function textBlock(text: string): Array<{ type: "text"; text: string }> {
|
|
56
|
+
return [{ type: "text", text }];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// codex ResponseItem(coldHistory 의 한 항목) 형태 판별용 최소 shape.
|
|
60
|
+
// extractPromptAndHistory(ai-sdk/utils.ts) 가 만드는 형식:
|
|
61
|
+
// { type:"message", role:"user", content:[{type:"input_text", text}] }
|
|
62
|
+
// { type:"message", role:"assistant", content:[{type:"output_text", text}] }
|
|
63
|
+
// { type:"function_call", name, arguments, call_id }
|
|
64
|
+
// { type:"function_call_output", call_id, output }
|
|
65
|
+
type ResponseItem = { [key: string]: JsonValue | undefined };
|
|
66
|
+
|
|
67
|
+
function asObject(v: JsonValue | undefined): ResponseItem | null {
|
|
68
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as ResponseItem) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// codex message content([{type:input_text|output_text, text}]) 에서 text 추출.
|
|
72
|
+
// 여러 content 블록은 줄바꿈으로 구분(token-adjacent 붙음 방지).
|
|
73
|
+
function responseItemText(item: ResponseItem): string {
|
|
74
|
+
const content = item.content;
|
|
75
|
+
if (!Array.isArray(content)) return "";
|
|
76
|
+
return content
|
|
77
|
+
.map((c) => {
|
|
78
|
+
const obj = asObject(c);
|
|
79
|
+
return obj && typeof obj.text === "string" ? obj.text : "";
|
|
80
|
+
})
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* cold history(codex ResponseItem[])를 **단일 평탄 텍스트**로 변환한다.
|
|
87
|
+
* user/assistant/tool 모두 라벨 붙은 한 덩어리로 — 실행되지 않는 assistant context 로 들어간다.
|
|
88
|
+
* (cold 의 user/tool-output 을 type:"user" 로 내보내면 새 prompt 로 재실행되므로 금지.)
|
|
89
|
+
*/
|
|
90
|
+
function flattenColdHistory(coldHistory: Array<JsonValue>): string {
|
|
91
|
+
const parts: Array<string> = [];
|
|
92
|
+
for (const raw of coldHistory) {
|
|
93
|
+
const item = asObject(raw);
|
|
94
|
+
if (!item) continue;
|
|
95
|
+
|
|
96
|
+
if (item.type === "message" && item.role === "assistant") {
|
|
97
|
+
parts.push(`Assistant: ${responseItemText(item)}`);
|
|
98
|
+
} else if (item.type === "message" && item.role === "user") {
|
|
99
|
+
parts.push(`User: ${responseItemText(item)}`);
|
|
100
|
+
} else if (item.type === "function_call") {
|
|
101
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
102
|
+
const args =
|
|
103
|
+
typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {});
|
|
104
|
+
parts.push(`Tool call: ${name}(${args})`);
|
|
105
|
+
} else if (item.type === "function_call_output") {
|
|
106
|
+
const output =
|
|
107
|
+
typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "");
|
|
108
|
+
parts.push(`Tool result: ${output}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return parts.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 서버 GenerateRequest payload 를 Claude stream-json JSONL 라인 배열로 변환한다.
|
|
116
|
+
*
|
|
117
|
+
* @param opts.coldHistory cold 경로의 codex ResponseItem 배열(JsonValue[]). resume 경로면 미사용.
|
|
118
|
+
* @param opts.input 실행할 input — cold 면 coldInput, resume 면 reuseInput.
|
|
119
|
+
* @param opts.isResume resume 경로 여부. true 면 history 재주입 없이 input(delta)만.
|
|
120
|
+
*
|
|
121
|
+
* 출력 규칙:
|
|
122
|
+
* - 실행 가능한 `type:"user"` 줄은 **항상 정확히 1개** — 마지막 input.
|
|
123
|
+
* - resume: user(delta) 1줄만. thread 가 이전 turn 을 누적하므로 history 미포함.
|
|
124
|
+
* - cold: coldHistory 전체를 **단일 assistant context 텍스트**로 평탄화(실행 안 됨) + user(input) 1줄.
|
|
125
|
+
* user/assistant/tool 모두 평탄화되므로 과거 user/tool-output 이 재실행되지 않는다.
|
|
126
|
+
* full-fidelity role replay 가 필요하면 session resume 또는 JSONL backstop(범위 밖).
|
|
127
|
+
* - tool(function_call/output): native 블록 없이 평탄 텍스트로.
|
|
128
|
+
*/
|
|
129
|
+
export function buildStreamJsonInput(opts: {
|
|
130
|
+
coldHistory?: Array<JsonValue>;
|
|
131
|
+
input: Array<UserInput>;
|
|
132
|
+
isResume: boolean;
|
|
133
|
+
}): Array<ClaudeStreamJsonLine> {
|
|
134
|
+
const lines: Array<ClaudeStreamJsonLine> = [];
|
|
135
|
+
|
|
136
|
+
// cold 경로: history 를 단일 assistant context 로 평탄화(실행 안 됨).
|
|
137
|
+
if (!opts.isResume && opts.coldHistory && opts.coldHistory.length > 0) {
|
|
138
|
+
const flattened = flattenColdHistory(opts.coldHistory);
|
|
139
|
+
if (flattened) {
|
|
140
|
+
lines.push({
|
|
141
|
+
type: "assistant",
|
|
142
|
+
message: {
|
|
143
|
+
role: "assistant",
|
|
144
|
+
content: textBlock(`Prior conversation context:\n${flattened}`),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 실행할 input — resume/cold 공통, 항상 마지막의 단일 user 줄.
|
|
151
|
+
lines.push({
|
|
152
|
+
type: "user",
|
|
153
|
+
message: { role: "user", content: textBlock(userInputToText(opts.input)) },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// JSONL 문자열(claude stdin 으로 흘릴 형태)로 직렬화.
|
|
160
|
+
export function serializeStreamJsonInput(lines: Array<ClaudeStreamJsonLine>): string {
|
|
161
|
+
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── 출력 어댑터 ────────────────────────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// claude -p --output-format stream-json --verbose 의 stdout(JSONL)을
|
|
167
|
+
// qgrid 서버 delta(onDelta) + 최종 result 로 변환한다.
|
|
168
|
+
//
|
|
169
|
+
// 계약:
|
|
170
|
+
// - 이 어댑터는 qgrid **서버** 계층이다. AI SDK stream part(text-delta 등)는
|
|
171
|
+
// packages/ai-sdk(client provider)가 만든다 — 여기서 만들지 않는다.
|
|
172
|
+
// - text_delta → onDelta(text). structured output 일 때 input_json_delta.partial_json
|
|
173
|
+
// 조각도 onDelta(text) 로 흘린다(client provider 가 partialObjectStream 으로 누적).
|
|
174
|
+
// - 최종 result → structured_output(우선) 또는 result(폴백, 자연어 가능) + usage + 종료.
|
|
175
|
+
// - usage 는 input/cache_creation/cache_read/output 4 카테고리를 받아 TokenUsageBreakdown 으로.
|
|
176
|
+
// payload 크기 비교는 3 카테고리 합으로(과거 학습 — input 단독 비교 무의미).
|
|
177
|
+
|
|
178
|
+
// claude stream-json 출력에서 우리가 보는 usage shape(부분). Anthropic 네이티브 필드.
|
|
179
|
+
interface ClaudeUsage {
|
|
180
|
+
input_tokens?: number;
|
|
181
|
+
output_tokens?: number;
|
|
182
|
+
cache_creation_input_tokens?: number;
|
|
183
|
+
cache_read_input_tokens?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 최종 result 파싱 산출물. dispatcher(U1)가 GenerateResult 조립에 쓴다.
|
|
187
|
+
export interface ClaudeStreamResult {
|
|
188
|
+
text: string;
|
|
189
|
+
usage: ProviderTokenUsageBreakdown;
|
|
190
|
+
durationMs: number;
|
|
191
|
+
costUsd: number;
|
|
192
|
+
// quota 소진("You've hit ...") 감지. dispatcher 가 QuotaError 로 변환.
|
|
193
|
+
quotaExhausted: boolean;
|
|
194
|
+
// result.is_error / terminal model_error 등. dispatcher 가 에러로 변환.
|
|
195
|
+
isError: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ClaudeStreamJsonState {
|
|
199
|
+
structuredOutputText?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Anthropic usage 4 카테고리 → TokenUsageBreakdown.
|
|
203
|
+
// Anthropic native usage 는 input/cache_creation/cache_read 를 서로 배타적인 카테고리로 준다.
|
|
204
|
+
// 반면 qgrid 내부 표준(TokenUsageBreakdown)과 cost 계산은 inputTokens 를 "전체 입력
|
|
205
|
+
// (= non-cache input + cache creation + cache read)" 으로 취급하고 cachedInputTokens/cacheCreationInputTokens
|
|
206
|
+
// 를 그중 각 몫으로 다시 표시한다(OpenAI/Codex usage 와 같은 의미). 따라서 여기서 합쳐 표준화한다.
|
|
207
|
+
function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
|
|
208
|
+
const input = u.input_tokens ?? 0;
|
|
209
|
+
const cacheCreate = u.cache_creation_input_tokens ?? 0;
|
|
210
|
+
const cacheRead = u.cache_read_input_tokens ?? 0;
|
|
211
|
+
const output = u.output_tokens ?? 0;
|
|
212
|
+
const totalInput = input + cacheCreate + cacheRead;
|
|
213
|
+
return {
|
|
214
|
+
totalTokens: totalInput + output,
|
|
215
|
+
inputTokens: totalInput,
|
|
216
|
+
cachedInputTokens: cacheRead,
|
|
217
|
+
cacheCreationInputTokens: cacheCreate,
|
|
218
|
+
outputTokens: output,
|
|
219
|
+
reasoningOutputTokens: 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// result.result 의 코드펜스(```json ... ```) 제거. structured_output 이 없을 때만 쓴다.
|
|
224
|
+
// 앞뒤 공백/개행을 먼저 trim 해 `\n```json\n...\n```\n` 같은 형태도 깨끗이 제거.
|
|
225
|
+
function stripCodeFence(s: string): string {
|
|
226
|
+
return s
|
|
227
|
+
.trim()
|
|
228
|
+
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
229
|
+
.replace(/\n?```\s*$/i, "");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function structuredOutputToolUseText(j: ResponseItem): string | undefined {
|
|
233
|
+
const message = asObject(j.message);
|
|
234
|
+
const content = message?.content;
|
|
235
|
+
if (!Array.isArray(content)) return undefined;
|
|
236
|
+
|
|
237
|
+
for (const raw of content) {
|
|
238
|
+
const block = asObject(raw);
|
|
239
|
+
if (!block) continue;
|
|
240
|
+
if (block.type === "tool_use" && block.name === "StructuredOutput" && block.input !== undefined) {
|
|
241
|
+
return JSON.stringify(block.input);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* stream-json 출력 라인 하나를 처리한다. 순수 함수 — side effect 는 onDelta 콜백뿐.
|
|
249
|
+
*
|
|
250
|
+
* @param opts.structuredOutput structured output(jsonSchema) 모드 여부.
|
|
251
|
+
* structured 모드에서 Claude 는 자연어 text_delta 와 input_json_delta.partial_json 을
|
|
252
|
+
* **함께** 흘릴 수 있다(PoC 확인). 이때 text_delta 를 그대로 onDelta 로 보내면 client 의
|
|
253
|
+
* streamObject 파서가 prose 를 JSON 앞에서 만나 깨진다. 그래서:
|
|
254
|
+
* - structured 모드: text_delta 무시, partial_json 만 onDelta.
|
|
255
|
+
* - text 모드: text_delta 만 onDelta (partial_json 은 안 옴).
|
|
256
|
+
*
|
|
257
|
+
* @returns result 이벤트면 ClaudeStreamResult, 그 외(delta/system 등)면 null.
|
|
258
|
+
* 호출부는 라인 스트림을 돌며 이 함수를 호출하고, non-null 이 나오면 종료.
|
|
259
|
+
*/
|
|
260
|
+
export function handleStreamJsonLine(
|
|
261
|
+
line: string,
|
|
262
|
+
onDelta: (text: string) => void,
|
|
263
|
+
opts?: { structuredOutput?: boolean; state?: ClaudeStreamJsonState },
|
|
264
|
+
): ClaudeStreamResult | null {
|
|
265
|
+
const structuredOutput = opts?.structuredOutput ?? false;
|
|
266
|
+
const state = opts?.state;
|
|
267
|
+
const trimmed = line.trim();
|
|
268
|
+
if (!trimmed) return null;
|
|
269
|
+
|
|
270
|
+
let j: { [key: string]: JsonValue | undefined };
|
|
271
|
+
try {
|
|
272
|
+
j = JSON.parse(trimmed) as { [key: string]: JsonValue | undefined };
|
|
273
|
+
} catch {
|
|
274
|
+
// 깨진 JSON 라인은 graceful skip.
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 점진 delta: stream_event > content_block_delta > {text_delta | input_json_delta}.
|
|
279
|
+
if (j.type === "stream_event") {
|
|
280
|
+
const event = asObject(j.event);
|
|
281
|
+
if (event && event.type === "content_block_delta") {
|
|
282
|
+
const delta = asObject(event.delta);
|
|
283
|
+
if (delta) {
|
|
284
|
+
if (structuredOutput) {
|
|
285
|
+
// structured: JSON 조각만. 자연어 text_delta 는 버린다(streamObject 파서 보호).
|
|
286
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
287
|
+
onDelta(delta.partial_json);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
// text: 자연어 delta 만.
|
|
291
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
292
|
+
onDelta(delta.text);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (j.type === "assistant" && structuredOutput && state && state.structuredOutputText === undefined) {
|
|
301
|
+
const text = structuredOutputToolUseText(j);
|
|
302
|
+
if (text !== undefined) state.structuredOutputText = text;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 최종 result.
|
|
307
|
+
if (j.type === "result") {
|
|
308
|
+
let text: string;
|
|
309
|
+
const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
|
|
310
|
+
if (preservedStructuredOutput !== undefined) {
|
|
311
|
+
text = preservedStructuredOutput;
|
|
312
|
+
} else if (j.structured_output !== undefined) {
|
|
313
|
+
text = JSON.stringify(j.structured_output);
|
|
314
|
+
} else {
|
|
315
|
+
text = stripCodeFence(typeof j.result === "string" ? j.result : "");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const usage = toUsageBreakdown((asObject(j.usage) ?? {}) as ClaudeUsage);
|
|
319
|
+
const quotaExhausted = text.startsWith("You've hit");
|
|
320
|
+
// 에러 판정: is_error true, terminal model_error,
|
|
321
|
+
// 또는 subtype 이 있고 "success" 가 아니면(error_during_execution / error_max_turns /
|
|
322
|
+
// error_max_budget_usd / error_max_structured_output_retries 등) 에러로 본다.
|
|
323
|
+
const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
|
|
324
|
+
const structuredMaxTurnsCompleted =
|
|
325
|
+
preservedStructuredOutput !== undefined &&
|
|
326
|
+
(subtype === "error_max_turns" || j.terminal_reason === "max_turns");
|
|
327
|
+
const isError =
|
|
328
|
+
!structuredMaxTurnsCompleted &&
|
|
329
|
+
(j.is_error === true ||
|
|
330
|
+
j.terminal_reason === "model_error" ||
|
|
331
|
+
(subtype !== undefined && subtype !== "success"));
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
text,
|
|
335
|
+
usage,
|
|
336
|
+
durationMs: typeof j.duration_ms === "number" ? j.duration_ms : 0,
|
|
337
|
+
costUsd: typeof j.total_cost_usd === "number" ? j.total_cost_usd : 0,
|
|
338
|
+
quotaExhausted,
|
|
339
|
+
isError,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { calculateCostUsd } from "./model-cost";
|
|
4
|
+
|
|
5
|
+
describe("calculateCostUsd", () => {
|
|
6
|
+
it("Anthropic cache read/write 를 전체 입력에서 분리해 각각 단가를 적용한다", () => {
|
|
7
|
+
const cost = calculateCostUsd("claude-sonnet-4-6", {
|
|
8
|
+
inputTokens: 1_917,
|
|
9
|
+
outputTokens: 161,
|
|
10
|
+
cachedInputTokens: 1_024,
|
|
11
|
+
cacheCreationInputTokens: 0,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(cost).toBeCloseTo(0.0054012, 10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("Anthropic cache creation 은 Claude Code 기본인 5분 write 단가를 적용한다", () => {
|
|
18
|
+
const cost = calculateCostUsd("claude-sonnet-4-6", {
|
|
19
|
+
inputTokens: 1_992,
|
|
20
|
+
outputTokens: 187,
|
|
21
|
+
cachedInputTokens: 0,
|
|
22
|
+
cacheCreationInputTokens: 1_068,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(cost).toBeCloseTo(0.009582, 10);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("legacy/원시 Anthropic usage 처럼 cache 가 input 보다 커도 음수 비용을 만들지 않는다", () => {
|
|
29
|
+
const cost = calculateCostUsd("claude-sonnet-4-6", {
|
|
30
|
+
inputTokens: 893,
|
|
31
|
+
outputTokens: 161,
|
|
32
|
+
cachedInputTokens: 1_024,
|
|
33
|
+
cacheCreationInputTokens: 0,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(cost).toBeGreaterThan(0);
|
|
37
|
+
expect(cost).toBeCloseTo(0.0027222, 10);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -14,6 +14,7 @@ export interface ModelCosts {
|
|
|
14
14
|
inputTokens: number;
|
|
15
15
|
outputTokens: number;
|
|
16
16
|
cachedInputTokens: number;
|
|
17
|
+
cacheCreationInputTokens?: number;
|
|
17
18
|
/**
|
|
18
19
|
* long-context 할증. 전체 입력 토큰(input_tokens, cache_read 포함)이 threshold 초과 시
|
|
19
20
|
* 초과분만이 아니라 요청 전체(full session)에 배율 적용.
|
|
@@ -31,7 +32,7 @@ export interface ModelCosts {
|
|
|
31
32
|
// 가격 출처: https://openai.com/api/pricing (2026-06-11 확인)
|
|
32
33
|
// 신모델 출시마다 단가가 바뀌므로(5.2→5.4→5.5) 모델 추가 시 반드시 공식 페이지 재확인해야함
|
|
33
34
|
|
|
34
|
-
// GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini는
|
|
35
|
+
// GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini/nano는 공식 표에서 long-context 단가 없음)
|
|
35
36
|
// 272K 초과 시 input 2x / cached 2x / output 1.5x — 초과분만이 아닌 세션 전체에 적용됨
|
|
36
37
|
// @see https://developers.openai.com/api/docs/models/gpt-5.5 ("prompts with >272K input tokens")
|
|
37
38
|
const LONG_CONTEXT_272K: NonNullable<ModelCosts["longContext"]> = {
|
|
@@ -66,21 +67,98 @@ const OPENAI_COSTS: Record<string, ModelCosts> = {
|
|
|
66
67
|
// ── Anthropic ───────────────────────────────────────────────────────
|
|
67
68
|
|
|
68
69
|
const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"claude-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"claude-
|
|
70
|
+
// Claude Code 는 별도 ttl:"1h" 옵션 없이 실행되므로 fallback 가격표는 5분 cache write(1.25x)를 기준으로 둔다.
|
|
71
|
+
// 1시간 cache write 는 input 2x 이지만 현재 qgrid 실행 경로의 기본값이 아니다.
|
|
72
|
+
sonnet: {
|
|
73
|
+
inputTokens: 3,
|
|
74
|
+
outputTokens: 15,
|
|
75
|
+
cachedInputTokens: 0.3,
|
|
76
|
+
cacheCreationInputTokens: 3.75,
|
|
77
|
+
},
|
|
78
|
+
"claude-3-5-haiku": {
|
|
79
|
+
inputTokens: 0.8,
|
|
80
|
+
outputTokens: 4,
|
|
81
|
+
cachedInputTokens: 0.08,
|
|
82
|
+
cacheCreationInputTokens: 1,
|
|
83
|
+
},
|
|
84
|
+
"claude-haiku-4-5": {
|
|
85
|
+
inputTokens: 1,
|
|
86
|
+
outputTokens: 5,
|
|
87
|
+
cachedInputTokens: 0.1,
|
|
88
|
+
cacheCreationInputTokens: 1.25,
|
|
89
|
+
},
|
|
90
|
+
"claude-3-5-sonnet": {
|
|
91
|
+
inputTokens: 3,
|
|
92
|
+
outputTokens: 15,
|
|
93
|
+
cachedInputTokens: 0.3,
|
|
94
|
+
cacheCreationInputTokens: 3.75,
|
|
95
|
+
},
|
|
96
|
+
"claude-3-7-sonnet": {
|
|
97
|
+
inputTokens: 3,
|
|
98
|
+
outputTokens: 15,
|
|
99
|
+
cachedInputTokens: 0.3,
|
|
100
|
+
cacheCreationInputTokens: 3.75,
|
|
101
|
+
},
|
|
102
|
+
"claude-sonnet-4": {
|
|
103
|
+
inputTokens: 3,
|
|
104
|
+
outputTokens: 15,
|
|
105
|
+
cachedInputTokens: 0.3,
|
|
106
|
+
cacheCreationInputTokens: 3.75,
|
|
107
|
+
},
|
|
108
|
+
"claude-sonnet-4-5": {
|
|
109
|
+
inputTokens: 3,
|
|
110
|
+
outputTokens: 15,
|
|
111
|
+
cachedInputTokens: 0.3,
|
|
112
|
+
cacheCreationInputTokens: 3.75,
|
|
113
|
+
},
|
|
114
|
+
"claude-sonnet-4-6": {
|
|
115
|
+
inputTokens: 3,
|
|
116
|
+
outputTokens: 15,
|
|
117
|
+
cachedInputTokens: 0.3,
|
|
118
|
+
cacheCreationInputTokens: 3.75,
|
|
119
|
+
},
|
|
120
|
+
"claude-sonnet-4-7": {
|
|
121
|
+
inputTokens: 3,
|
|
122
|
+
outputTokens: 15,
|
|
123
|
+
cachedInputTokens: 0.3,
|
|
124
|
+
cacheCreationInputTokens: 3.75,
|
|
125
|
+
},
|
|
126
|
+
"claude-opus-4": {
|
|
127
|
+
inputTokens: 15,
|
|
128
|
+
outputTokens: 75,
|
|
129
|
+
cachedInputTokens: 1.5,
|
|
130
|
+
cacheCreationInputTokens: 18.75,
|
|
131
|
+
},
|
|
132
|
+
"claude-opus-4-1": {
|
|
133
|
+
inputTokens: 15,
|
|
134
|
+
outputTokens: 75,
|
|
135
|
+
cachedInputTokens: 1.5,
|
|
136
|
+
cacheCreationInputTokens: 18.75,
|
|
137
|
+
},
|
|
138
|
+
"claude-opus-4-5": {
|
|
139
|
+
inputTokens: 5,
|
|
140
|
+
outputTokens: 25,
|
|
141
|
+
cachedInputTokens: 0.5,
|
|
142
|
+
cacheCreationInputTokens: 6.25,
|
|
143
|
+
},
|
|
144
|
+
"claude-opus-4-6": {
|
|
145
|
+
inputTokens: 5,
|
|
146
|
+
outputTokens: 25,
|
|
147
|
+
cachedInputTokens: 0.5,
|
|
148
|
+
cacheCreationInputTokens: 6.25,
|
|
149
|
+
},
|
|
150
|
+
"claude-opus-4-7": {
|
|
151
|
+
inputTokens: 5,
|
|
152
|
+
outputTokens: 25,
|
|
153
|
+
cachedInputTokens: 0.5,
|
|
154
|
+
cacheCreationInputTokens: 6.25,
|
|
155
|
+
},
|
|
156
|
+
"claude-opus-4-8": {
|
|
157
|
+
inputTokens: 5,
|
|
158
|
+
outputTokens: 25,
|
|
159
|
+
cachedInputTokens: 0.5,
|
|
160
|
+
cacheCreationInputTokens: 6.25,
|
|
161
|
+
},
|
|
84
162
|
};
|
|
85
163
|
|
|
86
164
|
const DEFAULT_COSTS: ModelCosts = { inputTokens: 3, outputTokens: 15, cachedInputTokens: 0.3 };
|
|
@@ -91,22 +169,32 @@ export function getModelCosts(model: string): ModelCosts {
|
|
|
91
169
|
|
|
92
170
|
export function calculateCostUsd(
|
|
93
171
|
model: string,
|
|
94
|
-
usage: {
|
|
172
|
+
usage: {
|
|
173
|
+
inputTokens: number;
|
|
174
|
+
outputTokens: number;
|
|
175
|
+
cachedInputTokens?: number;
|
|
176
|
+
cacheCreationInputTokens?: number;
|
|
177
|
+
},
|
|
95
178
|
): number {
|
|
96
179
|
const costs = getModelCosts(model);
|
|
97
180
|
const cachedInput = usage.cachedInputTokens ?? 0;
|
|
98
|
-
const
|
|
181
|
+
const cacheCreationInput = usage.cacheCreationInputTokens ?? 0;
|
|
182
|
+
const nonCachedInput = Math.max(usage.inputTokens - cachedInput - cacheCreationInput, 0);
|
|
99
183
|
|
|
100
184
|
// long-context 할증: 전체 입력(input_tokens, cache 포함)이 threshold 초과 시 요청 전체에 배율 적용
|
|
101
185
|
const lc = costs.longContext;
|
|
102
186
|
const isLongContext = lc !== undefined && usage.inputTokens > lc.threshold;
|
|
103
187
|
const inputRate = costs.inputTokens * (isLongContext ? lc.inputMultiplier : 1);
|
|
104
188
|
const cachedRate = costs.cachedInputTokens * (isLongContext ? lc.cachedInputMultiplier : 1);
|
|
189
|
+
const cacheCreationRate =
|
|
190
|
+
(costs.cacheCreationInputTokens ?? costs.inputTokens) *
|
|
191
|
+
(isLongContext ? lc.inputMultiplier : 1);
|
|
105
192
|
const outputRate = costs.outputTokens * (isLongContext ? lc.outputMultiplier : 1);
|
|
106
193
|
|
|
107
194
|
return (
|
|
108
195
|
(nonCachedInput / 1_000_000) * inputRate +
|
|
109
196
|
(usage.outputTokens / 1_000_000) * outputRate +
|
|
110
|
-
(cachedInput / 1_000_000) * cachedRate
|
|
197
|
+
(cachedInput / 1_000_000) * cachedRate +
|
|
198
|
+
(cacheCreationInput / 1_000_000) * cacheCreationRate
|
|
111
199
|
);
|
|
112
200
|
}
|
|
@@ -8,6 +8,12 @@ import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
|
8
8
|
import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
|
|
9
9
|
import { type UserInput } from "../../../codex-protocol/v2/UserInput";
|
|
10
10
|
|
|
11
|
+
// qgrid provider 내부 표준 usage. codex-protocol 의 TokenUsageBreakdown 은 생성 파일이므로
|
|
12
|
+
// provider 전용 cache write 세부 필드는 여기서 확장해 보존한다.
|
|
13
|
+
export type ProviderTokenUsageBreakdown = TokenUsageBreakdown & {
|
|
14
|
+
cacheCreationInputTokens?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
11
17
|
// thread 재사용 라우팅 좌표. 상위(qgrid.dispatcher)에서 conv 핸들 검증을 통과한 경우에만 전달.
|
|
12
18
|
// dispatcher 는 이 좌표가 가리키는 worker 의 기존 thread 에 turn 만 실행한다.
|
|
13
19
|
export interface ReuseThreadCoord {
|
|
@@ -17,7 +23,9 @@ export interface ReuseThreadCoord {
|
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export interface GenerateRequest {
|
|
20
|
-
|
|
26
|
+
// 미지정이면 dispatcher 가 provider 별 default 를 적용한다(Anthropic: ANTHROPIC_DEFAULT_MODEL).
|
|
27
|
+
// OpenAI 경로는 항상 prefix split 후 canonical model 을 넘기므로 영향 없음.
|
|
28
|
+
model?: string;
|
|
21
29
|
systemPrompt?: string;
|
|
22
30
|
outputSchema?: JsonValue;
|
|
23
31
|
effort?: string;
|
|
@@ -39,8 +47,11 @@ export interface GenerateRequest {
|
|
|
39
47
|
export interface GenerateResult {
|
|
40
48
|
text: string;
|
|
41
49
|
tokenName: string;
|
|
42
|
-
usage:
|
|
50
|
+
usage: ProviderTokenUsageBreakdown;
|
|
43
51
|
durationMs: number;
|
|
52
|
+
// Provider 가 직접 산출한 비용. Anthropic Claude Code 는 total_cost_usd 를 주므로 이 값을
|
|
53
|
+
// 우선 사용하고, 없으면 상위가 모델별 가격표로 계산한다.
|
|
54
|
+
costUsd?: number;
|
|
44
55
|
model: string;
|
|
45
56
|
// 이번 turn 이 사용한 thread 좌표. 상위가 conv 핸들을 발급/갱신하는 데 쓴다.
|
|
46
57
|
threadCoord: ReuseThreadCoord;
|
|
@@ -27,6 +27,7 @@ interface RefreshResult {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function handleChatgptAuthTokensRefresh(tokenId: number): Promise<RefreshResult> {
|
|
30
|
+
logger.info(`refresh requested for token ${tokenId} (pid=${process.pid})`);
|
|
30
31
|
const existing = inflightRefresh.get(tokenId);
|
|
31
32
|
if (existing) {
|
|
32
33
|
logger.info(`refresh dedup for token ${tokenId}`);
|
|
@@ -86,6 +87,19 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
86
87
|
|
|
87
88
|
if (!resp.ok) {
|
|
88
89
|
const body = await resp.text().catch(() => "");
|
|
90
|
+
// 401 본문의 error 코드가 토큰 사망의 결정적 사인이다.
|
|
91
|
+
// refresh_token_expired → refresh_token 자체 만료
|
|
92
|
+
// refresh_token_reused → 이미 사용된(회전 폐기된) refresh_token 재사용 → 패밀리 무효화
|
|
93
|
+
// refresh_token_invalidated → 서버측 revoke
|
|
94
|
+
// 위 3개는 모두 영구 실패. 재등록 외 복구 불가.
|
|
95
|
+
let errorCode: string | undefined;
|
|
96
|
+
try {
|
|
97
|
+
errorCode = (JSON.parse(body) as { error?: string }).error;
|
|
98
|
+
} catch {}
|
|
99
|
+
logger.error(
|
|
100
|
+
`OpenAI refresh FAILED token=${token.name}(id=${tokenId}) status=${resp.status} ` +
|
|
101
|
+
`code=${errorCode ?? "?"} body=${body}`,
|
|
102
|
+
);
|
|
89
103
|
throw new Error(`OpenAI refresh failed: ${resp.status} ${body}`);
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -115,16 +129,27 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
115
129
|
...(data.id_token ? { idToken: data.id_token } : {}),
|
|
116
130
|
};
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
// 회전(rotated)이 일어났는데 이 save 가 실패하면, OpenAI 쪽 옛 refresh_token 은 이미
|
|
133
|
+
// 폐기된 상태에서 DB 에는 옛 토큰이 남는다 → 다음 refresh 가 refresh_token_reused 로
|
|
134
|
+
// 영구 사망. 이 구간 실패는 토큰 사망의 직접 원인이므로 반드시 크게 남긴다.
|
|
135
|
+
try {
|
|
136
|
+
await TokenModel.save([
|
|
137
|
+
{
|
|
138
|
+
id: tokenId,
|
|
139
|
+
provider: "openai",
|
|
140
|
+
credentials: updatedCreds,
|
|
141
|
+
name: token.name,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
logger.error(
|
|
146
|
+
`OpenAI refresh: DB save FAILED after rotation(rotated=${rotated}) ` +
|
|
147
|
+
`token=${token.name}(id=${tokenId}) — 옛 refresh_token 폐기됨, DB 미반영 → 토큰 사망 위험. err=${String(e)}`,
|
|
148
|
+
);
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
126
151
|
|
|
127
|
-
logger.info(`token ${token.name} refreshed successfully`);
|
|
152
|
+
logger.info(`token ${token.name} refreshed successfully (rotated=${rotated})`);
|
|
128
153
|
|
|
129
154
|
return {
|
|
130
155
|
accessToken: data.access_token,
|