@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
|
@@ -18,7 +18,6 @@ import { spawn } from "node:child_process";
|
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
20
|
|
|
21
|
-
import { resolveAnthropicEffort } from "../common/effort";
|
|
22
21
|
import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "../common/provider-types";
|
|
23
22
|
import {
|
|
24
23
|
ARGV_SAFE_MAX_UTF8_BYTES,
|
|
@@ -28,14 +27,13 @@ import { createTtftTracker } from "../common/ttft";
|
|
|
28
27
|
import {
|
|
29
28
|
ANTHROPIC_CLAUDE_CWD,
|
|
30
29
|
anthropicConfigDir,
|
|
31
|
-
ANTHROPIC_DEFAULT_EFFORT,
|
|
32
30
|
ANTHROPIC_DISALLOWED_TOOLS,
|
|
33
31
|
assertSupportedOneMillionSuffix,
|
|
34
32
|
canonicalAnthropicModel,
|
|
35
33
|
needsCli1mSuffix,
|
|
36
34
|
supports1MContext,
|
|
37
|
-
usesAdaptiveThinking,
|
|
38
35
|
} from "./anthropic-constants";
|
|
36
|
+
import { assertAnthropicThinkingRuntime, resolveAnthropicThinking } from "./anthropic-thinking";
|
|
39
37
|
import {
|
|
40
38
|
buildStreamJsonInput,
|
|
41
39
|
type ClaudeStreamJsonState,
|
|
@@ -106,6 +104,7 @@ export interface ClaudeSessionRequest {
|
|
|
106
104
|
system?: string;
|
|
107
105
|
jsonSchema?: string; // 있으면 structured output
|
|
108
106
|
effort?: string;
|
|
107
|
+
thinking?: boolean;
|
|
109
108
|
timeoutMs: number;
|
|
110
109
|
coldHistory?: Array<JsonValue>;
|
|
111
110
|
input: Array<UserInput>;
|
|
@@ -149,18 +148,6 @@ export function oneMillionEnv(supported: boolean): { CLAUDE_CODE_DISABLE_1M_CONT
|
|
|
149
148
|
return supported ? {} : { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" };
|
|
150
149
|
}
|
|
151
150
|
|
|
152
|
-
export function thinkingEnv(model: string): {
|
|
153
|
-
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING?: "1";
|
|
154
|
-
MAX_THINKING_TOKENS?: "0";
|
|
155
|
-
} {
|
|
156
|
-
return usesAdaptiveThinking(model)
|
|
157
|
-
? {}
|
|
158
|
-
: {
|
|
159
|
-
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
|
|
160
|
-
MAX_THINKING_TOKENS: "0",
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
151
|
// SON-495: structured output retry 를 1 로 고정한다. CC 가 attempt 를 reject 하고 retry 하면
|
|
165
152
|
// `{rejected}{accepted}` 누적이 AI-SDK 의 JSON.parse 를 깨뜨리므로, retry 자체를 막아 그 경로를
|
|
166
153
|
// 원천 차단한다. `0` 은 CC query loop(`callsThisQuery >= maxRetries`)가 첫 attempt emit 전에
|
|
@@ -206,11 +193,13 @@ export function buildClaudeArgs(opts: {
|
|
|
206
193
|
system?: string;
|
|
207
194
|
systemPromptFile?: string;
|
|
208
195
|
effort?: string;
|
|
196
|
+
thinking?: boolean;
|
|
209
197
|
jsonSchema?: string;
|
|
210
198
|
sessionId: string;
|
|
211
199
|
needsOneMillionSuffix?: boolean;
|
|
212
200
|
includePartialMessages?: boolean;
|
|
213
201
|
}): Array<string> {
|
|
202
|
+
const thinking = resolveAnthropicThinking(opts.model, opts.thinking, opts.effort);
|
|
214
203
|
const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
|
|
215
204
|
if (useStructured) assertAnthropicSchemaArgvSize(opts.jsonSchema!);
|
|
216
205
|
// --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
|
|
@@ -244,11 +233,10 @@ export function buildClaudeArgs(opts: {
|
|
|
244
233
|
// inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
|
|
245
234
|
// 크기에 따라 --system-prompt(작음) / --system-prompt-file(큼)로 분기(systemArgs).
|
|
246
235
|
...systemArgs,
|
|
247
|
-
//
|
|
248
|
-
...(
|
|
249
|
-
// Claude Code 어휘 밖의 effort(OpenAI 전용 ultra, 옛 none/minimal 등)는 조용히 버리고 qgrid 기본을 쓴다.
|
|
236
|
+
// One CLI control owns thinking; no competing adaptive/budget environment overrides.
|
|
237
|
+
...(thinking.mode ? ["--thinking", thinking.mode] : []),
|
|
250
238
|
"--effort",
|
|
251
|
-
|
|
239
|
+
thinking.effort,
|
|
252
240
|
"--disable-slash-commands",
|
|
253
241
|
"--session-id",
|
|
254
242
|
opts.sessionId,
|
|
@@ -269,6 +257,8 @@ export function runClaudeSession(
|
|
|
269
257
|
// 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
|
|
270
258
|
assertSupportedOneMillionSuffix(req.model);
|
|
271
259
|
const model = canonicalAnthropicModel(req.model);
|
|
260
|
+
const thinking = resolveAnthropicThinking(model, req.thinking, req.effort);
|
|
261
|
+
assertAnthropicThinkingRuntime(thinking.extraBody);
|
|
272
262
|
const supportsOneMillion = supports1MContext(model);
|
|
273
263
|
const needsOneMillionSuffix = needsCli1mSuffix(model);
|
|
274
264
|
const sessionId = randomUUID();
|
|
@@ -293,6 +283,7 @@ export function runClaudeSession(
|
|
|
293
283
|
system: useSystemPromptFile ? undefined : req.system,
|
|
294
284
|
systemPromptFile,
|
|
295
285
|
effort: req.effort,
|
|
286
|
+
thinking: req.thinking,
|
|
296
287
|
jsonSchema: req.jsonSchema,
|
|
297
288
|
sessionId,
|
|
298
289
|
needsOneMillionSuffix,
|
|
@@ -324,8 +315,8 @@ export function runClaudeSession(
|
|
|
324
315
|
TMPDIR: process.env.TMPDIR,
|
|
325
316
|
CLAUDE_CODE_OAUTH_TOKEN: req.token,
|
|
326
317
|
CLAUDE_CONFIG_DIR: configDir,
|
|
318
|
+
...(thinking.extraBody ? { CLAUDE_CODE_EXTRA_BODY: thinking.extraBody } : {}),
|
|
327
319
|
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
|
|
328
|
-
...thinkingEnv(model),
|
|
329
320
|
...oneMillionEnv(supportsOneMillion),
|
|
330
321
|
CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
|
|
331
322
|
CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
|
|
@@ -14,7 +14,7 @@ const logger = getLogger(["qgrid", "effort"]);
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// Codex 구독 백엔드 모델 카탈로그(~/.codex/models_cache.json) 의 supported_reasoning_levels 합집합.
|
|
17
|
-
//
|
|
17
|
+
// Public effort options exclude none/minimal. thinking:false maps to wire-level none separately.
|
|
18
18
|
export const OPENAI_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"] as const;
|
|
19
19
|
export type OpenAIEffort = (typeof OPENAI_EFFORTS)[number];
|
|
20
20
|
|
|
@@ -53,7 +53,11 @@ export interface GenerateRequest {
|
|
|
53
53
|
model?: string;
|
|
54
54
|
systemPrompt?: string;
|
|
55
55
|
outputSchema?: JsonValue;
|
|
56
|
+
// A completed control message can yield to the caller before speculative later text.
|
|
57
|
+
// The transport must still drain to completion for usage, cancellation and errors.
|
|
58
|
+
stopAfterOutputMessage?: (text: string, phase?: string) => boolean;
|
|
56
59
|
effort?: string;
|
|
60
|
+
thinking?: boolean;
|
|
57
61
|
verbosity?: string;
|
|
58
62
|
reasoningSummary?: string;
|
|
59
63
|
serviceTier?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export class SmoothWeightedRoundRobin {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
readonly weights = new Map<number, number>();
|
|
3
|
+
readonly currentScores = new Map<number, number>();
|
|
4
4
|
|
|
5
5
|
setToken(tokenId: number, weight: number): void {
|
|
6
6
|
if (!Number.isInteger(weight) || weight < 1 || weight > 100) {
|
|
@@ -31,7 +31,7 @@ export class SmoothWeightedRoundRobin {
|
|
|
31
31
|
let selectedScore = Number.NEGATIVE_INFINITY;
|
|
32
32
|
let totalWeight = 0;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
candidates.forEach((tokenId) => {
|
|
35
35
|
const weight = this.weights.get(tokenId)!;
|
|
36
36
|
totalWeight += weight;
|
|
37
37
|
const score = (this.currentScores.get(tokenId) ?? 0) + weight;
|
|
@@ -40,7 +40,7 @@ export class SmoothWeightedRoundRobin {
|
|
|
40
40
|
selected = tokenId;
|
|
41
41
|
selectedScore = score;
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
});
|
|
44
44
|
|
|
45
45
|
this.currentScores.set(selected, selectedScore - totalWeight);
|
|
46
46
|
return selected;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BadRequestException } from "sonamu";
|
|
2
|
+
|
|
3
|
+
import { type LocalizedString } from "../../../i18n/sd.generated";
|
|
4
|
+
|
|
5
|
+
export class ThinkingValidationError extends BadRequestException {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message as LocalizedString);
|
|
8
|
+
this.name = "ThinkingValidationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -61,7 +61,7 @@ export interface OpenAIResponsesOptions {
|
|
|
61
61
|
toolChoice?: string | OpenAIResponseItem;
|
|
62
62
|
parallelToolCalls?: boolean;
|
|
63
63
|
reasoning?: {
|
|
64
|
-
effort?: OpenAIEffort;
|
|
64
|
+
effort?: OpenAIEffort | "none";
|
|
65
65
|
summary?: "auto" | "concise" | "detailed";
|
|
66
66
|
};
|
|
67
67
|
verbosity?: "low" | "medium" | "high";
|
|
@@ -170,7 +170,7 @@ export interface OpenAIUsage {
|
|
|
170
170
|
|
|
171
171
|
export type OpenAINormalizedEvent =
|
|
172
172
|
| { type: "created"; responseId?: string }
|
|
173
|
-
| { type: "text-delta"; text: string }
|
|
173
|
+
| { type: "text-delta"; text: string; itemId?: string }
|
|
174
174
|
| {
|
|
175
175
|
type: "image";
|
|
176
176
|
id?: string;
|
|
@@ -235,7 +235,11 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
|
|
|
235
235
|
return { type: "created", ...(response?.id ? { responseId: String(response.id) } : {}) };
|
|
236
236
|
}
|
|
237
237
|
if (type === "response.output_text.delta" && typeof event.delta === "string") {
|
|
238
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
type: "text-delta",
|
|
240
|
+
text: event.delta,
|
|
241
|
+
...(typeof event.item_id === "string" ? { itemId: event.item_id } : {}),
|
|
242
|
+
};
|
|
239
243
|
}
|
|
240
244
|
if (
|
|
241
245
|
(type === "response.output_item.added" || type === "response.output_item.done") &&
|
|
@@ -177,7 +177,7 @@ class MockSocket implements OpenAIWebSocketLike {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
emit(event: string, ...args: unknown[]): void {
|
|
180
|
-
|
|
180
|
+
(this.listeners.get(event) ?? []).forEach((listener) => listener(...(args as never[])));
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
send(data: string, callback?: (error?: Error) => void): void {
|
|
@@ -534,10 +534,10 @@ describe("OpenAI direct Responses WebSocket client", () => {
|
|
|
534
534
|
mock.sockets[2]!.emit("open");
|
|
535
535
|
await expect(sendFailure).rejects.toThrow("send failed");
|
|
536
536
|
|
|
537
|
-
|
|
537
|
+
mock.sockets.forEach((socket) => {
|
|
538
538
|
expect(socket.terminated).toBe(true);
|
|
539
539
|
expect([...socket.listeners.values()].flat()).toHaveLength(0);
|
|
540
|
-
}
|
|
540
|
+
});
|
|
541
541
|
});
|
|
542
542
|
|
|
543
543
|
it("terminates on iterator cancellation and when the receive buffer exceeds 1600 records", async () => {
|
|
@@ -31,7 +31,6 @@ export interface OpenAITransportRequest {
|
|
|
31
31
|
clientRequestId: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Transport-independent streaming interface; HTTPS is the first implementation. */
|
|
35
34
|
export interface OpenAIResponsesTransport {
|
|
36
35
|
stream(request: OpenAITransportRequest): OpenAIEventStream;
|
|
37
36
|
close?(): void;
|
|
@@ -150,7 +149,7 @@ function socketEvents(socket: OpenAIWebSocketLike, signal?: AbortSignal) {
|
|
|
150
149
|
kind: "error" as const,
|
|
151
150
|
error: new OpenAIProtocolError("OpenAI WebSocket event stream was closed"),
|
|
152
151
|
};
|
|
153
|
-
|
|
152
|
+
waiters.splice(0).forEach((waiter) => waiter(closed));
|
|
154
153
|
},
|
|
155
154
|
detachAbort: () => signal?.removeEventListener("abort", onAbort),
|
|
156
155
|
};
|
|
@@ -198,12 +197,12 @@ type AffinityConnection = {
|
|
|
198
197
|
};
|
|
199
198
|
|
|
200
199
|
export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
static readonly MAX_AFFINITY_CONNECTIONS = 16;
|
|
201
|
+
credentials: OpenAIDirectCredentials;
|
|
202
|
+
readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
|
|
203
|
+
readonly webSocketFactory: OpenAIWebSocketFactory;
|
|
204
|
+
readonly affinityConnections = new Map<string, AffinityConnection>();
|
|
205
|
+
readonly busyAffinities = new Set<string>();
|
|
207
206
|
|
|
208
207
|
constructor(options: OpenAIWebSocketTransportOptions) {
|
|
209
208
|
this.credentials = options.credentials;
|
|
@@ -212,24 +211,17 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
|
|
|
212
211
|
options.webSocketFactory ?? ((url, wsOptions) => new WsClient(url, wsOptions));
|
|
213
212
|
}
|
|
214
213
|
|
|
215
|
-
stream(request: OpenAITransportRequest): OpenAIEventStream {
|
|
216
|
-
return this.run(request);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
214
|
close(): void {
|
|
220
|
-
|
|
215
|
+
this.affinityConnections.forEach(({ socket, events }) => {
|
|
221
216
|
events.cleanup();
|
|
222
217
|
socket.close(1000, "transport closed");
|
|
223
|
-
}
|
|
218
|
+
});
|
|
224
219
|
this.affinityConnections.clear();
|
|
225
220
|
this.busyAffinities.clear();
|
|
226
221
|
}
|
|
227
222
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* 진행 중인 응답을 끊는 대신 등록을 포기한다(false → 이 요청은 일회성 소켓으로 처리).
|
|
231
|
-
*/
|
|
232
|
-
private rememberAffinityConnection(threadId: string, connection: AffinityConnection): boolean {
|
|
223
|
+
// 유휴 연결만 축출한다. 전부 사용 중이면 false를 반환해 일회성 소켓으로 처리한다.
|
|
224
|
+
rememberAffinityConnection(threadId: string, connection: AffinityConnection): boolean {
|
|
233
225
|
if (
|
|
234
226
|
!this.affinityConnections.has(threadId) &&
|
|
235
227
|
this.affinityConnections.size >= OpenAIWebSocketTransport.MAX_AFFINITY_CONNECTIONS
|
|
@@ -257,7 +249,7 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
|
|
|
257
249
|
return true;
|
|
258
250
|
}
|
|
259
251
|
|
|
260
|
-
|
|
252
|
+
async *stream(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
|
|
261
253
|
let refreshed = false;
|
|
262
254
|
while (true) {
|
|
263
255
|
if (request.signal?.aborted) throw abortError(request.signal);
|
|
@@ -270,7 +262,6 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
|
|
|
270
262
|
entry.events.cleanup();
|
|
271
263
|
}
|
|
272
264
|
const cached = entry?.closed ? undefined : entry;
|
|
273
|
-
// 캐시 등록에 실패하면 이 요청은 재사용 대상이 아니다.
|
|
274
265
|
let retained = retainConnection;
|
|
275
266
|
const socket =
|
|
276
267
|
cached?.socket ??
|
|
@@ -392,9 +383,9 @@ async function responseError(response: Response): Promise<OpenAIProtocolError> {
|
|
|
392
383
|
}
|
|
393
384
|
|
|
394
385
|
export class OpenAIHttpsTransport implements OpenAIResponsesTransport {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
386
|
+
credentials: OpenAIDirectCredentials;
|
|
387
|
+
readonly fetchImpl: typeof fetch;
|
|
388
|
+
readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
|
|
398
389
|
|
|
399
390
|
constructor(options: OpenAIHttpsTransportOptions) {
|
|
400
391
|
this.credentials = options.credentials;
|
|
@@ -402,11 +393,7 @@ export class OpenAIHttpsTransport implements OpenAIResponsesTransport {
|
|
|
402
393
|
this.refreshCredentials = options.refreshCredentials;
|
|
403
394
|
}
|
|
404
395
|
|
|
405
|
-
stream(request: OpenAITransportRequest):
|
|
406
|
-
return this.run(request);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private async *run(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
|
|
396
|
+
async *stream(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
|
|
410
397
|
let refreshed = false;
|
|
411
398
|
|
|
412
399
|
while (true) {
|
|
@@ -449,10 +436,10 @@ export interface OpenAIDirectClientOptions extends OpenAIHttpsTransportOptions {
|
|
|
449
436
|
}
|
|
450
437
|
|
|
451
438
|
export class OpenAIDirectClient {
|
|
452
|
-
|
|
453
|
-
|
|
439
|
+
readonly transport: OpenAIResponsesTransport;
|
|
440
|
+
imageCredentials: OpenAIDirectCredentials;
|
|
454
441
|
|
|
455
|
-
constructor(
|
|
442
|
+
constructor(readonly options: OpenAIDirectClientOptions) {
|
|
456
443
|
this.imageCredentials = options.credentials;
|
|
457
444
|
this.transport =
|
|
458
445
|
options.transport ??
|
|
@@ -484,7 +471,7 @@ export class OpenAIDirectClient {
|
|
|
484
471
|
});
|
|
485
472
|
}
|
|
486
473
|
|
|
487
|
-
|
|
474
|
+
async *standaloneImages(
|
|
488
475
|
options: OpenAIResponsesOptions,
|
|
489
476
|
signal?: AbortSignal,
|
|
490
477
|
): OpenAIEventStream {
|
|
@@ -51,6 +51,80 @@ async function tickTimer(): Promise<void> {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
describe("OpenAIDispatcher direct runtime", () => {
|
|
54
|
+
it.each(["gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"])(
|
|
55
|
+
"disables thinking and omits summaries through generate/stream for %s",
|
|
56
|
+
async (model) => {
|
|
57
|
+
const run = vi.fn((options: OpenAIResponsesOptions) => {
|
|
58
|
+
expect(buildOpenAIResponsesRequest(options).reasoning).toEqual({ effort: "none" });
|
|
59
|
+
expect(options.outputSchema).toEqual({ schema: { type: "object" } });
|
|
60
|
+
return events({ type: "completed", responseId: "r" });
|
|
61
|
+
});
|
|
62
|
+
const d = dispatcher(run);
|
|
63
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
64
|
+
const req = request({ model, thinking: false, effort: "high", reasoningSummary: "detailed", outputSchema: { type: "object" } });
|
|
65
|
+
await d.generate(req);
|
|
66
|
+
const complete = vi.fn();
|
|
67
|
+
await d.generateStream(req, { onDelta: vi.fn(), onComplete: complete, onError: vi.fn() });
|
|
68
|
+
expect(complete).toHaveBeenCalledOnce();
|
|
69
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
["gpt-6-astra", "requires thinking"],
|
|
75
|
+
["gpt-6.1-sol", "requires thinking"],
|
|
76
|
+
["gpt-5.4", "has not been verified"],
|
|
77
|
+
])("rejects thinking:false for %s before token selection", async (model, reason) => {
|
|
78
|
+
const run = vi.fn(() => events());
|
|
79
|
+
// No tokens: validation must win over NO_OPENAI_WORKERS and never contact transport.
|
|
80
|
+
const d = dispatcher(run);
|
|
81
|
+
const req = request({ model, thinking: false });
|
|
82
|
+
await expect(d.generate(req)).rejects.toMatchObject({ statusCode: 400, message: expect.stringContaining(reason) });
|
|
83
|
+
const onError = vi.fn();
|
|
84
|
+
const onComplete = vi.fn();
|
|
85
|
+
await expect(d.generateStream(req, { onDelta: vi.fn(), onError, onComplete })).rejects.toThrow(model);
|
|
86
|
+
expect(onError).toHaveBeenCalledOnce();
|
|
87
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
88
|
+
expect(run).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each([true, undefined])("preserves effort and summary with thinking=%s on an off-unsupported model", async (thinking) => {
|
|
92
|
+
const run = vi.fn((options: OpenAIResponsesOptions) => {
|
|
93
|
+
expect(options.reasoning).toEqual({ effort: "high", summary: "detailed" });
|
|
94
|
+
return events({ type: "completed", responseId: "r" });
|
|
95
|
+
});
|
|
96
|
+
const d = dispatcher(run);
|
|
97
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
98
|
+
await d.generate(request({ model: "gpt-6-astra", thinking, effort: "high", reasoningSummary: "detailed" }));
|
|
99
|
+
expect(run).toHaveBeenCalledOnce();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("explicit true enables reasoning with absent or invalid effort without changing omission", async () => {
|
|
103
|
+
const observed: Array<OpenAIResponsesOptions["reasoning"]> = [];
|
|
104
|
+
const d = dispatcher((options) => {
|
|
105
|
+
observed.push(options.reasoning);
|
|
106
|
+
return events({ type: "completed", responseId: "r" });
|
|
107
|
+
});
|
|
108
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
109
|
+
for (const effort of [undefined, "invalid"]) {
|
|
110
|
+
await d.generate(request({ model: "gpt-6-sol", thinking: true, effort }));
|
|
111
|
+
await d.generate(request({ model: "gpt-6-sol", effort }));
|
|
112
|
+
}
|
|
113
|
+
expect(observed).toEqual([{ effort: "low" }, undefined, { effort: "low" }, undefined]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("applies thinking:false to the automatic image text driver", async () => {
|
|
117
|
+
const run = vi.fn((options: OpenAIResponsesOptions) => {
|
|
118
|
+
expect(options.reasoning).toEqual({ effort: "none" });
|
|
119
|
+
expect(options.imageGeneration).toBe(true);
|
|
120
|
+
return events({ type: "text-delta", text: "hello" }, { type: "completed", responseId: "r" });
|
|
121
|
+
});
|
|
122
|
+
const d = dispatcher(run);
|
|
123
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
124
|
+
await d.generate(request({ model: "gpt-6-sol", thinking: false, imageGeneration: "auto" }));
|
|
125
|
+
expect(run).toHaveBeenCalledOnce();
|
|
126
|
+
});
|
|
127
|
+
|
|
54
128
|
it.each(["png", "jpeg", "webp"] as const)("forwards %s format and preserves returned MIME", async (outputFormat) => {
|
|
55
129
|
const args = QueryInput.parse({ prompt: "draw", imageGeneration: true, imageGenerationOptions: { outputFormat } });
|
|
56
130
|
let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
|
|
@@ -112,7 +186,7 @@ describe("OpenAIDispatcher direct runtime", () => {
|
|
|
112
186
|
await d.onTokenAdded(1, "one", credentials);
|
|
113
187
|
await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow(`HTTP ${status}`);
|
|
114
188
|
expect(refreshMock).not.toHaveBeenCalled();
|
|
115
|
-
expect(d.
|
|
189
|
+
expect(d.countActiveTokens()).toBe(1);
|
|
116
190
|
});
|
|
117
191
|
|
|
118
192
|
it("propagates refresh failure instead of retrying rejected credentials", async () => {
|
|
@@ -275,18 +349,20 @@ describe("OpenAIDispatcher direct runtime", () => {
|
|
|
275
349
|
});
|
|
276
350
|
});
|
|
277
351
|
|
|
278
|
-
it("streams deltas
|
|
352
|
+
it("streams final-answer deltas incrementally while checking message boundaries", async () => {
|
|
279
353
|
const d = dispatcher(() =>
|
|
280
354
|
events(
|
|
281
|
-
{ type: "
|
|
282
|
-
{ type: "text-delta", text: "
|
|
355
|
+
{ type: "output-item", item: { id: "m", type: "message", phase: "final_answer", content: [] } },
|
|
356
|
+
{ type: "text-delta", text: "a", itemId: "m" },
|
|
357
|
+
{ type: "text-delta", text: "b", itemId: "m" },
|
|
358
|
+
{ type: "output-item", completed: true, item: { id: "m", type: "message", phase: "final_answer", content: [{type: "output_text", text: "ab"}] } },
|
|
283
359
|
{ type: "completed", responseId: "r" },
|
|
284
360
|
),
|
|
285
361
|
);
|
|
286
362
|
await d.onTokenAdded(1, "one", credentials);
|
|
287
363
|
const deltas: string[] = [];
|
|
288
364
|
const complete = vi.fn();
|
|
289
|
-
await d.generateStream(request(), {
|
|
365
|
+
await d.generateStream(request({stopAfterOutputMessage: () => false}), {
|
|
290
366
|
onDelta: (v) => deltas.push(v),
|
|
291
367
|
onComplete: complete,
|
|
292
368
|
onError: vi.fn(),
|
|
@@ -295,6 +371,21 @@ describe("OpenAIDispatcher direct runtime", () => {
|
|
|
295
371
|
expect(complete).toHaveBeenCalledWith(expect.objectContaining({ text: "ab" }));
|
|
296
372
|
});
|
|
297
373
|
|
|
374
|
+
it("still rejects an upstream failure after accepting a completed output message", async () => {
|
|
375
|
+
const failure = new Error("upstream failed after tool request");
|
|
376
|
+
const d = dispatcher(() => events(
|
|
377
|
+
{type: "text-delta", text: "tool request"},
|
|
378
|
+
{type: "output-item", completed: true, item: {type: "message", content: [{type: "output_text", text: "tool request"}]}},
|
|
379
|
+
{type: "error", error: failure},
|
|
380
|
+
));
|
|
381
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
382
|
+
const complete = vi.fn();
|
|
383
|
+
const onError = vi.fn();
|
|
384
|
+
await expect(d.generateStream(request({stopAfterOutputMessage: () => true}), {onDelta: vi.fn(), onComplete: complete, onError})).rejects.toBe(failure);
|
|
385
|
+
expect(complete).not.toHaveBeenCalled();
|
|
386
|
+
expect(onError).toHaveBeenCalledWith(failure);
|
|
387
|
+
});
|
|
388
|
+
|
|
298
389
|
it("returns multiple hosted images with one driver request", async () => {
|
|
299
390
|
const run = vi.fn(() => events(
|
|
300
391
|
{ type: "image", id: "first", base64: "first", mimeType: "image/png" },
|
|
@@ -492,7 +583,7 @@ describe("OpenAIDispatcher direct runtime", () => {
|
|
|
492
583
|
await vi.waitFor(() => expect(releases).toHaveLength(3));
|
|
493
584
|
expect(d.inFlight).toBe(3);
|
|
494
585
|
|
|
495
|
-
|
|
586
|
+
releases.splice(0).forEach((release) => release());
|
|
496
587
|
await Promise.all([first, second, third]);
|
|
497
588
|
expect(d.inFlight).toBe(0);
|
|
498
589
|
});
|