@cartanova/qgrid-cli 2.10.2 → 2.10.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.
Files changed (118) 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 +64 -141
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -6
  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 +2 -64
  14. package/bundle/dist/application/setting/setting.store.js +3 -4
  15. package/bundle/dist/application/setting/setting.types.js +2 -12
  16. package/bundle/dist/i18n/en.js +1 -3
  17. package/bundle/dist/i18n/ko.js +1 -3
  18. package/bundle/dist/sonamu.config.js +6 -6
  19. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  20. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  21. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  22. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  23. package/bundle/dist/utils/providers/common/effort.js +1 -1
  24. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  25. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  26. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  27. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  28. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  29. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  30. package/bundle/src/application/monit/log-buffer.ts +13 -27
  31. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  32. package/bundle/src/application/monit/monit.frame.ts +9 -14
  33. package/bundle/src/application/qgrid/oauth.ts +1 -1
  34. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -111
  35. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +83 -179
  36. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  37. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  38. package/bundle/src/application/qgrid/qgrid.frame.test.ts +0 -38
  39. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -12
  40. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  41. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  42. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  43. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  44. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  45. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  46. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  47. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  48. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  49. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  50. package/bundle/src/application/setting/setting.model.test.ts +1 -101
  51. package/bundle/src/application/setting/setting.model.ts +1 -74
  52. package/bundle/src/application/setting/setting.store.ts +2 -3
  53. package/bundle/src/application/setting/setting.types.ts +0 -12
  54. package/bundle/src/application/sonamu.generated.http +2 -7
  55. package/bundle/src/i18n/en.ts +0 -3
  56. package/bundle/src/i18n/ko.ts +0 -3
  57. package/bundle/src/sonamu.config.ts +5 -5
  58. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  59. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  60. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  61. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  62. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  63. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  64. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  65. package/bundle/src/utils/providers/common/effort.ts +1 -1
  66. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  67. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  68. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  69. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  70. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  71. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  72. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  73. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  74. package/bundle/web-dist/client/assets/index-CvG4UB9v.css +1 -0
  75. package/bundle/web-dist/client/assets/{index-DtRTSTes.js → index-reW8VvJF.js} +4 -4
  76. package/bundle/web-dist/client/assets/{logs-BhVYDEzC.js → logs-DDIt97D1.js} +1 -1
  77. package/bundle/web-dist/client/assets/{monit-BxN4JWqK.js → monit-DkABv7PG.js} +1 -1
  78. package/bundle/web-dist/client/assets/{routes-CBCqRp2o.js → routes-BRmQ4M3-.js} +1 -1
  79. package/bundle/web-dist/client/assets/services.generated-jnPHYMER.js +51 -0
  80. package/bundle/web-dist/client/assets/settings-BHvtycEZ.js +1 -0
  81. package/bundle/web-dist/client/assets/{show-BzYft5N4.js → show-qeTIzgDZ.js} +1 -1
  82. package/bundle/web-dist/client/assets/{tokens-PMybtI8X.js → tokens-5Bep_G-w.js} +1 -1
  83. package/bundle/web-dist/client/assets/{use-oauth-login-flow-C51_d6-6.js → use-oauth-login-flow-Cx-QO25M.js} +1 -1
  84. package/bundle/web-dist/client/assets/{use-update-token-mutation-D-Y8OySK.js → use-update-token-mutation-DQCwCYfR.js} +1 -1
  85. package/bundle/web-dist/client/index.html +3 -3
  86. package/bundle/web-dist/server/assets/{lazyRouteComponent-Bj1cPq6x.js → lazyRouteComponent-CUR-kWzI.js} +2 -2
  87. package/bundle/web-dist/server/assets/{logs-BSk9lOHe.js → logs-CagNGTBj.js} +3 -3
  88. package/bundle/web-dist/server/assets/{monit-D9W4ioUi.js → monit-DSxs4Jnl.js} +3 -3
  89. package/bundle/web-dist/server/assets/{plus-CmvbWuDf.js → plus-Bvdj8Pwx.js} +1 -1
  90. package/bundle/web-dist/server/assets/{react-dom-BjR1Iaz8.js → react-dom-CyuShs9K.js} +1 -1
  91. package/bundle/web-dist/server/assets/{refresh-cw-B8U9BL9-.js → refresh-cw-3QAiSWBd.js} +1 -1
  92. package/bundle/web-dist/server/assets/{routes-C7lB2Q85.js → routes-i-sTqXMX.js} +4 -4
  93. package/bundle/web-dist/server/assets/{services.generated-BM4X3Ecj.js → services.generated-FPwsG9p3.js} +116 -131
  94. package/bundle/web-dist/server/assets/{settings-BFVuldch.js → settings-DeHNwoMv.js} +40 -251
  95. package/bundle/web-dist/server/assets/{show-Cswh_tqs.js → show-C4jk1Hrp.js} +2 -2
  96. package/bundle/web-dist/server/assets/{tokens-DiTUwUwr.js → tokens-odgS2-9q.js} +4 -4
  97. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DYKnG4A2.js → use-oauth-login-flow-DTQKdYBO.js} +1 -1
  98. package/bundle/web-dist/server/assets/{use-update-token-mutation-DqGMopII.js → use-update-token-mutation-xvXbPrCI.js} +1 -1
  99. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  100. package/package.json +1 -1
  101. package/skills/qgrid/SKILL.md +1 -0
  102. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  103. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  104. package/skills/qgrid/references/cli-env-and-server-boot.md +12 -9
  105. package/skills/qgrid/references/decision-rationale.md +9 -0
  106. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  107. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  108. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  109. package/skills/qgrid/references/verification-and-debugging.md +1 -1
  110. package/bundle/dist/utils/process-supervisor.js +0 -22
  111. package/bundle/dist/utils/server-restart.js +0 -61
  112. package/bundle/src/utils/process-supervisor.test.ts +0 -24
  113. package/bundle/src/utils/process-supervisor.ts +0 -17
  114. package/bundle/src/utils/server-restart.test.ts +0 -76
  115. package/bundle/src/utils/server-restart.ts +0 -68
  116. package/bundle/web-dist/client/assets/index-D_sagzsR.css +0 -1
  117. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +0 -51
  118. package/bundle/web-dist/client/assets/settings-hBTEC7Ki.js +0 -1
