@cartanova/qgrid-cli 2.10.1 → 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.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/monit/log-buffer.js +7 -11
- package/bundle/dist/application/monit/monit.frame.js +6 -11
- package/bundle/dist/application/qgrid/oauth.js +2 -2
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
- package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
- package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
- package/bundle/dist/application/request-log/request-log.model.js +7 -5
- package/bundle/dist/application/setting/setting.model.js +1 -1
- package/bundle/dist/sonamu.config.js +6 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
- package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
- package/bundle/dist/utils/providers/common/effort.js +1 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
- package/bundle/dist/utils/providers/common/thinking.js +12 -0
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
- package/bundle/src/application/monit/log-buffer.test.ts +3 -4
- package/bundle/src/application/monit/log-buffer.ts +13 -27
- package/bundle/src/application/monit/monit.frame.test.ts +7 -8
- package/bundle/src/application/monit/monit.frame.ts +9 -14
- package/bundle/src/application/qgrid/oauth.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
- package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
- package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
- package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
- package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
- package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
- package/bundle/src/application/request-log/request-log.model.ts +5 -5
- package/bundle/src/application/setting/setting.model.ts +1 -1
- package/bundle/src/sonamu.config.ts +5 -5
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
- package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
- package/bundle/src/utils/providers/common/effort.ts +1 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
- package/bundle/src/utils/providers/common/thinking.ts +10 -0
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
- package/bundle/web-dist/client/assets/{index-Cq4_ExLO.js → index-BIC1sQxw.js} +3 -3
- package/bundle/web-dist/client/assets/logs-zvdujZWn.js +1 -0
- package/bundle/web-dist/client/assets/monit-CraVV3MQ.js +1 -0
- package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
- package/bundle/web-dist/client/assets/{routes-CXotE1OF.js → routes-B3ZBoJ60.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-CfJDz4AC.js → settings-DwbzT5jo.js} +1 -1
- package/bundle/web-dist/client/assets/{show-gBpWK4z2.js → show-ItTExdAh.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-CbFJjIyC.js → tokens-BynKx8Vn.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-jJo7GV3r.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{logs-DMlOXcDS.js → logs-BSk9lOHe.js} +13 -3
- package/bundle/web-dist/server/assets/{monit-Dg1wZCdx.js → monit-D9W4ioUi.js} +2 -21
- package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
- package/bundle/web-dist/server/assets/{settings-BaIBt_Vs.js → settings-BFVuldch.js} +1 -1
- package/bundle/web-dist/server/assets/{show-CDxGr4rS.js → show-Cswh_tqs.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-DJlfeGcx.js → tokens-DiTUwUwr.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +6 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -0
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
- package/skills/qgrid/references/decision-rationale.md +9 -0
- package/skills/qgrid/references/openai-codex-runtime.md +6 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +3 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
- package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
- package/skills/qgrid/references/verification-and-debugging.md +1 -1
- package/bundle/web-dist/client/assets/logs-kg1KOMBk.js +0 -1
- package/bundle/web-dist/client/assets/monit-DmtHTmSM.js +0 -1
- /package/bundle/web-dist/client/assets/{plus-CknMNCP5.js → plus-B62CuqT8.js} +0 -0
- /package/bundle/web-dist/client/assets/{request-model-eygfdRv7.js → request-model-D4CZPmnG.js} +0 -0
- /package/bundle/web-dist/server/assets/{plus-DVobMrag.js → plus-CmvbWuDf.js} +0 -0
- /package/bundle/web-dist/server/assets/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Monit 로그
|
|
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
|
-
|
|
52
|
-
|
|
45
|
+
entries: MonitLogEntry[] = [];
|
|
46
|
+
nextSeq = 1;
|
|
53
47
|
|
|
54
48
|
constructor(
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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 ??
|
|
82
|
+
nextCursor: entries.at(-1)?.seq ?? latestSeq,
|
|
97
83
|
dropped: 0,
|
|
98
84
|
};
|
|
99
85
|
}
|
|
100
86
|
|
|
101
87
|
// 이전 프로세스의 커서 등 미래 커서는 방어적으로 현재 tail 로 재동기화한다.
|
|
102
|
-
if (cursor >
|
|
103
|
-
return { entries: [], nextCursor:
|
|
88
|
+
if (cursor > latestSeq) {
|
|
89
|
+
return { entries: [], nextCursor: latestSeq, dropped: 0 };
|
|
104
90
|
}
|
|
105
91
|
|
|
106
|
-
const dropped = Math.max(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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?.
|
|
68
|
-
anthropicTokenCount:
|
|
69
|
-
anthropicTokenNames:
|
|
70
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
347
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|