@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,12 +1,6 @@
1
1
  /**
2
- * Monit 로그 ring buffer — 프로세스 생존 기간 동안 최근 로그를 구조화 레코드로 보관한다.
3
- *
4
- * 콘솔 sink 와 병렬로 등록되는 두 번째 logtape sink 가 이 버퍼를 채우고,
5
- * monit.frame 이 커서 기반으로 읽어 대시보드 Monit 탭에 제공한다.
6
- * 재시작하면 리셋된다 — 영속 기록은 request_log 의 몫.
7
- *
8
- * timestamp/processStartedAt 은 epoch ms 숫자로 유지한다. web 의 전역 dateReviver 가
9
- * ISO 문자열을 Date 로 바꿔버리므로, 숫자만이 재시작 감지의 동등성 비교를 보존한다.
2
+ * 재시작하면 초기화되는 Monit 로그 버퍼.
3
+ * timestamp/processStartedAt은 web의 dateReviver 변환을 피하고 동등성 비교를 유지하도록 epoch ms를 쓴다.
10
4
  */
11
5
  import { type LogRecord, type Sink } from "@logtape/logtape";
12
6
 
@@ -48,16 +42,14 @@ function renderValue(value: unknown): string {
48
42
 
49
43
  export class MonitLogBuffer {
50
44
  readonly processStartedAt = Date.now();
51
- private entries: MonitLogEntry[] = [];
52
- private nextSeq = 1;
45
+ entries: MonitLogEntry[] = [];
46
+ nextSeq = 1;
53
47
 
54
48
  constructor(
55
- private readonly capacity = DEFAULT_CAPACITY,
56
- private readonly maxTextLength = DEFAULT_MAX_TEXT_LENGTH,
49
+ readonly capacity = DEFAULT_CAPACITY,
50
+ readonly maxTextLength = DEFAULT_MAX_TEXT_LENGTH,
57
51
  ) {}
58
52
 
59
- // logtape sink 본체. logtape 가 sink 오류를 격리하지만, 버퍼 버그가
60
- // meta logger 소음을 만들지 않도록 방어적으로 한 번 더 감싼다.
61
53
  push(record: LogRecord): void {
62
54
  try {
63
55
  let text = flattenMessage(record.message);
@@ -79,31 +71,25 @@ export class MonitLogBuffer {
79
71
  }
80
72
  }
81
73
 
82
- get oldestSeq(): number {
83
- return this.entries[0]?.seq ?? this.nextSeq;
84
- }
85
-
86
- get latestSeq(): number {
87
- return this.nextSeq - 1;
88
- }
89
-
90
- // cursor 이후의 엔트리를 최대 limit 개 반환한다. cursor 미지정이면 tail.
74
+ // cursor 미지정이면 최근 엔트리부터 반환한다.
91
75
  after(cursor: number | undefined, limit: number): MonitLogChunk {
76
+ const latestSeq = this.nextSeq - 1;
77
+ const oldestSeq = this.entries[0]?.seq ?? this.nextSeq;
92
78
  if (cursor === undefined) {
93
79
  const entries = this.entries.slice(-limit);
94
80
  return {
95
81
  entries,
96
- nextCursor: entries.at(-1)?.seq ?? this.latestSeq,
82
+ nextCursor: entries.at(-1)?.seq ?? latestSeq,
97
83
  dropped: 0,
98
84
  };
99
85
  }
100
86
 
101
87
  // 이전 프로세스의 커서 등 미래 커서는 방어적으로 현재 tail 로 재동기화한다.
102
- if (cursor > this.latestSeq) {
103
- return { entries: [], nextCursor: this.latestSeq, dropped: 0 };
88
+ if (cursor > latestSeq) {
89
+ return { entries: [], nextCursor: latestSeq, dropped: 0 };
104
90
  }
105
91
 
106
- const dropped = Math.max(this.oldestSeq - cursor - 1, 0);
92
+ const dropped = Math.max(oldestSeq - cursor - 1, 0);
107
93
  const startIndex = this.entries.findIndex((entry) => entry.seq > cursor);
108
94
  const entries = startIndex === -1 ? [] : this.entries.slice(startIndex, startIndex + limit);
109
95
  return {
@@ -36,12 +36,12 @@ describe("MonitFrame.monitLogs", () => {
36
36
  const chunk = await MonitFrame.monitLogs();
37
37
  expect(chunk.processStartedAt).toBe(monitLogBuffer.processStartedAt);
38
38
  expect(chunk.dropped).toBe(0);
39
- expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
39
+ expect(chunk.nextCursor).toBe(monitLogBuffer.after(undefined, 1).nextCursor);
40
40
  expect(chunk.entries.at(-1)?.text).toBe("second line");
41
41
  });
42
42
 
43
43
  it("returns only entries newer than the cursor with no boundary duplicates", async () => {
44
- const baseline = monitLogBuffer.latestSeq;
44
+ const baseline = monitLogBuffer.after(undefined, 1).nextCursor;
45
45
  push("after baseline 1");
46
46
  push("after baseline 2");
47
47
 
@@ -50,7 +50,7 @@ describe("MonitFrame.monitLogs", () => {
50
50
  "after baseline 1",
51
51
  "after baseline 2",
52
52
  ]);
53
- expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
53
+ expect(chunk.nextCursor).toBe(monitLogBuffer.after(undefined, 1).nextCursor);
54
54
 
55
55
  const followUp = await MonitFrame.monitLogs(chunk.nextCursor);
56
56
  expect(followUp.entries).toEqual([]);
@@ -90,7 +90,7 @@ describe("MonitFrame.monitLogs", () => {
90
90
  properties: { accessToken: "forbidden-property-secret", refreshToken: "also-forbidden" },
91
91
  });
92
92
 
93
- const chunk = await MonitFrame.monitLogs(monitLogBuffer.latestSeq - 1);
93
+ const chunk = await MonitFrame.monitLogs(monitLogBuffer.after(undefined, 1).nextCursor - 1);
94
94
  const serialized = JSON.stringify(chunk);
95
95
  expect(serialized).not.toContain("forbidden-property-secret");
96
96
  expect(serialized).not.toContain("also-forbidden");
@@ -115,14 +115,13 @@ describe("MonitFrame.monitLogs", () => {
115
115
  it("piggybacks live vitals on every chunk, zeroed before dispatchers boot", async () => {
116
116
  QgridDispatcher.openaiDispatcher = {
117
117
  inFlight: 3,
118
- quotaByToken: [
118
+ getQuotaSnapshot: () => [
119
119
  { name: "openai/haze", usedPercent: 41, threshold: 80, blocked: false, resetsAt: null },
120
120
  { name: "openai/nk", usedPercent: null, threshold: null, blocked: false, resetsAt: null },
121
121
  ],
122
122
  } as never;
123
123
  QgridDispatcher.anthropicDispatcher = {
124
- tokenCount: 8,
125
- tokenNames: ["anthropic/haze", "anthropic/noa"],
124
+ tokenPool: new Map([[1, {name: "anthropic/noa"}], [2, {name: "anthropic/haze"}]]),
126
125
  inFlight: 2,
127
126
  } as never;
128
127
  try {
@@ -133,7 +132,7 @@ describe("MonitFrame.monitLogs", () => {
133
132
  { name: "openai/haze", usedPercent: 41, threshold: 80, blocked: false, resetsAt: null },
134
133
  { name: "openai/nk", usedPercent: null, threshold: null, blocked: false, resetsAt: null },
135
134
  ],
136
- anthropicTokenCount: 8,
135
+ anthropicTokenCount: 2,
137
136
  anthropicTokenNames: ["anthropic/haze", "anthropic/noa"],
138
137
  anthropicInFlight: 2,
139
138
  });
@@ -1,9 +1,4 @@
1
- /**
2
- * Monit — 대시보드 Monit 탭용 프로세스 로그 관측 API.
3
- *
4
- * 읽기 전용. ring buffer 를 얇게 직접 읽으며(범용 쿼리 기계 없이),
5
- * request log 를 남기지 않는다. 접근 경계는 다른 대시보드 API 와 동일하다.
6
- */
1
+ // 읽기 전용 모니터링 API. 조회 자체는 request log에 남기지 않는다.
7
2
  import { api, BaseFrameClass, DB } from "sonamu";
8
3
 
9
4
  import { resolveOpenAITransportKind } from "../../utils/providers/openai/openai-transport-config";
@@ -49,9 +44,7 @@ class MonitFrameClass extends BaseFrameClass {
49
44
  };
50
45
  }
51
46
 
52
- // 최근 1시간 provider 별 요청/오류/캐시 통계. vitals 와 달리 request_logs 를 타므로
53
- // 분리된 endpoint 로 두고 웹에서 더 느슨한 주기로 폴링한다. 읽기 전용이며 request log 를
54
- // 남기지 않는 monit 경계는 동일하다.
47
+ // DB를 조회하므로 메모리 기반 vitals보다 느린 주기로 폴링한다.
55
48
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
56
49
  async monitStats(): Promise<MonitStats> {
57
50
  const windowMinutes = 60;
@@ -60,14 +53,16 @@ class MonitFrameClass extends BaseFrameClass {
60
53
  }
61
54
  }
62
55
 
63
- // dispatcher 미초기화(부팅 직후) 시 0 으로 응답한다 — 가벼운 스냅샷이라 오류로 만들지 않는다.
64
56
  function currentVitals(): MonitVitals {
57
+ const anthropic = QgridDispatcher.anthropicDispatcher;
65
58
  return {
66
59
  openaiInFlight: QgridDispatcher.openaiDispatcher?.inFlight ?? 0,
67
- openaiQuotaByToken: QgridDispatcher.openaiDispatcher?.quotaByToken ?? [],
68
- anthropicTokenCount: QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0,
69
- anthropicTokenNames: QgridDispatcher.anthropicDispatcher?.tokenNames ?? [],
70
- anthropicInFlight: QgridDispatcher.anthropicDispatcher?.inFlight ?? 0,
60
+ openaiQuotaByToken: QgridDispatcher.openaiDispatcher?.getQuotaSnapshot() ?? [],
61
+ anthropicTokenCount: anthropic?.tokenPool.size ?? 0,
62
+ anthropicTokenNames: anthropic
63
+ ? [...anthropic.tokenPool.values()].map((token) => token.name).toSorted()
64
+ : [],
65
+ anthropicInFlight: anthropic?.inFlight ?? 0,
71
66
  };
72
67
  }
73
68
 
@@ -132,7 +132,7 @@ export class RefreshFailedError extends Error {
132
132
  }
133
133
 
134
134
  /** 재로그인 외 복구 불가한 인증 실패인지. 일시 오류(5xx·네트워크)는 제외된다. */
135
- get isAuthDead(): boolean {
135
+ isAuthDead(): boolean {
136
136
  return this.status === 400 || this.status === 401;
137
137
  }
138
138
  }
@@ -122,6 +122,13 @@ describe("qgrid run lifecycle start", () => {
122
122
  });
123
123
  });
124
124
 
125
+ it.each([false, true, undefined])("does not log ignored effort when thinking is %s", async (thinking) => {
126
+ await beforeQuery({ prompt: "hi", model: "openai/gpt-6-sol", thinking, effort: "high" });
127
+ expect(createRunMock).toHaveBeenCalledWith(expect.objectContaining({
128
+ effort: thinking === false ? undefined : "high",
129
+ }));
130
+ });
131
+
125
132
  it.each([false, true])("classifies automatic image output only when produced (%s)", async (hasImage) => {
126
133
  const args = { prompt: "chat", model: "openai/gpt-6-astra", imageGeneration: "auto" as const };
127
134
  const run = await beforeQuery(args);
@@ -131,7 +131,7 @@ export async function beforeQuery(args: QueryInput): Promise<{
131
131
  user_prompt: args.prompt,
132
132
  system_prompt: args.system,
133
133
  requested_model_name: args.model,
134
- effort: args.effort,
134
+ effort: args.thinking === false ? undefined : args.effort,
135
135
  project_name: args.projectName,
136
136
  history: filterHistoryForStorage(args.history),
137
137
  tools: args.tools?.length ? args.tools : undefined,
@@ -6,8 +6,10 @@ import {
6
6
  type GenerateStreamCallbacks,
7
7
  } from "../../utils/providers/common/provider-dispatcher";
8
8
  import { systemHash } from "./conv-routing";
9
+ import { OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
10
+ import { normalizeOpenAIEvent } from "../../utils/providers/openai/openai-backend-protocol";
9
11
  import { buildStrictOutputSchema, QgridDispatcherClass } from "./qgrid.dispatcher";
10
- import { type QueryOutput } from "./qgrid.types";
12
+ import { QueryInput, type QueryOutput } from "./qgrid.types";
11
13
  import { type TokenSubsetA } from "../sonamu.generated";
12
14
 
13
15
  function providerResult(overrides: Partial<GenerateResult> = {}): GenerateResult {
@@ -34,6 +36,65 @@ function deeplyNestedOutputSchema(depth: number): string {
34
36
  }
35
37
 
36
38
  describe("QgridDispatcherClass", () => {
39
+ it.each(["generate", "stream"])("%s yields a completed tool-call message before a speculative later answer", async (mode) => {
40
+ const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "lookupMarker", args: "{}"}]}});
41
+ const answer = JSON.stringify({result: {action: "answer", answer: {marker: ""}, toolCalls: null}});
42
+ const provider = new OpenAIDispatcher("https", {clientFactory: () => ({
43
+ async *responses() {
44
+ yield normalizeOpenAIEvent({type: "response.output_item.added", item: {id: "preamble", type: "message", phase: "commentary", content: []}})!;
45
+ yield normalizeOpenAIEvent({type: "response.output_text.delta", item_id: "preamble", delta: "We need request tool structured."})!;
46
+ yield normalizeOpenAIEvent({type: "response.output_item.done", item: {id: "preamble", type: "message", phase: "commentary", content: [{type: "output_text", text: "We need request tool structured."}]}})!;
47
+ for (const [id, phase, text] of [["tool", "commentary", tool], ["answer", "final_answer", answer]]) {
48
+ yield normalizeOpenAIEvent({type: "response.output_item.added", item: {id, type: "message", role: "assistant", phase, content: []}})!;
49
+ yield normalizeOpenAIEvent({type: "response.output_text.delta", item_id: id, delta: text})!;
50
+ yield normalizeOpenAIEvent({type: "response.output_item.done", item: {id, type: "message", role: "assistant", phase, content: [{type: "output_text", text}]}})!;
51
+ }
52
+ yield normalizeOpenAIEvent({type: "response.completed", response: {id: "r", model: "gpt-6-luna", usage: {input_tokens: 10, output_tokens: 30, total_tokens: 40}}})!;
53
+ },
54
+ })});
55
+ await provider.onTokenAdded(1, "test", {accessToken: "unused", refreshToken: "", accountId: "unused", accessTokenExpiresAt: Date.now()+60000});
56
+ const dispatcher = new QgridDispatcherClass();
57
+ dispatcher.openaiDispatcher = provider;
58
+ const input = {model: "openai/gpt-6-luna", prompt: "lookup", tools: [{name: "lookupMarker", inputSchema: {type: "object"}}], jsonSchema: JSON.stringify({type: "object",properties: {marker: {type: "string"}}})};
59
+ const deltas: string[] = [];
60
+ let result: QueryOutput | undefined;
61
+ if (mode === "generate") result = await dispatcher.query(input);
62
+ else await dispatcher.queryStream(input, {onDelta: text => deltas.push(text), onComplete: value => {result = value;}, onError: error => {throw error;}});
63
+ expect(result?.finishReason).toBe("tool-calls");
64
+ expect(result?.content[0]).toMatchObject({type: "tool-call", toolName: "lookupMarker", input: "{}"});
65
+ expect(result?.usage.output_tokens).toBe(30); // Drain through completion; don't undercount discarded text.
66
+ if (mode === "stream") expect(deltas.join("")).toBe(tool);
67
+ await provider.stop();
68
+ });
69
+
70
+ it("accepts only boolean thinking values on both query and prepareStream's shared input", () => {
71
+ for (const thinking of [true, false, undefined]) {
72
+ expect(QueryInput.parse({ prompt: "hi", thinking }).thinking).toBe(thinking);
73
+ }
74
+ expect(QueryInput.safeParse({ prompt: "hi", thinking: "false" }).success).toBe(false);
75
+ });
76
+
77
+ it.each(["openai/gpt-6-sol", "anthropic/claude-sonnet-4-6"])(
78
+ "forwards thinking on generate and stream, suppressing effort only for explicit off (%s)",
79
+ async (model) => {
80
+ const dispatcher = new QgridDispatcherClass();
81
+ const generate = vi.fn(async () => providerResult());
82
+ const generateStream = vi.fn(async (_req: GenerateRequest, cb: GenerateStreamCallbacks) => {
83
+ cb.onComplete(providerResult());
84
+ });
85
+ dispatcher.openaiDispatcher = { generate, generateStream } as never;
86
+ dispatcher.anthropicDispatcher = { generate, generateStream } as never;
87
+ for (const thinking of [false, true, undefined]) {
88
+ const input = { prompt: "hi", model, thinking, effort: "high" };
89
+ await dispatcher.query(input);
90
+ await dispatcher.queryStream(input, { onDelta: vi.fn(), onComplete: vi.fn(), onError: vi.fn() });
91
+ const expected = { thinking, effort: thinking === false ? undefined : "high" };
92
+ expect(generate).toHaveBeenLastCalledWith(expect.objectContaining(expected));
93
+ expect(generateStream).toHaveBeenLastCalledWith(expect.objectContaining(expected), expect.any(Object));
94
+ }
95
+ },
96
+ );
97
+
37
98
  it("counts completed query and stream generations separately for providers sharing a token name", async () => {
38
99
  const dispatcher = new QgridDispatcherClass();
39
100
  dispatcher.replaceCache(
@@ -1,11 +1,4 @@
1
- /**
2
- * QgridDispatcher — provider dispatcher 라우팅 + 토큰 캐시/통계 싱글턴.
3
- *
4
- * - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
5
- * - OpenAI/Anthropic 요청은 각 provider dispatcher 로만 실행
6
- * - QuotaError 는 그대로 상위 전파
7
- * - dispatcher 미준비는 기동 중(503)/초기화 실패(500)로 구분해 던진다
8
- */
1
+ // provider 라우팅과 토큰 캐시·통계. 캐시는 TokenSubscriber가 갱신한다.
9
2
 
10
3
  import { InternalServerErrorException, ServiceUnavailableException } from "sonamu";
11
4
 
@@ -37,7 +30,7 @@ import {
37
30
  } from "./qgrid.types";
38
31
  import { composeSystemWithSchemaContract } from "./schema-prompt";
39
32
  import { type TokenSubscriber } from "./token-subscriber";
40
- import { applyToolCallEmulation } from "./tool-emulation";
33
+ import { applyToolCallEmulation, isToolCallEnvelope } from "./tool-emulation";
41
34
  import { buildToolCallSchema } from "./tool-emulation-schema";
42
35
 
43
36
  export type InternalQueryInput = QueryInput & {
@@ -50,55 +43,31 @@ export class QgridDispatcherClass {
50
43
 
51
44
  // 프로세스 시작 이후 완료된 provider generation 수. 실패/중간 시도는 제외하며,
52
45
  // 후속 tool turn도 한 generation으로 센다. 동명 토큰은 provider별로 분리한다.
53
- private requestCounts = new Map<string, number>();
46
+ requestCounts = new Map<string, number>();
54
47
 
55
- // sonamu.config onStart 에서 처리하는 변수
56
48
  subscriber: TokenSubscriber | null = null;
57
49
  openaiDispatcher: OpenAIDispatcher | null = null;
58
50
  anthropicDispatcher: AnthropicDispatcher | null = null;
59
51
 
60
- /**
61
- * provider 별 기동 상태. dispatcher 가 없는 이유를 구분하기 위해 필요하다 —
62
- * HTTP 리스닝은 dispatcher 준비보다 먼저 열리므로(sonamu.config onStart),
63
- * 그 사이 들어온 요청은 "잠시 후 되는" 상태와 "재시도해도 안 되는" 상태가 다르다.
64
- *
65
- * - `starting`: provider 토큰·런타임 초기화 중 → 재시도 가능
66
- * - `ready`: 정상
67
- * - `failed`: start() 가 예외로 끝남 → 재시도해도 같은 결과
68
- */
52
+ // HTTP가 dispatcher보다 먼저 열리므로 초기화 중과 초기화 실패를 구분한다.
69
53
  startupState: Record<"openai" | "anthropic", ProviderStartupState> = {
70
54
  openai: "starting",
71
55
  anthropic: "starting",
72
56
  };
73
57
 
74
- /**
75
- * dispatcher 가 준비되지 않았을 때 던질 예외를 만든다.
76
- *
77
- * 기동 중이면 503(+Retry-After) 으로 재시도 가능함을 알리고, 초기화가 실패로 끝났으면
78
- * 500 으로 재시도가 무의미함을 알린다. 이전에는 둘 다 QuotaError 였는데, 쿼터 소진이
79
- * 아닌 상태를 그렇게 표현하면 호출자가 토큰 문제로 오해하고 재시도 판단도 못 한다.
80
- */
81
- private notReadyError(provider: "openai" | "anthropic"): Error {
58
+ // 초기화 중에는 503 + Retry-After, 초기화 실패에는 500을 반환한다.
59
+ notReadyError(provider: "openai" | "anthropic"): Error {
82
60
  const label = provider === "openai" ? "OpenAI" : "Anthropic";
83
61
  return this.startupState[provider] === "failed"
84
62
  ? new InternalServerErrorException(SD("qgrid.dispatcherFailed")(label))
85
63
  : new ServiceUnavailableException(SD("qgrid.dispatcherStarting")(label));
86
64
  }
87
65
 
88
- private recordCompleted(provider: string, tokenName: string): void {
66
+ recordCompleted(provider: string, tokenName: string): void {
89
67
  const key = `${provider}\0${tokenName}`;
90
68
  this.requestCounts.set(key, (this.requestCounts.get(key) ?? 0) + 1);
91
69
  }
92
70
 
93
- // TokenSubscriber 콜백 — 캐시 mutation
94
- upsertCache(id: number, row: TokenSubsetA): void {
95
- this.tokens.set(id, row);
96
- }
97
-
98
- removeCache(id: number): void {
99
- this.tokens.delete(id);
100
- }
101
-
102
71
  replaceCache(rows: TokenSubsetA[]): void {
103
72
  this.tokens = new Map(rows.map((r) => [r.id, r]));
104
73
  }
@@ -115,10 +84,8 @@ export class QgridDispatcherClass {
115
84
  async query(input: InternalQueryInput, abortSignal?: AbortSignal): Promise<QueryOutput> {
116
85
  const route = parseProviderRoute(input.model);
117
86
  const outputSchema = buildAndValidateStrictOutputSchema(input, route.provider);
118
- // answer 인코딩 판정: jsonSchema 있으면 사용자 스키마 JSON, 없으면 평문 string. 4개 방출 지점이 공유.
119
87
  const answerKind = input.jsonSchema ? ("json" as const) : ("text" as const);
120
88
 
121
- // provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
122
89
  if (route.provider === "openai") {
123
90
  if (!this.openaiDispatcher) throw this.notReadyError("openai");
124
91
 
@@ -130,7 +97,11 @@ export class QgridDispatcherClass {
130
97
  model: route.model,
131
98
  systemPrompt: input.system,
132
99
  outputSchema,
133
- effort: input.effort,
100
+ stopAfterOutputMessage: input.tools?.length
101
+ ? (text, phase) => isToolCallEnvelope(text, input.tools!, answerKind, phase)
102
+ : undefined,
103
+ thinking: input.thinking,
104
+ effort: input.thinking === false ? undefined : input.effort,
134
105
  verbosity: input.verbosity,
135
106
  reasoningSummary: input.reasoningSummary,
136
107
  serviceTier: input.serviceTier,
@@ -163,13 +134,10 @@ export class QgridDispatcherClass {
163
134
 
164
135
  const decision = decideConvRouting(input);
165
136
  const result = await this.anthropicDispatcher.generate({
166
- // AnthropicDispatcher 내부에서 알아서 provider prefix 를 canonical model 로 정규화함
167
137
  model: input.model,
168
- // 스키마/envelope 계약은 --json-schema 대신 system 말미의 텍스트로 안내한다(SON-532).
169
- // outputSchema 는 전달하지 않는다 — anthropic 에서 항상 undefined(U1)이며,
170
- // CC 로 가는 --json-schema 채널 자체가 닫혀 있음을 여기서 명시한다.
171
138
  systemPrompt: composeSystemWithSchemaContract(input.system, input),
172
- effort: input.effort,
139
+ thinking: input.thinking,
140
+ effort: input.thinking === false ? undefined : input.effort,
173
141
  timeoutMs: input.timeout,
174
142
  abortSignal,
175
143
  coldInput: decision.coldInput,
@@ -196,7 +164,6 @@ export class QgridDispatcherClass {
196
164
  ): Promise<void> {
197
165
  const route = parseProviderRoute(input.model);
198
166
  const outputSchema = buildAndValidateStrictOutputSchema(input, route.provider);
199
- // answer 인코딩 판정: jsonSchema 있으면 사용자 스키마 JSON, 없으면 평문 string. 4개 방출 지점이 공유.
200
167
  const answerKind = input.jsonSchema ? ("json" as const) : ("text" as const);
201
168
 
202
169
  if (route.provider === "openai") {
@@ -211,7 +178,11 @@ export class QgridDispatcherClass {
211
178
  model: route.model,
212
179
  systemPrompt: input.system,
213
180
  outputSchema,
214
- effort: input.effort,
181
+ stopAfterOutputMessage: input.tools?.length
182
+ ? (text, phase) => isToolCallEnvelope(text, input.tools!, answerKind, phase)
183
+ : undefined,
184
+ thinking: input.thinking,
185
+ effort: input.thinking === false ? undefined : input.effort,
215
186
  verbosity: input.verbosity,
216
187
  reasoningSummary: input.reasoningSummary,
217
188
  serviceTier: input.serviceTier,
@@ -222,7 +193,6 @@ export class QgridDispatcherClass {
222
193
  preferredTokenId: input.preferredTokenId ?? decision.preferredTokenId,
223
194
  requirePreferredToken: input.requirePreferredToken,
224
195
  abortSignal,
225
- // 이미지 플래그를 전달해야 generateStream 의 non-stream 전용 거부(R2)가 발동한다.
226
196
  imageGeneration: input.imageGeneration,
227
197
  imageGenerationOptions: input.imageGenerationOptions,
228
198
  },
@@ -250,9 +220,7 @@ export class QgridDispatcherClass {
250
220
  } else if (route.provider === "anthropic") {
251
221
  if (!this.anthropicDispatcher) throw this.notReadyError("anthropic");
252
222
 
253
- // 계약(스키마/envelope)이 주입된 요청의 델타에서 코드펜스를 벗긴다(SON-532).
254
- // 비스트림 최종 텍스트는 stream-json-adapter 의 stripFences 가 같은 시맨틱으로
255
- // 처리하므로 델타 연결과 done.text 가 어긋나지 않는다. 일반 텍스트 요청은 무변경.
223
+ // 구조화 응답은 최종 텍스트와 델타 모두 코드펜스를 제거한다.
256
224
  const hasSchemaContract = input.jsonSchema !== undefined || Boolean(input.tools?.length);
257
225
  const fenceStrip = hasSchemaContract ? createFenceStripTransform() : undefined;
258
226
  const emitDelta = fenceStrip
@@ -266,9 +234,9 @@ export class QgridDispatcherClass {
266
234
  await this.anthropicDispatcher.generateStream(
267
235
  {
268
236
  model: input.model,
269
- // 스키마/envelope 계약은 --json-schema 대신 system 말미의 텍스트로 안내한다(SON-532)
270
237
  systemPrompt: composeSystemWithSchemaContract(input.system, input),
271
- effort: input.effort,
238
+ thinking: input.thinking,
239
+ effort: input.thinking === false ? undefined : input.effort,
272
240
  coldInput: decision.coldInput,
273
241
  coldHistory: decision.coldHistory,
274
242
  preferredTokenId: input.preferredTokenId,
@@ -282,8 +250,7 @@ export class QgridDispatcherClass {
282
250
  onThreadId: cb.onThreadId,
283
251
  onComplete: (turnResult) => {
284
252
  this.recordCompleted("anthropic", turnResult.tokenName);
285
- // 홀드백 잔여(닫는 펜스가 아니었던 tail)를 done 전에 마저 방출한다 —
286
- // 델타를 조립하는 클라이언트(EnvelopeStreamParser 등)의 무손실 보장.
253
+ // 닫는 펜스 판정 때문에 보류한 텍스트를 완료 전에 방출한다.
287
254
  const rest = fenceStrip?.flush();
288
255
  if (rest) cb.onDelta(rest);
289
256
 
@@ -315,19 +282,12 @@ function parseProviderRoute(model: string | undefined): { provider?: string; mod
315
282
  return { provider, model: routedModel };
316
283
  }
317
284
 
318
- // qgrid provider dispatcher 에서 처리하지 않는 모델은 향후 직접 LLM API 호출 지점으로 보낸다.
319
285
  function directLlmApiFallbackNotImplemented(input: QueryInput): ProcessError {
320
286
  return new ProcessError(
321
287
  `Direct LLM API fallback not implemented for model: ${input.model ?? "<default>"}`,
322
288
  );
323
289
  }
324
290
 
325
- // 모든 provider(OpenAI/Anthropic)가 동일하게 strict output schema 를 쓴다.
326
- //
327
- // SON-495 교훈: 한때 Anthropic route 만 required 를 제거(optionalize)해 partial/rejected attempt 가
328
- // 섞여도 통과하게 했으나, 실측 결과 그게 오히려 "모델이 비는 필드를 키째 생략"하도록 유도했다
329
- // (required 살린 schema 는 누락 0, loose 는 누락 다발). strict 로 두면 모델이 빠짐없이 채우고,
330
- // 드물게 어겨 provider retry 예산을 소진한 비정상 종료도 정직한 에러로 받아준다.
331
291
  export function buildStrictOutputSchema(
332
292
  input: Pick<QueryInput, "tools" | "jsonSchema">,
333
293
  provider?: string,
@@ -343,11 +303,8 @@ export function buildAndValidateStrictOutputSchema(
343
303
  input: Pick<QueryInput, "model" | "tools" | "jsonSchema">,
344
304
  provider = parseProviderRoute(input.model).provider,
345
305
  ): JsonValue | undefined {
346
- // anthropic(SON-532): CC 에 --json-schema 를 전달하지 않는다 — 강제 없는 사후 채점이
347
- // 소비자 의도(nullish 생략 등)와 충돌해 CC 내부 재시도 루프를 발화시켰다(왕복 2~4회,
348
- // 4.7~9.1배 출력 청구 실측). 스키마/envelope 계약은 프롬프트 텍스트로 안내하고 판정은
349
- // 소비자(zod)/parseEnvelope 가 맡는다. 따라서 strict 변환·argv 64KiB 제한 없이 수신
350
- // 원형의 구문·복잡도(전역 512KiB 한도)만 검증해 caller-fault 를 조기에 돌려준다.
306
+ // Claude Code의 --json-schema는 불필요한 내부 재시도를 유발한다.
307
+ // Anthropic은 원본 스키마를 검증한 뒤 프롬프트로 전달하고, 응답 검증은 소비자에 맡긴다.
351
308
  if (provider === "anthropic") {
352
309
  parseAndValidateCallerSchemas(input);
353
310
  return undefined;
@@ -368,10 +325,7 @@ function buildRawOutputSchema(
368
325
  : callerOutputSchema;
369
326
  }
370
327
 
371
- // GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
372
- // OpenAI/Anthropic, query/queryStream 4 경로가 동일하게 쓴다.
373
328
  // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
374
- // per-request cost 는 provider 가 준 값을 우선 사용하고, 없으면 공통 cost 함수를 쓴다.
375
329
  function toEmulationResult(
376
330
  result: GenerateResult,
377
331
  ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
@@ -630,11 +630,7 @@ class QgridFrameClass extends BaseFrameClass {
630
630
  }
631
631
 
632
632
  // 교환 + 계정 중복 제거 + 저장 — redirect 콜백과 코드 붙여넣기 플로우가 공유한다.
633
- private async completeAnthropicLogin(
634
- code: string,
635
- state: string,
636
- pending: PendingOAuth,
637
- ): Promise<void> {
633
+ async completeAnthropicLogin(code: string, state: string, pending: PendingOAuth): Promise<void> {
638
634
  const tokens = await exchangeCodeForTokens(
639
635
  code,
640
636
  pending.codeVerifier,
@@ -656,7 +652,7 @@ class QgridFrameClass extends BaseFrameClass {
656
652
  notifyTokenAdded(pending.name, "anthropic");
657
653
  }
658
654
 
659
- private async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
655
+ async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
660
656
  const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
661
657
  await TokenModel.replaceByAccount("openai", creds.accountId, {
662
658
  provider: "openai",
@@ -801,7 +797,7 @@ class QgridFrameClass extends BaseFrameClass {
801
797
  }
802
798
  }
803
799
 
804
- private async doRefreshToken(token: TokenSubsetA): Promise<string> {
800
+ async doRefreshToken(token: TokenSubsetA): Promise<string> {
805
801
  const creds = token.credentials;
806
802
  const rt = getRefreshToken(creds);
807
803
  if (!rt) throw new Error("No refresh token");
@@ -810,7 +806,7 @@ class QgridFrameClass extends BaseFrameClass {
810
806
  try {
811
807
  refreshed = await refreshAccessToken(rt);
812
808
  } catch (e) {
813
- if (e instanceof RefreshFailedError && e.isAuthDead) {
809
+ if (e instanceof RefreshFailedError && e.isAuthDead()) {
814
810
  await deactivateAuthDeadToken(token, `anthropic:${e.status}`);
815
811
  }
816
812
  throw e;
@@ -150,6 +150,8 @@ export const QueryInput = z.looseObject({
150
150
  timeout: QueryTimeoutMs.optional(),
151
151
  jsonSchema: z.string().optional(),
152
152
  tools: z.array(QgridTool).optional(),
153
+ // Boolean literals keep Sonamu's HTTP caster from accepting "true"/"false" strings.
154
+ thinking: z.union([z.literal(true), z.literal(false)]).optional(),
153
155
  effort: z.string().optional(),
154
156
  verbosity: Verbosity.optional(),
155
157
  reasoningSummary: ReasoningSummary.optional(),