@@ -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"> {
@@ -21,7 +21,6 @@ const {
21
21
  dispatcherQueryStreamMock,
22
22
  beforeQueryMock,
23
23
  afterQueryMock,
24
- assertNativeRunAdmissionMock,
25
24
  finishRunWithErrorMock,
26
25
  finishRunAbortedMock,
27
26
  getRateLimitsByTokenIdMock,
@@ -45,7 +44,6 @@ const {
45
44
  dispatcherQueryStreamMock: vi.fn(),
46
45
  beforeQueryMock: vi.fn(),
47
46
  afterQueryMock: vi.fn(),
48
- assertNativeRunAdmissionMock: vi.fn(),
49
47
  finishRunWithErrorMock: vi.fn(),
50
48
  finishRunAbortedMock: vi.fn(),
51
49
  getRateLimitsByTokenIdMock: vi.fn(),
@@ -95,7 +93,6 @@ vi.mock("./qgrid.dispatcher", async (importOriginal) => {
95
93
  });
96
94
 
97
95
  vi.mock("./qgrid-run-lifecycle", () => ({
98
- assertNativeRunAdmission: assertNativeRunAdmissionMock,
99
96
  beforeQuery: beforeQueryMock,
100
97
  afterQuery: afterQueryMock,
101
98
  finishRunWithError: finishRunWithErrorMock,
@@ -215,7 +212,6 @@ describe("QgridFrame.query request logging", () => {
215
212
  beforeEach(() => {
216
213
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
217
214
  afterQueryMock.mockReset().mockResolvedValue({});
218
- assertNativeRunAdmissionMock.mockReset();
219
215
  finishRunWithErrorMock.mockReset();
220
216
  dispatcherQueryMock.mockReset();
221
217
  findActiveByProviderAndNameMock.mockReset();
@@ -368,18 +364,6 @@ describe("QgridFrame.query request logging", () => {
368
364
  expect(finishRunWithErrorMock).not.toHaveBeenCalled();
369
365
  });
370
366
 
371
- it("rejects logger-disabled native dispatch while restart admission is closed", async () => {
372
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
373
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
374
- });
375
-
376
- await expect(
377
- QgridFrame.query({ prompt: "hi", model: "openai/gpt-5-codex", logger: false }),
378
- ).rejects.toMatchObject({ statusCode: 503 });
379
- expect(dispatcherQueryMock).not.toHaveBeenCalled();
380
- expect(beforeQueryMock).not.toHaveBeenCalled();
381
- });
382
-
383
367
  it("merges a tool run id with the provider thread coordinate", async () => {
384
368
  dispatcherQueryMock.mockResolvedValueOnce(queryOutput());
385
369
  afterQueryMock.mockResolvedValueOnce({ runContext: { requestLogId: 41 } });
@@ -952,7 +936,6 @@ describe("QgridFrame.queryStream request logging", () => {
952
936
  beforeEach(() => {
953
937
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 52, stepIndex: 0 });
954
938
  afterQueryMock.mockReset().mockResolvedValue({});
955
- assertNativeRunAdmissionMock.mockReset();
956
939
  finishRunWithErrorMock.mockReset();
957
940
  finishRunAbortedMock.mockReset();
958
941
  dispatcherQueryStreamMock.mockReset();
@@ -986,27 +969,6 @@ describe("QgridFrame.queryStream request logging", () => {
986
969
  };
987
970
  }
988
971
 
989
- it("rejects stream preparation while restart admission is closed", async () => {
990
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
991
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
992
- });
993
-
994
- await expect(QgridFrame.prepareStream({ prompt: "hi" })).rejects.toMatchObject({
995
- statusCode: 503,
996
- });
997
- expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
998
- });
999
-
1000
- it("rechecks admission before dispatching a previously prepared stream", async () => {
1001
- const { streamId } = await QgridFrame.prepareStream({ prompt: "hi", logger: false });
1002
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
1003
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
1004
- });
1005
-
1006
- await expect(QgridFrame.queryStream(streamId)).rejects.toMatchObject({ statusCode: 503 });
1007
- expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
1008
- });
1009
-
1010
972
  function installSseContext() {
1011
973
  let closeHandler: (() => void) | undefined;
1012
974
  const sse = {
@@ -38,7 +38,6 @@ import {
38
38
  RefreshFailedError,
39
39
  } from "./oauth";
40
40
  import {
41
- assertNativeRunAdmission,
42
41
  afterQuery,
43
42
  beforeQuery,
44
43
  finishRunAborted,
@@ -248,7 +247,6 @@ class QgridFrameClass extends BaseFrameClass {
248
247
 
249
248
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
250
249
  async query(args: QueryInput): Promise<QueryOutput> {
251
- assertNativeRunAdmission();
252
250
  rejectInvalidCallerSchemas(args);
253
251
  const resolvedArgs = await resolveTokenName(args);
254
252
  const disconnect = createHttpDisconnectHandle();
@@ -299,7 +297,6 @@ class QgridFrameClass extends BaseFrameClass {
299
297
 
300
298
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
301
299
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
302
- assertNativeRunAdmission();
303
300
  rejectImageGenerationStream(args);
304
301
  rejectInvalidCallerSchemas(args);
305
302
  const resolvedArgs = await resolveTokenName(args);
@@ -315,7 +312,6 @@ class QgridFrameClass extends BaseFrameClass {
315
312
  const args = pendingStreams.get(streamId);
316
313
  pendingStreams.delete(streamId);
317
314
  if (!args) throw new Error("invalid or expired streamId");
318
- assertNativeRunAdmission();
319
315
  rejectImageGenerationStream(args);
320
316
 
321
317
  const ctx = Sonamu.getContext();
@@ -630,11 +626,7 @@ class QgridFrameClass extends BaseFrameClass {
630
626
  }
631
627
 
632
628
  // 교환 + 계정 중복 제거 + 저장 — redirect 콜백과 코드 붙여넣기 플로우가 공유한다.
633
- private async completeAnthropicLogin(
634
- code: string,
635
- state: string,
636
- pending: PendingOAuth,
637
- ): Promise<void> {
629
+ async completeAnthropicLogin(code: string, state: string, pending: PendingOAuth): Promise<void> {
638
630
  const tokens = await exchangeCodeForTokens(
639
631
  code,
640
632
  pending.codeVerifier,
@@ -656,7 +648,7 @@ class QgridFrameClass extends BaseFrameClass {
656
648
  notifyTokenAdded(pending.name, "anthropic");
657
649
  }
658
650
 
659
- private async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
651
+ async completeOpenAILogin(code: string, pending: PendingOAuth): Promise<void> {
660
652
  const creds = await exchangeOpenAICode(code, pending.codeVerifier, pending.redirectUri);
661
653
  await TokenModel.replaceByAccount("openai", creds.accountId, {
662
654
  provider: "openai",
@@ -801,7 +793,7 @@ class QgridFrameClass extends BaseFrameClass {
801
793
  }
802
794
  }
803
795
 
804
- private async doRefreshToken(token: TokenSubsetA): Promise<string> {
796
+ async doRefreshToken(token: TokenSubsetA): Promise<string> {
805
797
  const creds = token.credentials;
806
798
  const rt = getRefreshToken(creds);
807
799
  if (!rt) throw new Error("No refresh token");
@@ -810,7 +802,7 @@ class QgridFrameClass extends BaseFrameClass {
810
802
  try {
811
803
  refreshed = await refreshAccessToken(rt);
812
804
  } catch (e) {
813
- if (e instanceof RefreshFailedError && e.isAuthDead) {
805
+ if (e instanceof RefreshFailedError && e.isAuthDead()) {
814
806
  await deactivateAuthDeadToken(token, `anthropic:${e.status}`);
815
807
  }
816
808
  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(),
@@ -32,8 +32,7 @@ function subscriberWith(openaiDispatcher: Record<string, unknown>) {
32
32
  return new TokenSubscriber(
33
33
  {} as never,
34
34
  {
35
- removeCache: vi.fn(),
36
- upsertCache: vi.fn(),
35
+ tokens: new Map(),
37
36
  replaceCache: vi.fn(),
38
37
  openaiDispatcher,
39
38
  anthropicDispatcher: null,
@@ -82,8 +81,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
82
81
  const subscriber = new TokenSubscriber(
83
82
  {} as never,
84
83
  {
85
- removeCache: vi.fn(),
86
- upsertCache: vi.fn(),
84
+ tokens: new Map(),
87
85
  replaceCache: vi.fn(),
88
86
  openaiDispatcher: null,
89
87
  anthropicDispatcher,
@@ -112,15 +110,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
112
110
  {} as never,
113
111
  {
114
112
  tokens: new Map(),
115
- removeCache: vi.fn(),
116
- upsertCache: vi.fn(),
117
113
  replaceCache: vi.fn(),
118
114
  openaiDispatcher: null,
119
115
  anthropicDispatcher,
120
116
  } as never,
121
117
  );
122
118
  const onTokensChanged = vi.fn();
123
- subscriber.setTokenChangeHandler(onTokensChanged);
119
+ subscriber.tokenChangeHandler = onTokensChanged;
124
120
  findOneMock.mockResolvedValueOnce({
125
121
  ...openaiToken(true),
126
122
  provider: "anthropic",
@@ -143,15 +139,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
143
139
  {} as never,
144
140
  {
145
141
  tokens: new Map([[previous.id, previous]]),
146
- removeCache: vi.fn(),
147
- upsertCache: vi.fn(),
148
142
  replaceCache: vi.fn(),
149
143
  openaiDispatcher: null,
150
144
  anthropicDispatcher,
151
145
  } as never,
152
146
  );
153
147
  const onTokensChanged = vi.fn();
154
- subscriber.setTokenChangeHandler(onTokensChanged);
148
+ subscriber.tokenChangeHandler = onTokensChanged;
155
149
  findOneMock.mockResolvedValueOnce({
156
150
  ...previous,
157
151
  credentials: { accessToken: "new", refreshToken: "refresh" },
@@ -173,15 +167,13 @@ describe("TokenSubscriber OpenAI notifications", () => {
173
167
  {} as never,
174
168
  {
175
169
  tokens: new Map([[previous.id, previous]]),
176
- removeCache: vi.fn(),
177
- upsertCache: vi.fn(),
178
170
  replaceCache: vi.fn(),
179
171
  openaiDispatcher: null,
180
172
  anthropicDispatcher: { onTokenRemoved: vi.fn() },
181
173
  } as never,
182
174
  );
183
175
  const onTokensChanged = vi.fn();
184
- subscriber.setTokenChangeHandler(onTokensChanged);
176
+ subscriber.tokenChangeHandler = onTokensChanged;
185
177
  findOneMock.mockResolvedValueOnce({ ...previous, active: false });
186
178
 
187
179
  await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: previous.id }));
@@ -198,8 +190,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
198
190
  const subscriber = new TokenSubscriber(
199
191
  {} as never,
200
192
  {
201
- removeCache: vi.fn(),
202
- upsertCache: vi.fn(),
193
+ tokens: new Map(),
203
194
  replaceCache: vi.fn(),
204
195
  openaiDispatcher: null,
205
196
  anthropicDispatcher,
@@ -243,8 +234,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
243
234
  const subscriber = new TokenSubscriber(
244
235
  {} as never,
245
236
  {
246
- removeCache: vi.fn(),
247
- upsertCache: vi.fn(),
237
+ tokens: new Map(),
248
238
  replaceCache: vi.fn(),
249
239
  openaiDispatcher,
250
240
  anthropicDispatcher,
@@ -290,7 +280,7 @@ describe("TokenSubscriber OpenAI notifications", () => {
290
280
  };
291
281
  const subscriber = new TokenSubscriber({} as never, dispatcher as never);
292
282
  const onTokensChanged = vi.fn();
293
- subscriber.setTokenChangeHandler(onTokensChanged);
283
+ subscriber.tokenChangeHandler = onTokensChanged;
294
284
  findActiveMock
295
285
  .mockResolvedValueOnce([currentAnthropic])
296
286
  .mockResolvedValueOnce([