@cartanova/qgrid-cli 2.10.2 → 2.10.3

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 (81) hide show
  1. package/bundle/dist/application/monit/log-buffer.js +7 -11
  2. package/bundle/dist/application/monit/monit.frame.js +6 -11
  3. package/bundle/dist/application/qgrid/oauth.js +2 -2
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
  7. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  8. package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
  9. package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
  10. package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
  11. package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
  12. package/bundle/dist/application/request-log/request-log.model.js +7 -5
  13. package/bundle/dist/application/setting/setting.model.js +1 -1
  14. package/bundle/dist/sonamu.config.js +6 -6
  15. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  16. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  17. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  18. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  19. package/bundle/dist/utils/providers/common/effort.js +1 -1
  20. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  21. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  22. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  23. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  24. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  25. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  26. package/bundle/src/application/monit/log-buffer.ts +13 -27
  27. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  28. package/bundle/src/application/monit/monit.frame.ts +9 -14
  29. package/bundle/src/application/qgrid/oauth.ts +1 -1
  30. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
  31. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  32. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  33. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  34. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
  35. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  36. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  37. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  38. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  39. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  40. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  41. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  42. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  43. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  44. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  45. package/bundle/src/application/setting/setting.model.ts +1 -1
  46. package/bundle/src/sonamu.config.ts +5 -5
  47. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  48. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  49. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  50. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  51. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  52. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  53. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  54. package/bundle/src/utils/providers/common/effort.ts +1 -1
  55. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  56. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  57. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  58. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  59. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  60. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  61. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  62. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  63. package/bundle/web-dist/client/assets/{index-DtRTSTes.js → index-BIC1sQxw.js} +3 -3
  64. package/bundle/web-dist/client/assets/{logs-BhVYDEzC.js → logs-zvdujZWn.js} +1 -1
  65. package/bundle/web-dist/client/assets/{monit-BxN4JWqK.js → monit-CraVV3MQ.js} +1 -1
  66. package/bundle/web-dist/client/assets/{routes-CBCqRp2o.js → routes-B3ZBoJ60.js} +1 -1
  67. package/bundle/web-dist/client/assets/{settings-hBTEC7Ki.js → settings-DwbzT5jo.js} +1 -1
  68. package/bundle/web-dist/client/assets/{show-BzYft5N4.js → show-ItTExdAh.js} +1 -1
  69. package/bundle/web-dist/client/assets/{tokens-PMybtI8X.js → tokens-BynKx8Vn.js} +1 -1
  70. package/bundle/web-dist/client/assets/{use-oauth-login-flow-C51_d6-6.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
  71. package/bundle/web-dist/client/index.html +1 -1
  72. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  73. package/package.json +1 -1
  74. package/skills/qgrid/SKILL.md +1 -0
  75. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  76. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  77. package/skills/qgrid/references/decision-rationale.md +9 -0
  78. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  79. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  80. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  81. package/skills/qgrid/references/verification-and-debugging.md +1 -1
@@ -1,14 +1,6 @@
1
1
  /**
2
- * AnthropicDispatcher
3
- *
4
- * OpenAIDispatcher와 달리, worker pool을 두는 대신 요청별 fresh spawn
5
- * 토큰은 인메모리(MAP)으로 관리하고 smooth weighted round-robin 으로 고른다
6
- *
7
- * - 모든 요청은 fresh `--session-id` 로 실행
8
- * - 멀티턴 문맥은 클라이언트가 매 호출 보내는 full history 를 평탄화해 전달한다.
9
- * - 발급한 session-id 를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
10
- * epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다. 다음 turn 이 좌표를
11
- * 회송해도 Anthropic 라우팅에는 쓰지 않는다.
2
+ * 요청마다 새 Claude 프로세스에 전체 대화 이력을 전달한다.
3
+ * 반환한 threadCoord는 다음 요청의 세션 재사용이나 토큰 선택에 쓰지 않는다.
12
4
  */
13
5
 
14
6
  import { getLogger } from "@logtape/logtape";
@@ -26,19 +18,14 @@ import {
26
18
  import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
27
19
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
28
20
  import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
29
- import {
30
- invalidateAnthropicQuotaUsage,
31
- readAnthropicQuotaUsage,
32
- type AnthropicQuotaUsageResult,
33
- } from "./anthropic-quota";
21
+ import { invalidateAnthropicQuotaUsage, readAnthropicQuotaUsage } from "./anthropic-quota";
22
+ import { assertAnthropicThinkingRuntime, resolveAnthropicThinking } from "./anthropic-thinking";
34
23
  import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
35
24
 
36
25
  const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
37
26
 
38
- // 1M context 정상 생성은 실측상 100s+까지 간다. 240s는 완충 장치일 뿐,
39
- // 거부+거대 system 재생성 지연을 완전히 해결하는 값은 아니다.
27
+ // 1M context 생성은 100초 이상 걸릴 수 있다.
40
28
  const DEFAULT_TIMEOUT_MS = 240_000;
41
- // access token 만료 임박 임계 — 기존 standalone 경로(qgrid.dispatcher.ts)와 동일(60s).
42
29
  const REFRESH_SAFETY_MS = 60_000;
43
30
 
44
31
  interface PooledToken {
@@ -49,30 +36,15 @@ interface PooledToken {
49
36
  weight: number;
50
37
  }
51
38
 
52
- function quotaThresholdExceededMessage(
53
- provider: string,
54
- tokens: Array<{ tokenName: string; threshold: number }>,
55
- ): string {
56
- const details = tokens
57
- .map((token) => `${token.tokenName} (threshold ${token.threshold}%)`)
58
- .join(", ");
59
- return details.length > 0
60
- ? `All ${provider} tokens exceeded quota threshold: ${details}`
61
- : `All ${provider} tokens exceeded quota threshold`;
62
- }
63
-
64
39
  export class AnthropicDispatcher implements ProviderDispatcher {
65
- // tokenId → 풀 항목. start()/token 이벤트로 채움
66
- private tokenPool = new Map<number, PooledToken>();
67
- private readonly weightedSelector = new SmoothWeightedRoundRobin();
40
+ tokenPool = new Map<number, PooledToken>();
41
+ readonly weightedSelector = new SmoothWeightedRoundRobin();
42
+ inFlight = 0;
68
43
 
69
44
  async start(): Promise<void> {
70
- // OpenAIDispatcher.start() 와 동일하게 DB 에서 기존 anthropic 토큰을 self-bootstrap 한다.
71
- // (서버 재시작 시 NOTIFY 가 안 오므로 부트스트랩이 없으면 풀이 빈 채로 남는다.)
72
- // 이후 변경분은 token-subscriber 가 onTokenAdded/Updated/Removed 로 전달한다.
73
- // findActiveByProvider 가 active=true + provider 필터를 DB 에서 처리 → inactive 는 애초에 안 온다.
45
+ // NOTIFY는 기존 토큰을 재전송하지 않으므로 시작 시 DB에서 읽는다.
74
46
  const tokens = await TokenModel.findActiveByProvider("A", "anthropic");
75
- for (const t of tokens) {
47
+ tokens.forEach((t) => {
76
48
  this.tokenPool.set(t.id, {
77
49
  id: t.id,
78
50
  name: t.name,
@@ -82,37 +54,17 @@ export class AnthropicDispatcher implements ProviderDispatcher {
82
54
  });
83
55
  this.weightedSelector.setToken(t.id, t.weight);
84
56
  logger.info(`worker spawned: ${t.name}`);
85
- }
57
+ });
86
58
  }
87
59
 
88
60
  async stop(): Promise<void> {
89
61
  const tokenIds = [...this.tokenPool.keys()];
90
- for (const id of tokenIds) this.weightedSelector.removeToken(id);
62
+ tokenIds.forEach((id) => this.weightedSelector.removeToken(id));
91
63
  this.tokenPool.clear();
92
64
  this.weightedSelector.resetScores();
93
65
  }
94
66
 
95
- // 현재 풀의 토큰 수(startup 로그/health 용).
96
- get tokenCount(): number {
97
- return this.tokenPool.size;
98
- }
99
-
100
- // 지금 실행 중인 Claude Code 프로세스 수 — fresh-spawn 이라 이게 곧 동시 요청 수다.
101
- private inFlightCount = 0;
102
- get inFlight(): number {
103
- return this.inFlightCount;
104
- }
105
-
106
- // Monit 표시용 — 풀에 있는 토큰 이름 스냅샷 (이름순 정렬)
107
- get tokenNames(): string[] {
108
- return [...this.tokenPool.values()].map((token) => token.name).toSorted();
109
- }
110
-
111
- // DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
112
- // LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
113
- // 이벤트 핸들러를 재사용해 풀 갱신이 일관되게 적용되도록 한다:
114
- // - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
115
- // - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
67
+ // LISTEN/NOTIFY 연결이 끊긴 동안 놓친 변경을 DB 기준으로 맞춘다.
116
68
  replaceTokens(
117
69
  rows: Array<{
118
70
  id: number;
@@ -123,21 +75,20 @@ export class AnthropicDispatcher implements ProviderDispatcher {
123
75
  }>,
124
76
  ): void {
125
77
  const next = new Set(rows.map((r) => r.id));
126
- // onTokenRemoved 가 tokenPool 을 delete 하므로 순회 중 수정을 피하려 키를 먼저 스냅샷한다.
78
+ // 삭제할 키는 순회 전에 스냅샷한다.
127
79
  const currentIds = Array.from(this.tokenPool.keys());
128
- for (const id of currentIds) {
80
+ currentIds.forEach((id) => {
129
81
  if (!next.has(id)) this.onTokenRemoved(id);
130
- }
131
- for (const r of rows) {
82
+ });
83
+ rows.forEach((r) => {
132
84
  if (this.tokenPool.has(r.id)) {
133
85
  this.onTokenUpdated(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
134
86
  } else {
135
87
  this.onTokenAdded(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
136
88
  }
137
- }
89
+ });
138
90
  }
139
91
 
140
- // token events (token-subscriber 가 호출)
141
92
  onTokenAdded(
142
93
  id: number,
143
94
  name: string,
@@ -167,7 +118,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
167
118
 
168
119
  // 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
169
120
  // 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
170
- private async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
121
+ async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
171
122
  const preferred =
172
123
  preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
173
124
  if (preferredTokenId !== undefined && !preferred) {
@@ -185,8 +136,13 @@ export class AnthropicDispatcher implements ProviderDispatcher {
185
136
  overThresholdTokens,
186
137
  reason: "all_exceeded",
187
138
  });
139
+ const details = overThresholdTokens
140
+ .map((token) => `${token.tokenName} (threshold ${token.threshold}%)`)
141
+ .join(", ");
188
142
  throw new QuotaThresholdExceededError(
189
- quotaThresholdExceededMessage("anthropic", overThresholdTokens),
143
+ details
144
+ ? `All anthropic tokens exceeded quota threshold: ${details}`
145
+ : "All anthropic tokens exceeded quota threshold",
190
146
  );
191
147
  }
192
148
 
@@ -197,7 +153,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
197
153
  return this.tokenPool.get(selectedId) ?? null;
198
154
  }
199
155
 
200
- private async filterEligibleTokens(
156
+ async filterEligibleTokens(
201
157
  rows: PooledToken[],
202
158
  model: string,
203
159
  ): Promise<{
@@ -219,24 +175,35 @@ export class AnthropicDispatcher implements ProviderDispatcher {
219
175
  );
220
176
 
221
177
  checks.forEach((check, index) => {
222
- if (check.status === "rejected") {
223
- const token = thresholded[index];
224
- if (!token) return;
225
- this.logQuotaLookupFailOpen(token, String(check.reason));
226
- eligibleIds.add(token.id);
227
- return;
228
- }
229
-
230
- const token = check.value.token;
231
- const result = check.value.result;
178
+ const token = check.status === "rejected" ? thresholded[index] : check.value.token;
179
+ if (!token) return;
180
+ const result =
181
+ check.status === "fulfilled"
182
+ ? check.value.result
183
+ : { kind: "lookup_failed" as const, reason: String(check.reason) };
232
184
  if (result.kind === "lookup_failed") {
233
- this.logQuotaLookupFailOpen(token, result.reason);
185
+ logger.warn("quota_threshold gate: lookup_fail_open", {
186
+ tokenId: token.id,
187
+ tokenName: token.name,
188
+ provider: "anthropic",
189
+ threshold: token.quotaThreshold,
190
+ reason: "lookup_fail_open",
191
+ lookupReason: result.reason,
192
+ });
234
193
  eligibleIds.add(token.id);
235
194
  return;
236
195
  }
237
196
 
238
197
  if (result.utilizationPct >= token.quotaThreshold) {
239
- this.logQuotaOverThreshold(token, result);
198
+ logger.info("quota_threshold gate: over_threshold", {
199
+ tokenId: token.id,
200
+ tokenName: token.name,
201
+ provider: "anthropic",
202
+ threshold: token.quotaThreshold,
203
+ cachedUtilization: result.utilizationPct,
204
+ cacheAge: result.cacheAgeMs,
205
+ reason: "over_threshold",
206
+ });
240
207
  overThresholdTokens.push({ tokenName: token.name, threshold: token.quotaThreshold });
241
208
  return;
242
209
  }
@@ -247,39 +214,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
247
214
  return { eligible: rows.filter((token) => eligibleIds.has(token.id)), overThresholdTokens };
248
215
  }
249
216
 
250
- private hasQuotaThreshold(token: PooledToken): token is PooledToken & { quotaThreshold: number } {
217
+ hasQuotaThreshold(token: PooledToken): token is PooledToken & { quotaThreshold: number } {
251
218
  return token.quotaThreshold !== undefined && token.quotaThreshold !== null;
252
219
  }
253
220
 
254
- private logQuotaOverThreshold(
255
- token: PooledToken & { quotaThreshold: number },
256
- result: Extract<AnthropicQuotaUsageResult, { kind: "ok" }>,
257
- ): void {
258
- logger.info("quota_threshold gate: over_threshold", {
259
- tokenId: token.id,
260
- tokenName: token.name,
261
- provider: "anthropic",
262
- threshold: token.quotaThreshold,
263
- cachedUtilization: result.utilizationPct,
264
- cacheAge: result.cacheAgeMs,
265
- reason: "over_threshold",
266
- });
267
- }
268
-
269
- private logQuotaLookupFailOpen(
270
- token: PooledToken & { quotaThreshold: number },
271
- reason: string,
272
- ): void {
273
- logger.warn("quota_threshold gate: lookup_fail_open", {
274
- tokenId: token.id,
275
- tokenName: token.name,
276
- provider: "anthropic",
277
- threshold: token.quotaThreshold,
278
- reason: "lookup_fail_open",
279
- lookupReason: reason,
280
- });
281
- }
282
-
283
221
  async generate(req: GenerateRequest): Promise<GenerateResult> {
284
222
  return this.run(req, () => {});
285
223
  }
@@ -294,38 +232,26 @@ export class AnthropicDispatcher implements ProviderDispatcher {
294
232
  }
295
233
  }
296
234
 
297
- // generate/generateStream 공통 실행. onDelta 는 스트림이면 점진 방출, 비스트림이면 no-op.
298
- private async run(...args: Parameters<AnthropicDispatcher["runInner"]>): Promise<GenerateResult> {
299
- this.inFlightCount++;
300
- try {
301
- return await this.runInner(...args);
302
- } finally {
303
- this.inFlightCount--;
304
- }
305
- }
306
-
307
- private async runInner(
235
+ async run(
308
236
  req: GenerateRequest,
309
237
  onDelta: (t: string) => void,
310
238
  opts?: { includePartialMessages?: boolean },
311
239
  ): Promise<GenerateResult> {
312
- // 이미지 생성은 OpenAI 경로 전용. Anthropic 라우팅에 이미지 플래그가
313
- // 도달하면 조용히 텍스트로 폴백하지 않고 명시적으로 거부한다.
314
- if (req.imageGeneration) {
315
- throw new Error("image generation is not supported on the Anthropic route");
316
- }
317
- // model 정규화를 dispatcher 진입점에서 한 번 한다: result.model / runClaudeSession 전부
318
- // canonical(prefix 없는 cost 키) 을 쓰게 통일. 미지정이면 ANTHROPIC_DEFAULT_MODEL — qgrid.dispatcher
319
- // 의 "sonnet" 별칭 우회 차단. 정규화 규칙은 fallback 경로와 공유(canonicalAnthropicModel).
320
- assertSupportedOneMillionSuffix(req.model);
321
- const model = canonicalAnthropicModel(req.model);
322
- const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
240
+ this.inFlight++;
241
+ try {
242
+ if (req.imageGeneration) {
243
+ throw new Error("image generation is not supported on the Anthropic route");
244
+ }
245
+ assertSupportedOneMillionSuffix(req.model);
246
+ const model = canonicalAnthropicModel(req.model);
247
+ const thinking = resolveAnthropicThinking(model, req.thinking, req.effort);
248
+ assertAnthropicThinkingRuntime(thinking.extraBody);
249
+ const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
323
250
 
324
- const token = await this.selectToken(model, req.preferredTokenId);
325
- if (!token) throw new Error("No anthropic tokens available");
251
+ const token = await this.selectToken(model, req.preferredTokenId);
252
+ if (!token) throw new Error("No anthropic tokens available");
326
253
 
327
- const exec = async (): Promise<GenerateResult> => {
328
- // 만료 임박 토큰 preemptive refresh(기존 standalone 경로와 동일). 실패해도 진행.
254
+ // 만료 직전에 갱신하되 실패하면 기존 토큰으로 진행한다.
329
255
  let accessToken = token.credentials.accessToken;
330
256
  const expiresAt = getExpiresAt(token.credentials);
331
257
  if (
@@ -336,7 +262,6 @@ export class AnthropicDispatcher implements ProviderDispatcher {
336
262
  try {
337
263
  const { QgridFrame } = await import("../../../application/qgrid/qgrid.frame");
338
264
  // provider 를 반드시 채워야 refreshToken 내부 TokenModel.save 가 성공한다.
339
- // refreshToken(TokenSubsetA) 는 id/provider/credentials/name 만 사용하므로 그 4개만 넘긴다.
340
265
  accessToken = await QgridFrame.refreshToken({
341
266
  id: token.id,
342
267
  provider: "anthropic",
@@ -357,6 +282,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
357
282
  system: req.systemPrompt,
358
283
  jsonSchema,
359
284
  effort: req.effort,
285
+ thinking: req.thinking,
360
286
  timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
361
287
  coldHistory: req.coldHistory,
362
288
  input: req.coldInput,
@@ -374,9 +300,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
374
300
  throw new Error(`quota exhausted (${token.name})`);
375
301
  }
376
302
  if (session.isError) {
377
- // 진단(SON-495): isError 판정 사유(subtype/terminal_reason)를 메시지 앞에 드러낸다.
378
- // 그동안 detail 이 session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /
379
- // max turns 인지"를 구분할 수 없었다. text 는 진단에 필요한 만큼만 잘라 덧붙인다.
303
+ // 종료 사유를 먼저 표시하고 응답 본문은 진단에 필요한 만큼만 남긴다.
380
304
  const reason =
381
305
  session.subtype ?? session.terminalReason ?? (session.isError ? "is_error" : "unknown");
382
306
  const body = session.text
@@ -395,20 +319,18 @@ export class AnthropicDispatcher implements ProviderDispatcher {
395
319
  durationMs: session.durationMs,
396
320
  ttftMs: session.ttftMs,
397
321
  costUsd: session.costUsd,
398
- // Claude Code 는 Fable refusal 을 Opus 로 자동 fallback 할 수 있다. 요청 모델을
399
- // 그대로 serving model 로 기록하면 비용/감사 로그가 모두 틀어지므로 둘을 구분한다.
322
+ // Claude Code의 모델 fallback을 비용·감사 로그에 반영한다.
400
323
  model: session.servedModel ?? model,
401
324
  requestedModel: model,
402
325
  modelFallbacks: session.modelFallbacks,
403
- // systemHash 는 상위 issueConvContext 가 채운다(여기선 비운다).
404
326
  threadCoord: {
405
327
  workerId: makeAnthropicWorkerId(token.id),
406
328
  threadId: session.sessionId,
407
329
  epoch: 0,
408
330
  },
409
331
  };
410
- };
411
-
412
- return exec();
332
+ } finally {
333
+ this.inFlight--;
334
+ }
413
335
  }
414
336
  }
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const version = vi.hoisted(() => vi.fn());
4
+ vi.mock("node:child_process", () => ({ execFileSync: version }));
5
+
6
+ describe("Anthropic native extra-body thinking modes", () => {
7
+ beforeEach(() => {
8
+ vi.resetModules();
9
+ version.mockReset();
10
+ });
11
+
12
+ it.each([
13
+ ["claude-haiku-5-5", "disabled"],
14
+ ["claude-sonnet-5-5", "between_tools"],
15
+ ])("%s only overrides thinking and ignores caller effort", async (model, type) => {
16
+ const { resolveAnthropicThinking, assertAnthropicThinkingRuntime } = await import("./anthropic-thinking");
17
+ version.mockReturnValue("2.1.295 (Claude Code)");
18
+ const policy = resolveAnthropicThinking(model, false, "max");
19
+ expect(policy.mode).toBe("disabled");
20
+ expect(policy.effort).toBe("low");
21
+ expect(JSON.parse(policy.extraBody!)).toEqual({thinking: {type}});
22
+ assertAnthropicThinkingRuntime(policy.extraBody);
23
+ assertAnthropicThinkingRuntime(policy.extraBody);
24
+ expect(version).toHaveBeenCalledTimes(1);
25
+ expect(resolveAnthropicThinking(model).extraBody).toBeUndefined();
26
+ expect(resolveAnthropicThinking(model, true).extraBody).toBeUndefined();
27
+ });
28
+
29
+ it.each(["2.1.294 (Claude Code)", "unrecognized"])("rejects unverified runtime %s without affecting other models", async (output) => {
30
+ const { assertAnthropicThinkingRuntime } = await import("./anthropic-thinking");
31
+ version.mockReturnValue(output);
32
+ expect(() => assertAnthropicThinkingRuntime(undefined)).not.toThrow();
33
+ expect(version).not.toHaveBeenCalled();
34
+ expect(() => assertAnthropicThinkingRuntime('{"thinking":{"type":"disabled"}}')).toThrow("2.1.295");
35
+ });
36
+ });
@@ -0,0 +1,84 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ import { resolveAnthropicEffort } from "../common/effort";
4
+ import { ThinkingValidationError } from "../common/thinking";
5
+ import {
6
+ ANTHROPIC_DEFAULT_EFFORT,
7
+ canonicalAnthropicModel,
8
+ usesAdaptiveThinking,
9
+ } from "./anthropic-constants";
10
+
11
+ const THINKING_OFF_MODELS = new Set([
12
+ "claude-haiku-4-5",
13
+ "claude-sonnet-4-5",
14
+ "claude-sonnet-4-6",
15
+ "claude-sonnet-5",
16
+ "claude-opus-4-5",
17
+ "claude-opus-4-6",
18
+ "claude-opus-4-7",
19
+ "claude-opus-4-8",
20
+ "claude-opus-5",
21
+ "claude-haiku-5-5",
22
+ "claude-sonnet-5-5",
23
+ ]);
24
+
25
+ let extraBodyRuntimeSupported: boolean | undefined;
26
+
27
+ /** These modes were verified through native extra-body delivery on Claude Code 2.1.295. */
28
+ export function assertAnthropicThinkingRuntime(extraBody: string | undefined): void {
29
+ if (!extraBody) return;
30
+ if (extraBodyRuntimeSupported === undefined) {
31
+ try {
32
+ const version = execFileSync("claude", ["--version"], {
33
+ encoding: "utf8",
34
+ timeout: 5000,
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR },
37
+ }).match(/^(\d+)\.(\d+)\.(\d+)/);
38
+ const [major, minor, patch] = version?.slice(1).map(Number) ?? [];
39
+ extraBodyRuntimeSupported =
40
+ major !== undefined &&
41
+ minor !== undefined &&
42
+ patch !== undefined &&
43
+ (major > 2 || (major === 2 && (minor > 1 || (minor === 1 && patch >= 295))));
44
+ } catch {
45
+ extraBodyRuntimeSupported = false;
46
+ }
47
+ }
48
+ if (!extraBodyRuntimeSupported) {
49
+ throw new ThinkingValidationError(
50
+ "thinking:false for Haiku 5.5 and Sonnet 5.5 requires Claude Code 2.1.295 or later; runtime version could not be verified",
51
+ );
52
+ }
53
+ }
54
+
55
+ export function resolveAnthropicThinking(model: string, thinking?: boolean, effort?: string) {
56
+ const canonical = canonicalAnthropicModel(model);
57
+ if (thinking === false && !THINKING_OFF_MODELS.has(canonical)) {
58
+ const reason = usesAdaptiveThinking(canonical)
59
+ ? "this model requires thinking"
60
+ : "thinking-off support has not been verified for this model";
61
+ throw new ThinkingValidationError(
62
+ `thinking:false is not supported for anthropic/${canonical}: ${reason}`,
63
+ );
64
+ }
65
+ return {
66
+ // Never accept caller-provided extra body data or inherit it from the host environment.
67
+ extraBody:
68
+ thinking === false && (canonical === "claude-haiku-5-5" || canonical === "claude-sonnet-5-5")
69
+ ? JSON.stringify({
70
+ thinking: { type: canonical === "claude-sonnet-5-5" ? "between_tools" : "disabled" },
71
+ })
72
+ : undefined,
73
+ mode:
74
+ thinking === true
75
+ ? ("adaptive" as const)
76
+ : thinking === false || !usesAdaptiveThinking(canonical)
77
+ ? ("disabled" as const)
78
+ : undefined,
79
+ effort:
80
+ thinking === false
81
+ ? ANTHROPIC_DEFAULT_EFFORT
82
+ : (resolveAnthropicEffort(effort) ?? ANTHROPIC_DEFAULT_EFFORT),
83
+ };
84
+ }
@@ -1,6 +1,9 @@
1
+ import * as childProcess from "node:child_process";
2
+ import { EventEmitter } from "node:events";
1
3
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { PassThrough } from "node:stream";
2
5
 
3
- import { describe, expect, it } from "vitest";
6
+ import { describe, expect, it, vi } from "vitest";
4
7
 
5
8
  import { ANTHROPIC_CONFIG_DIR_BASE, anthropicConfigDir } from "./anthropic-constants";
6
9
  import {
@@ -13,12 +16,17 @@ import {
13
16
  runClaudeSession,
14
17
  shouldPinStructuredRetries,
15
18
  structuredOutputRetriesEnv,
16
- thinkingEnv,
17
19
  SYSTEM_PROMPT_ARGV_MAX_BYTES,
18
20
  withSessionLock,
19
21
  } from "./claude-session";
20
22
  import { buildStreamJsonInput } from "./stream-json-adapter";
21
23
 
24
+ vi.mock("node:child_process", async (importOriginal) => ({
25
+ ...await importOriginal<typeof import("node:child_process")>(),
26
+ spawn: vi.fn(),
27
+ execFileSync: vi.fn(() => "2.1.295 (Claude Code)"),
28
+ }));
29
+
22
30
  describe("makeAnthropicWorkerId (coord 매핑 P0-2)", () => {
23
31
  it("tokenId 기반 안정 합성", () => {
24
32
  expect(makeAnthropicWorkerId(7)).toBe(7);
@@ -74,6 +82,54 @@ describe("ensureConfigDir (R10 격리 seed self-healing)", () => {
74
82
  });
75
83
  });
76
84
 
85
+ describe("runClaudeSession thinking and environment boundary", () => {
86
+ it.each([
87
+ {model: "claude-opus-4-8", thinking: undefined},
88
+ {model: "claude-opus-4-8", thinking: true},
89
+ {model: "claude-opus-4-8", thinking: false},
90
+ {model: "claude-haiku-5-5", thinking: false},
91
+ {model: "claude-sonnet-5-5", thinking: false},
92
+ ])("$model thinking=$thinking preserves isolation without competing env controls", async ({model, thinking}) => {
93
+ const tokenId = -990002;
94
+ const child = Object.assign(new EventEmitter(), {
95
+ stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), kill: vi.fn(),
96
+ });
97
+ const spawn = vi.mocked(childProcess.spawn).mockImplementation(() => {
98
+ queueMicrotask(() => child.stdout.write(`${JSON.stringify({
99
+ type: "result", subtype: "success", result: "OK", is_error: false,
100
+ usage: {input_tokens: 1, output_tokens: 1},
101
+ })}\n`));
102
+ return child as unknown as ReturnType<typeof childProcess.spawn>;
103
+ });
104
+ vi.stubEnv("ANTHROPIC_API_KEY", "must-not-inherit");
105
+ vi.stubEnv("MAX_THINKING_TOKENS", "0");
106
+ vi.stubEnv("CLAUDE_CODE_EXTRA_BODY", '{"model":"must-not-inherit"}');
107
+ try {
108
+ const result = await runClaudeSession({
109
+ tokenId, token: "test-oauth", model, thinking, effort: "high", timeoutMs: 1000,
110
+ input: [{type: "text", text: "OK", text_elements: []}],
111
+ }, () => {});
112
+ expect(result.text).toBe("OK");
113
+ const [, args, options] = spawn.mock.calls[0]!;
114
+ expect(args).toContain("--thinking");
115
+ expect(args).toContain(thinking === true ? "adaptive" : "disabled");
116
+ expect(options?.env).toMatchObject({
117
+ CLAUDE_CODE_OAUTH_TOKEN: "test-oauth", CLAUDE_CONFIG_DIR: anthropicConfigDir(tokenId),
118
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
119
+ CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1", CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
120
+ });
121
+ for (const key of ["ANTHROPIC_API_KEY", "MAX_THINKING_TOKENS", "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"]) {
122
+ expect(options?.env).not.toHaveProperty(key);
123
+ }
124
+ const extraBody = options?.env?.CLAUDE_CODE_EXTRA_BODY;
125
+ if (model === "claude-opus-4-8") expect(extraBody).toBeUndefined();
126
+ else expect(JSON.parse(extraBody!)).toEqual({thinking: {type: model === "claude-sonnet-5-5" ? "between_tools" : "disabled"}});
127
+ } finally {
128
+ spawn.mockRestore(); vi.unstubAllEnvs(); rmSync(anthropicConfigDir(tokenId), {recursive: true, force: true});
129
+ }
130
+ });
131
+ });
132
+
77
133
  describe("withSessionLock (per-session 락 P0-3)", () => {
78
134
  it("같은 session-id 의 동시 호출은 직렬화된다", async () => {
79
135
  const order: Array<string> = [];
@@ -134,19 +190,24 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
134
190
  expect(oneMillionEnv(false)).toEqual({ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" });
135
191
  });
136
192
 
137
- it("thinkingEnv: Fable 5/5.1/Opus 5 는 adaptive thinking 을 보존하고 나머지는 비활성화", () => {
138
- expect(thinkingEnv("claude-fable-5-1")).toEqual({});
139
- expect(thinkingEnv("anthropic/claude-fable-5-1")).toEqual({});
140
- expect(thinkingEnv("claude-fable-5")).toEqual({});
141
- expect(thinkingEnv("anthropic/claude-fable-5")).toEqual({});
142
- expect(thinkingEnv("claude-opus-5")).toEqual({});
143
- expect(thinkingEnv("anthropic/claude-opus-5")).toEqual({});
144
- expect(thinkingEnv("claude-opus-4-8")).toEqual({
145
- CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
146
- MAX_THINKING_TOKENS: "0",
147
- });
193
+ it("thinking true enables a legacy default-off model and preserves effort", () => {
194
+ const args = buildClaudeArgs({model: "claude-opus-4-8", sessionId: "u", thinking: true, effort: "high"});
195
+ expect(args[args.indexOf("--thinking") + 1]).toBe("adaptive");
196
+ expect(args[args.indexOf("--effort") + 1]).toBe("high");
148
197
  });
149
198
 
199
+ it("thinking false disables a supported default-adaptive model and ignores effort", () => {
200
+ const args = buildClaudeArgs({model: "anthropic/claude-opus-5", sessionId: "u", thinking: false, effort: "max"});
201
+ expect(args[args.indexOf("--thinking") + 1]).toBe("disabled");
202
+ expect(args[args.indexOf("--effort") + 1]).toBe("low");
203
+ });
204
+
205
+ it.each(["claude-fable-5-1", "claude-fable-5", "claude-opus-5-5", "claude-opus-4-1", "unknown"])(
206
+ "thinking false rejects %s instead of silently changing its model or thinking mode", (model) => {
207
+ expect(() => buildClaudeArgs({model, sessionId: "u", thinking: false})).toThrow(`anthropic/${model}`);
208
+ },
209
+ );
210
+
150
211
  // SON-495: structured output retry 를 1 로 고정한다.
151
212
  it("structuredOutputRetriesEnv: 미설정/비정상 값은 1 로 기본 고정", () => {
152
213
  expect(structuredOutputRetriesEnv(undefined)).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });