@cartanova/qgrid-cli 2.3.2 → 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -278
- package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +42 -98
- package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
- package/bundle/dist/utils/providers/common/model-cost.js +3 -2
- package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +235 -7
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -364
- package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +61 -188
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
- package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
- package/bundle/src/utils/providers/common/model-cost.ts +2 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
- package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
- package/package.json +1 -1
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* stream-json
|
|
2
|
+
* Claude CLI stream-json adapter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* → `claude -p --input-format stream-json` 의 JSONL 라인.
|
|
7
|
-
* - 출력: `claude -p --output-format stream-json --verbose` 의 JSONL 라인
|
|
8
|
-
* → qgrid 서버 delta(onDelta) + 최종 result. (U3, 같은 파일 하단)
|
|
4
|
+
* Input: qgrid history/input -> Claude JSONL stdin.
|
|
5
|
+
* Output: Claude JSONL stdout -> qgrid deltas + final result metadata.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 공통 계약).
|
|
7
|
+
* Previous conversation items are flattened into one assistant context line.
|
|
8
|
+
* The only executable user line is the current input.
|
|
25
9
|
*/
|
|
26
10
|
|
|
27
11
|
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
@@ -30,11 +14,7 @@ import { type ProviderTokenUsageBreakdown } from "../common/provider-dispatcher"
|
|
|
30
14
|
|
|
31
15
|
// ── 입력 어댑터 ────────────────────────────────────────────────────
|
|
32
16
|
|
|
33
|
-
// Claude stream-json 입력 한 줄(JSONL).
|
|
34
|
-
//
|
|
35
|
-
// SDK envelope 필드(uuid / session_id / parent_tool_use_id)는 **여기서 만들지 않는다**.
|
|
36
|
-
// 그 값들은 세션을 발급/소유하는 claude-session 의 책임이다. 이 모듈은 role/content 변환만 하는
|
|
37
|
-
// 순수 함수로 두고, claude-session 이 직렬화 직전에 envelope 를 decorate 한다. 책임 분리.
|
|
17
|
+
// Claude stream-json 입력 한 줄(JSONL). Envelope fields are added in claude-session.
|
|
38
18
|
export interface ClaudeStreamJsonLine {
|
|
39
19
|
type: "user" | "assistant";
|
|
40
20
|
message: {
|
|
@@ -43,7 +23,6 @@ export interface ClaudeStreamJsonLine {
|
|
|
43
23
|
};
|
|
44
24
|
}
|
|
45
25
|
|
|
46
|
-
// UserInput[] 에서 text 만 뽑아 하나의 문자열로. (image/skill/mention 등은 현재 범위 밖 — text 위주)
|
|
47
26
|
function userInputToText(input: Array<UserInput>): string {
|
|
48
27
|
return input
|
|
49
28
|
.filter((i): i is Extract<UserInput, { type: "text" }> => i.type === "text")
|
|
@@ -51,12 +30,11 @@ function userInputToText(input: Array<UserInput>): string {
|
|
|
51
30
|
.join("\n");
|
|
52
31
|
}
|
|
53
32
|
|
|
54
|
-
// content 를 항상 block 배열로 정규화(문자열 content 는 history replay 에서 깨짐).
|
|
55
33
|
function textBlock(text: string): Array<{ type: "text"; text: string }> {
|
|
56
34
|
return [{ type: "text", text }];
|
|
57
35
|
}
|
|
58
36
|
|
|
59
|
-
// codex ResponseItem(coldHistory
|
|
37
|
+
// codex ResponseItem(coldHistory item) 최소 shape.
|
|
60
38
|
// extractPromptAndHistory(ai-sdk/utils.ts) 가 만드는 형식:
|
|
61
39
|
// { type:"message", role:"user", content:[{type:"input_text", text}] }
|
|
62
40
|
// { type:"message", role:"assistant", content:[{type:"output_text", text}] }
|
|
@@ -68,8 +46,6 @@ function asObject(v: JsonValue | undefined): ResponseItem | null {
|
|
|
68
46
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as ResponseItem) : null;
|
|
69
47
|
}
|
|
70
48
|
|
|
71
|
-
// codex message content([{type:input_text|output_text, text}]) 에서 text 추출.
|
|
72
|
-
// 여러 content 블록은 줄바꿈으로 구분(token-adjacent 붙음 방지).
|
|
73
49
|
function responseItemText(item: ResponseItem): string {
|
|
74
50
|
const content = item.content;
|
|
75
51
|
if (!Array.isArray(content)) return "";
|
|
@@ -82,11 +58,6 @@ function responseItemText(item: ResponseItem): string {
|
|
|
82
58
|
.join("\n");
|
|
83
59
|
}
|
|
84
60
|
|
|
85
|
-
/**
|
|
86
|
-
* cold history(codex ResponseItem[])를 **단일 평탄 텍스트**로 변환한다.
|
|
87
|
-
* user/assistant/tool 모두 라벨 붙은 한 덩어리로 — 실행되지 않는 assistant context 로 들어간다.
|
|
88
|
-
* (cold 의 user/tool-output 을 type:"user" 로 내보내면 새 prompt 로 재실행되므로 금지.)
|
|
89
|
-
*/
|
|
90
61
|
function flattenColdHistory(coldHistory: Array<JsonValue>): string {
|
|
91
62
|
const parts: Array<string> = [];
|
|
92
63
|
for (const raw of coldHistory) {
|
|
@@ -111,30 +82,13 @@ function flattenColdHistory(coldHistory: Array<JsonValue>): string {
|
|
|
111
82
|
return parts.join("\n");
|
|
112
83
|
}
|
|
113
84
|
|
|
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
85
|
export function buildStreamJsonInput(opts: {
|
|
130
86
|
coldHistory?: Array<JsonValue>;
|
|
131
87
|
input: Array<UserInput>;
|
|
132
|
-
isResume: boolean;
|
|
133
88
|
}): Array<ClaudeStreamJsonLine> {
|
|
134
89
|
const lines: Array<ClaudeStreamJsonLine> = [];
|
|
135
90
|
|
|
136
|
-
|
|
137
|
-
if (!opts.isResume && opts.coldHistory && opts.coldHistory.length > 0) {
|
|
91
|
+
if (opts.coldHistory && opts.coldHistory.length > 0) {
|
|
138
92
|
const flattened = flattenColdHistory(opts.coldHistory);
|
|
139
93
|
if (flattened) {
|
|
140
94
|
lines.push({
|
|
@@ -147,7 +101,6 @@ export function buildStreamJsonInput(opts: {
|
|
|
147
101
|
}
|
|
148
102
|
}
|
|
149
103
|
|
|
150
|
-
// 실행할 input — resume/cold 공통, 항상 마지막의 단일 user 줄.
|
|
151
104
|
lines.push({
|
|
152
105
|
type: "user",
|
|
153
106
|
message: { role: "user", content: textBlock(userInputToText(opts.input)) },
|
|
@@ -156,26 +109,14 @@ export function buildStreamJsonInput(opts: {
|
|
|
156
109
|
return lines;
|
|
157
110
|
}
|
|
158
111
|
|
|
159
|
-
// JSONL 문자열(claude stdin 으로 흘릴 형태)로 직렬화.
|
|
160
112
|
export function serializeStreamJsonInput(lines: Array<ClaudeStreamJsonLine>): string {
|
|
161
113
|
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
162
114
|
}
|
|
163
115
|
|
|
164
116
|
// ── 출력 어댑터 ────────────────────────────────────────────────────
|
|
165
117
|
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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 네이티브 필드.
|
|
118
|
+
// Claude stdout(JSONL)을 qgrid deltas + final result 로 변환한다.
|
|
119
|
+
|
|
179
120
|
interface ClaudeUsage {
|
|
180
121
|
input_tokens?: number;
|
|
181
122
|
output_tokens?: number;
|
|
@@ -183,7 +124,6 @@ interface ClaudeUsage {
|
|
|
183
124
|
cache_read_input_tokens?: number;
|
|
184
125
|
}
|
|
185
126
|
|
|
186
|
-
// 최종 result 파싱 산출물. dispatcher(U1)가 GenerateResult 조립에 쓴다.
|
|
187
127
|
export interface ClaudeStreamResult {
|
|
188
128
|
text: string;
|
|
189
129
|
usage: ProviderTokenUsageBreakdown;
|
|
@@ -193,6 +133,10 @@ export interface ClaudeStreamResult {
|
|
|
193
133
|
quotaExhausted: boolean;
|
|
194
134
|
// result.is_error / terminal model_error 등. dispatcher 가 에러로 변환.
|
|
195
135
|
isError: boolean;
|
|
136
|
+
// 진단용: CC result 라인의 subtype("success" | "error_max_turns" | ...) 과 terminal_reason.
|
|
137
|
+
// isError 판정 사유를 에러 메시지에 드러내기 위해 보존한다(SON-495).
|
|
138
|
+
subtype?: string;
|
|
139
|
+
terminalReason?: string;
|
|
196
140
|
}
|
|
197
141
|
|
|
198
142
|
export interface ClaudeStreamJsonState {
|
|
@@ -220,8 +164,6 @@ function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
|
|
|
220
164
|
};
|
|
221
165
|
}
|
|
222
166
|
|
|
223
|
-
// result.result 의 코드펜스(```json ... ```) 제거. structured_output 이 없을 때만 쓴다.
|
|
224
|
-
// 앞뒤 공백/개행을 먼저 trim 해 `\n```json\n...\n```\n` 같은 형태도 깨끗이 제거.
|
|
225
167
|
function stripCodeFence(s: string): string {
|
|
226
168
|
return s
|
|
227
169
|
.trim()
|
|
@@ -237,26 +179,18 @@ function structuredOutputToolUseText(j: ResponseItem): string | undefined {
|
|
|
237
179
|
for (const raw of content) {
|
|
238
180
|
const block = asObject(raw);
|
|
239
181
|
if (!block) continue;
|
|
240
|
-
if (
|
|
182
|
+
if (
|
|
183
|
+
block.type === "tool_use" &&
|
|
184
|
+
block.name === "StructuredOutput" &&
|
|
185
|
+
block.input !== undefined
|
|
186
|
+
) {
|
|
241
187
|
return JSON.stringify(block.input);
|
|
242
188
|
}
|
|
243
189
|
}
|
|
244
190
|
return undefined;
|
|
245
191
|
}
|
|
246
192
|
|
|
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
|
-
*/
|
|
193
|
+
// Processes one Claude stdout JSONL line. Returns final result on result event.
|
|
260
194
|
export function handleStreamJsonLine(
|
|
261
195
|
line: string,
|
|
262
196
|
onDelta: (text: string) => void,
|
|
@@ -271,23 +205,19 @@ export function handleStreamJsonLine(
|
|
|
271
205
|
try {
|
|
272
206
|
j = JSON.parse(trimmed) as { [key: string]: JsonValue | undefined };
|
|
273
207
|
} catch {
|
|
274
|
-
// 깨진 JSON 라인은 graceful skip.
|
|
275
208
|
return null;
|
|
276
209
|
}
|
|
277
210
|
|
|
278
|
-
// 점진 delta: stream_event > content_block_delta > {text_delta | input_json_delta}.
|
|
279
211
|
if (j.type === "stream_event") {
|
|
280
212
|
const event = asObject(j.event);
|
|
281
213
|
if (event && event.type === "content_block_delta") {
|
|
282
214
|
const delta = asObject(event.delta);
|
|
283
215
|
if (delta) {
|
|
284
216
|
if (structuredOutput) {
|
|
285
|
-
// structured: JSON 조각만. 자연어 text_delta 는 버린다(streamObject 파서 보호).
|
|
286
217
|
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
287
218
|
onDelta(delta.partial_json);
|
|
288
219
|
}
|
|
289
220
|
} else {
|
|
290
|
-
// text: 자연어 delta 만.
|
|
291
221
|
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
292
222
|
onDelta(delta.text);
|
|
293
223
|
}
|
|
@@ -297,13 +227,17 @@ export function handleStreamJsonLine(
|
|
|
297
227
|
return null;
|
|
298
228
|
}
|
|
299
229
|
|
|
300
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
j.type === "assistant" &&
|
|
232
|
+
structuredOutput &&
|
|
233
|
+
state &&
|
|
234
|
+
state.structuredOutputText === undefined
|
|
235
|
+
) {
|
|
301
236
|
const text = structuredOutputToolUseText(j);
|
|
302
237
|
if (text !== undefined) state.structuredOutputText = text;
|
|
303
238
|
return null;
|
|
304
239
|
}
|
|
305
240
|
|
|
306
|
-
// 최종 result.
|
|
307
241
|
if (j.type === "result") {
|
|
308
242
|
let text: string;
|
|
309
243
|
const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
|
|
@@ -317,18 +251,20 @@ export function handleStreamJsonLine(
|
|
|
317
251
|
|
|
318
252
|
const usage = toUsageBreakdown((asObject(j.usage) ?? {}) as ClaudeUsage);
|
|
319
253
|
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
254
|
const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
255
|
+
const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
|
|
256
|
+
|
|
257
|
+
// SON-495: 비정상 종료(error_max_structured_output_retries 등)는 정직하게 에러로 처리한다.
|
|
258
|
+
// CC structured output 은 constrained decoding 이 아니라 "tool input 생성 후 사후 AJV 검증"이라
|
|
259
|
+
// 모델이 가끔 placeholder($PARAMETER_NAME)·거부("just kidding")·필드 누락(advance) 같은 발작/
|
|
260
|
+
// 불완전 출력을 낸다. 이를 "거의 맞았으니 살리자"고 흘리면 클라이언트 검증에서 깨지거나(필드 누락)
|
|
261
|
+
// 쓰레기가 새어 나간다(실측 11674/11675). 알려진 best practice(refusal/degenerate output 은 살리지
|
|
262
|
+
// 말고 graceful error)대로, subtype != success 는 그대로 에러로 둔다. 클라이언트가 그 턴을 실패로
|
|
263
|
+
// 다루게 한다(스트림이라 자동 재시도 없음 — 시간 2배 방지).
|
|
327
264
|
const isError =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
(subtype !== undefined && subtype !== "success"));
|
|
265
|
+
j.is_error === true ||
|
|
266
|
+
j.terminal_reason === "model_error" ||
|
|
267
|
+
(subtype !== undefined && subtype !== "success");
|
|
332
268
|
|
|
333
269
|
return {
|
|
334
270
|
text,
|
|
@@ -337,6 +273,8 @@ export function handleStreamJsonLine(
|
|
|
337
273
|
costUsd: typeof j.total_cost_usd === "number" ? j.total_cost_usd : 0,
|
|
338
274
|
quotaExhausted,
|
|
339
275
|
isError,
|
|
276
|
+
subtype,
|
|
277
|
+
terminalReason,
|
|
340
278
|
};
|
|
341
279
|
}
|
|
342
280
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { calculateCostUsd } from "./model-cost";
|
|
3
|
+
import { calculateCostUsd, getModelCosts } from "./model-cost";
|
|
4
4
|
|
|
5
5
|
describe("calculateCostUsd", () => {
|
|
6
6
|
it("Anthropic cache read/write 를 전체 입력에서 분리해 각각 단가를 적용한다", () => {
|
|
@@ -36,4 +36,22 @@ describe("calculateCostUsd", () => {
|
|
|
36
36
|
expect(cost).toBeGreaterThan(0);
|
|
37
37
|
expect(cost).toBeCloseTo(0.0027222, 10);
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it("[1m] suffix 는 cost lookup 에서 strip 하고 long-context 할증은 붙이지 않는다", () => {
|
|
41
|
+
const base = getModelCosts("claude-sonnet-4-6");
|
|
42
|
+
const suffixed = getModelCosts("claude-sonnet-4-6[1m]");
|
|
43
|
+
expect(suffixed).toBe(base);
|
|
44
|
+
expect(suffixed.longContext).toBeUndefined();
|
|
45
|
+
|
|
46
|
+
const usage = {
|
|
47
|
+
inputTokens: 250_000,
|
|
48
|
+
outputTokens: 1_000,
|
|
49
|
+
cachedInputTokens: 200_000,
|
|
50
|
+
cacheCreationInputTokens: 0,
|
|
51
|
+
};
|
|
52
|
+
expect(calculateCostUsd("claude-sonnet-4-6[1m]", usage)).toBeCloseTo(
|
|
53
|
+
calculateCostUsd("claude-sonnet-4-6", usage),
|
|
54
|
+
10,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
39
57
|
});
|
|
@@ -164,7 +164,8 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
|
|
|
164
164
|
const DEFAULT_COSTS: ModelCosts = { inputTokens: 3, outputTokens: 15, cachedInputTokens: 0.3 };
|
|
165
165
|
|
|
166
166
|
export function getModelCosts(model: string): ModelCosts {
|
|
167
|
-
|
|
167
|
+
const normalizedModel = model.replace(/\[1m\]$/i, "");
|
|
168
|
+
return OPENAI_COSTS[normalizedModel] ?? ANTHROPIC_COSTS[normalizedModel] ?? DEFAULT_COSTS;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
export function calculateCostUsd(
|
|
@@ -57,16 +57,19 @@ export interface GenerateResult {
|
|
|
57
57
|
threadCoord: ReuseThreadCoord;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
export interface GenerateStreamCallbacks {
|
|
60
|
+
// 스트림 콜백 컨테이너. 계층별로 onComplete payload 만 다르고, 스트림 이벤트 shape 는 동일하다.
|
|
61
|
+
export interface StreamCallbacks<TComplete> {
|
|
63
62
|
onDelta: (text: string) => void;
|
|
64
|
-
onComplete: (result:
|
|
63
|
+
onComplete: (result: TComplete) => void;
|
|
65
64
|
onError: (error: Error) => void;
|
|
66
65
|
onThreadId?: (threadId: string) => void;
|
|
67
66
|
onTurnId?: (turnId: string) => void;
|
|
68
67
|
}
|
|
69
68
|
|
|
69
|
+
// 상위(qgrid.dispatcher)로 가는 스트림 콜백. onComplete 는 non-stream 의 GenerateResult 와
|
|
70
|
+
// 동일 shape(tokenName/threadCoord 포함)를 받아, 두 경로가 같은 일급 타입을 공유한다.
|
|
71
|
+
export type GenerateStreamCallbacks = StreamCallbacks<GenerateResult>;
|
|
72
|
+
|
|
70
73
|
export interface ProviderDispatcher {
|
|
71
74
|
generate(req: GenerateRequest): Promise<GenerateResult>;
|
|
72
75
|
start(): Promise<void>;
|
|
@@ -9,6 +9,7 @@ import { type ThreadStartResponse } from "../../../codex-protocol/v2/ThreadStart
|
|
|
9
9
|
import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
|
|
10
10
|
import { type TurnStartParams } from "../../../codex-protocol/v2/TurnStartParams";
|
|
11
11
|
import { type TurnStartResponse } from "../../../codex-protocol/v2/TurnStartResponse";
|
|
12
|
+
import { type StreamCallbacks as CommonStreamCallbacks } from "../common/provider-dispatcher";
|
|
12
13
|
import { CodexRpcClient } from "./codex-rpc";
|
|
13
14
|
|
|
14
15
|
const logger = getLogger(["qgrid", "codex-worker"]);
|
|
@@ -46,13 +47,7 @@ export interface TurnResult {
|
|
|
46
47
|
model: string;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
export
|
|
50
|
-
onDelta: (text: string) => void;
|
|
51
|
-
onComplete: (result: TurnResult) => void;
|
|
52
|
-
onError: (error: Error) => void;
|
|
53
|
-
onThreadId?: (threadId: string) => void;
|
|
54
|
-
onTurnId?: (turnId: string) => void;
|
|
55
|
-
}
|
|
50
|
+
export type StreamCallbacks = CommonStreamCallbacks<TurnResult>;
|
|
56
51
|
|
|
57
52
|
type RefreshableWorkerCredentials = Pick<WorkerConfig, "accessToken" | "accountId" | "planType">;
|
|
58
53
|
type ActiveTurnAbort = (error: Error) => void;
|
|
@@ -191,7 +186,6 @@ export class CodexAppServerWorker {
|
|
|
191
186
|
await this.waitForLoginCompleted();
|
|
192
187
|
this.ready = true;
|
|
193
188
|
this.restartAttempts = 0;
|
|
194
|
-
logger.info(`worker ${this.config.tokenName} ready`);
|
|
195
189
|
}
|
|
196
190
|
|
|
197
191
|
async startBrowserLogin(): Promise<string> {
|
|
@@ -89,12 +89,16 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
89
89
|
|
|
90
90
|
async onTokenRemoved(id: number): Promise<void> {
|
|
91
91
|
const workers = this.workerPool.get(id) ?? [];
|
|
92
|
+
const tokenName = workers[0]?.tokenName;
|
|
92
93
|
this.workerPool.delete(id);
|
|
93
94
|
await Promise.allSettled(workers.map((w) => w.kill()));
|
|
94
95
|
if (this.getAllReadyActiveWorkers().length === 0) {
|
|
95
96
|
this.rejectAllQueued("NO_OPENAI_WORKERS");
|
|
96
97
|
}
|
|
97
|
-
if (workers.length > 0)
|
|
98
|
+
if (workers.length > 0) {
|
|
99
|
+
const label = tokenName ?? `token ${id}`;
|
|
100
|
+
logger.info(`workers removed: ${label} (${workers.length})`);
|
|
101
|
+
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
async onTokenUpdated(id: number, name: string, credentials: OpenAICredentials): Promise<void> {
|
|
@@ -106,7 +110,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
106
110
|
|
|
107
111
|
if (existing.every((w) => w.canReuseForToken(name, credentials))) {
|
|
108
112
|
existing.forEach((w) => w.updateTokenState(name, credentials));
|
|
109
|
-
logger.info(`workers updated in-place
|
|
113
|
+
logger.info(`workers updated in-place: ${name}`);
|
|
110
114
|
this.drainQueue();
|
|
111
115
|
return;
|
|
112
116
|
}
|
|
@@ -117,21 +121,27 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
onTokenDeactivated(id: number): void {
|
|
120
|
-
|
|
124
|
+
const workers = this.workerPool.get(id) ?? [];
|
|
125
|
+
const tokenName = workers[0]?.tokenName;
|
|
126
|
+
workers.forEach((w) => {
|
|
121
127
|
w.active = false;
|
|
122
128
|
});
|
|
123
129
|
if (this.getAllReadyActiveWorkers().length === 0) {
|
|
124
130
|
this.rejectAllQueued("NO_ACTIVE_WORKERS");
|
|
125
131
|
}
|
|
126
|
-
|
|
132
|
+
const label = tokenName ?? `token ${id}`;
|
|
133
|
+
logger.info(`workers deactivated: ${label}`);
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
onTokenActivated(id: number): void {
|
|
130
|
-
|
|
137
|
+
const workers = this.workerPool.get(id) ?? [];
|
|
138
|
+
const tokenName = workers[0]?.tokenName;
|
|
139
|
+
workers.forEach((w) => {
|
|
131
140
|
w.active = true;
|
|
132
141
|
});
|
|
133
142
|
this.drainQueue();
|
|
134
|
-
|
|
143
|
+
const label = tokenName ?? `token ${id}`;
|
|
144
|
+
logger.info(`workers activated: ${label}`);
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
// ── Generate ────────────────────────────────────────────────────
|
|
@@ -403,10 +413,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
403
413
|
const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
|
|
404
414
|
if (worker) workers.push(worker);
|
|
405
415
|
}
|
|
406
|
-
if (workers.length > 0)
|
|
407
|
-
this.workerPool.set(tokenId, workers);
|
|
408
|
-
logger.info(`${workers.length}/${WORKERS_PER_TOKEN} workers spawned for ${tokenName}`);
|
|
409
|
-
}
|
|
416
|
+
if (workers.length > 0) this.workerPool.set(tokenId, workers);
|
|
410
417
|
}
|
|
411
418
|
|
|
412
419
|
async spawnSingleWorker(
|
|
@@ -435,7 +442,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
435
442
|
|
|
436
443
|
try {
|
|
437
444
|
await worker.initialize();
|
|
438
|
-
logger.info(`worker spawned: ${tokenName}[${workerIndex}]
|
|
445
|
+
logger.info(`worker spawned: ${tokenName}[${workerIndex}]`);
|
|
439
446
|
return worker;
|
|
440
447
|
} catch (e) {
|
|
441
448
|
logger.warn(`worker spawn failed: ${tokenName}[${workerIndex}]: ${(e as Error).message}`);
|