@cartanova/qgrid-cli 2.3.5 → 2.3.7
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/bundle/dist/application/qgrid/oauth.js +15 -12
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +40 -20
- package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
- package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
- package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
- package/bundle/dist/application/request-log/request-log.model.js +12 -7
- package/bundle/dist/application/sonamu.generated.js +11 -3
- package/bundle/dist/application/sonamu.generated.sso.js +7 -2
- package/bundle/dist/application/token/token.model.js +10 -2
- package/bundle/dist/application/token/token.types.js +5 -3
- package/bundle/dist/i18n/sd.generated.js +4 -1
- package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
- package/bundle/dist/sonamu.config.js +13 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +8 -3
- package/bundle/dist/utils/providers/common/ttft.js +25 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
- package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
- package/bundle/src/application/qgrid/oauth.ts +17 -7
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +172 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +45 -20
- package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
- package/bundle/src/application/request-log/request-log.entity.json +3 -0
- package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
- package/bundle/src/application/request-log/request-log.model.ts +20 -6
- package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
- package/bundle/src/application/sonamu.generated.http +7 -2
- package/bundle/src/application/sonamu.generated.sso.ts +5 -0
- package/bundle/src/application/sonamu.generated.ts +11 -1
- package/bundle/src/application/token/token.entity.json +8 -2
- package/bundle/src/application/token/token.model.test.ts +69 -0
- package/bundle/src/application/token/token.model.ts +8 -1
- package/bundle/src/application/token/token.types.test.ts +34 -0
- package/bundle/src/application/token/token.types.ts +5 -0
- package/bundle/src/i18n/sd.generated.ts +3 -0
- package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
- package/bundle/src/sonamu.config.ts +8 -1
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +7 -2
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
- package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
- package/bundle/src/utils/providers/common/ttft.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
- package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
- package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
- package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
- package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
- package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
- package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
- package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
- package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
- package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
- package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
- package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
- package/bundle/web-dist/server/entry-server.generated.js +6 -7
- package/dist/cli.js +67 -15
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
- package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
- package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
- package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
- /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
|
|
3
4
|
import { type OpenAICredentials } from "../../../application/token/token.types";
|
|
5
|
+
import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
|
|
4
6
|
import { type CodexAppServerWorker } from "./codex-worker";
|
|
5
7
|
import { OpenAIDispatcher } from "./openai-dispatcher";
|
|
8
|
+
import { type OpenAIQuotaUsageResult } from "./openai-quota";
|
|
9
|
+
|
|
10
|
+
const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
|
|
11
|
+
readOpenAIQuotaUsageMock: vi.fn(),
|
|
12
|
+
loggerInfoMock: vi.fn(),
|
|
13
|
+
loggerWarnMock: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@logtape/logtape", () => ({
|
|
17
|
+
getLogger: () => ({ info: loggerInfoMock, warn: loggerWarnMock }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./openai-quota", () => ({
|
|
21
|
+
readOpenAIQuotaUsage: readOpenAIQuotaUsageMock,
|
|
22
|
+
}));
|
|
6
23
|
|
|
7
24
|
function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredentials {
|
|
8
25
|
return {
|
|
@@ -15,12 +32,121 @@ function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredenti
|
|
|
15
32
|
};
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
function
|
|
35
|
+
function quotaOk(utilizationPct: number): OpenAIQuotaUsageResult {
|
|
36
|
+
return {
|
|
37
|
+
kind: "ok",
|
|
38
|
+
utilizationPct,
|
|
39
|
+
cacheAgeMs: 100,
|
|
40
|
+
windowDurationMins: 300,
|
|
41
|
+
resetsAt: 1_782_912_345,
|
|
42
|
+
limitId: "codex-primary",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function quotaFail(reason = "rate limit lookup failed"): OpenAIQuotaUsageResult {
|
|
47
|
+
return { kind: "lookup_failed", reason };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function baseReq(overrides: Partial<GenerateRequest> = {}): GenerateRequest {
|
|
51
|
+
return {
|
|
52
|
+
model: "gpt-5-codex",
|
|
53
|
+
systemPrompt: "you are helpful",
|
|
54
|
+
coldInput: [{ type: "text", text: "hi", text_elements: [] }],
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resultFor(worker: CodexAppServerWorker, threadId = "thread-new"): GenerateResult {
|
|
19
60
|
return {
|
|
61
|
+
text: "ok",
|
|
62
|
+
tokenName: worker.tokenName,
|
|
63
|
+
usage: {
|
|
64
|
+
totalTokens: 1,
|
|
65
|
+
inputTokens: 1,
|
|
66
|
+
cachedInputTokens: 0,
|
|
67
|
+
outputTokens: 0,
|
|
68
|
+
reasoningOutputTokens: 0,
|
|
69
|
+
},
|
|
70
|
+
durationMs: 10,
|
|
71
|
+
model: "gpt-5-codex",
|
|
72
|
+
threadCoord: {
|
|
73
|
+
workerId: worker.tokenId * 10 + worker.workerIndex,
|
|
74
|
+
threadId,
|
|
75
|
+
epoch: worker.epoch,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fakeWorker(
|
|
81
|
+
reusableOrOptions:
|
|
82
|
+
| boolean
|
|
83
|
+
| {
|
|
84
|
+
tokenId?: number;
|
|
85
|
+
tokenName?: string;
|
|
86
|
+
workerIndex?: number;
|
|
87
|
+
reusable?: boolean;
|
|
88
|
+
ready?: boolean;
|
|
89
|
+
active?: boolean;
|
|
90
|
+
busy?: boolean;
|
|
91
|
+
threads?: string[];
|
|
92
|
+
epoch?: number;
|
|
93
|
+
rateLimits?: unknown;
|
|
94
|
+
},
|
|
95
|
+
): CodexAppServerWorker {
|
|
96
|
+
const options =
|
|
97
|
+
typeof reusableOrOptions === "boolean" ? { reusable: reusableOrOptions } : reusableOrOptions;
|
|
98
|
+
let busy = options.busy ?? false;
|
|
99
|
+
const worker = {
|
|
100
|
+
tokenId: options.tokenId ?? 1,
|
|
101
|
+
tokenName: options.tokenName ?? "token",
|
|
102
|
+
workerIndex: options.workerIndex ?? 0,
|
|
103
|
+
active: options.active ?? true,
|
|
104
|
+
epoch: options.epoch ?? 1,
|
|
20
105
|
kill: vi.fn(async () => {}),
|
|
21
|
-
canReuseForToken: vi.fn(() => reusable),
|
|
106
|
+
canReuseForToken: vi.fn(() => options.reusable ?? true),
|
|
22
107
|
updateTokenState: vi.fn(),
|
|
108
|
+
get isReady() {
|
|
109
|
+
return options.ready ?? true;
|
|
110
|
+
},
|
|
111
|
+
tryAcquireTurn: vi.fn(() => {
|
|
112
|
+
if (busy) return false;
|
|
113
|
+
busy = true;
|
|
114
|
+
return true;
|
|
115
|
+
}),
|
|
116
|
+
releaseTurn: vi.fn(() => {
|
|
117
|
+
busy = false;
|
|
118
|
+
}),
|
|
119
|
+
hasThread: vi.fn((threadId: string) => (options.threads ?? []).includes(threadId)),
|
|
120
|
+
getRateLimits: vi.fn(
|
|
121
|
+
async () => options.rateLimits ?? { rateLimits: {}, rateLimitsByLimitId: null },
|
|
122
|
+
),
|
|
23
123
|
} as unknown as CodexAppServerWorker;
|
|
124
|
+
return worker;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
readOpenAIQuotaUsageMock.mockReset();
|
|
129
|
+
readOpenAIQuotaUsageMock.mockResolvedValue(quotaOk(0));
|
|
130
|
+
loggerInfoMock.mockReset();
|
|
131
|
+
loggerWarnMock.mockReset();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[]): void {
|
|
135
|
+
const byToken = new Map<number, CodexAppServerWorker[]>();
|
|
136
|
+
for (const worker of workers) {
|
|
137
|
+
const group = byToken.get(worker.tokenId) ?? [];
|
|
138
|
+
group.push(worker);
|
|
139
|
+
byToken.set(worker.tokenId, group);
|
|
140
|
+
}
|
|
141
|
+
for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
|
|
145
|
+
for (let i = 0; i < 10; i++) {
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
147
|
+
if (dispatcher.queueLength === length) return;
|
|
148
|
+
}
|
|
149
|
+
expect(dispatcher.queueLength).toBe(length);
|
|
24
150
|
}
|
|
25
151
|
|
|
26
152
|
describe("OpenAIDispatcher token updates", () => {
|
|
@@ -36,7 +162,7 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
36
162
|
idToken: "new-id",
|
|
37
163
|
accessTokenExpiresAt: Date.now() + 120_000,
|
|
38
164
|
});
|
|
39
|
-
await dispatcher.onTokenUpdated(1, "renamed-token", rotated);
|
|
165
|
+
await dispatcher.onTokenUpdated(1, "renamed-token", rotated, 80);
|
|
40
166
|
|
|
41
167
|
expect(worker.kill).not.toHaveBeenCalled();
|
|
42
168
|
expect(worker.updateTokenState).toHaveBeenCalledWith("renamed-token", rotated);
|
|
@@ -51,9 +177,255 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
51
177
|
dispatcher.workerPool.set(1, [worker]);
|
|
52
178
|
|
|
53
179
|
const changedIdentity = credentials({ accountId: "other-account" });
|
|
54
|
-
await dispatcher.onTokenUpdated(1, "token", changedIdentity);
|
|
180
|
+
await dispatcher.onTokenUpdated(1, "token", changedIdentity, null);
|
|
55
181
|
|
|
56
182
|
expect(worker.kill).toHaveBeenCalledTimes(1);
|
|
57
|
-
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity);
|
|
183
|
+
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("uses tokenId keyed rate-limit cache", async () => {
|
|
187
|
+
const dispatcher = new OpenAIDispatcher();
|
|
188
|
+
const rateLimitsA = { rateLimits: { primary: { usedPercent: 10 } }, rateLimitsByLimitId: null };
|
|
189
|
+
const rateLimitsB = { rateLimits: { primary: { usedPercent: 20 } }, rateLimitsByLimitId: null };
|
|
190
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "same-name", rateLimits: rateLimitsA });
|
|
191
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "same-name", rateLimits: rateLimitsB });
|
|
192
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
193
|
+
|
|
194
|
+
await expect(dispatcher.getRateLimitsByTokenId(1)).resolves.toMatchObject({
|
|
195
|
+
data: rateLimitsA,
|
|
196
|
+
});
|
|
197
|
+
await expect(dispatcher.getRateLimitsByTokenId(1)).resolves.toMatchObject({
|
|
198
|
+
data: rateLimitsA,
|
|
199
|
+
});
|
|
200
|
+
await expect(dispatcher.getRateLimitsByTokenId(2)).resolves.toMatchObject({
|
|
201
|
+
data: rateLimitsB,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(workerA.getRateLimits).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(workerB.getRateLimits).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("replaceTokens removes absent OpenAI workers and updates threshold metadata", async () => {
|
|
209
|
+
const dispatcher = new OpenAIDispatcher();
|
|
210
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
211
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
|
|
212
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
213
|
+
|
|
214
|
+
await dispatcher.replaceTokens([
|
|
215
|
+
{ id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80 },
|
|
216
|
+
]);
|
|
217
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
218
|
+
|
|
219
|
+
await expect(dispatcher.acquireIdleWorker()).rejects.toBeInstanceOf(
|
|
220
|
+
QuotaThresholdExceededError,
|
|
221
|
+
);
|
|
222
|
+
expect(workerB.kill).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("OpenAIDispatcher quota threshold gate", () => {
|
|
227
|
+
it("excludes over-threshold tokenIds before idle selection", async () => {
|
|
228
|
+
const dispatcher = new OpenAIDispatcher();
|
|
229
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
230
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
|
|
231
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
232
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
233
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
|
|
234
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85)).mockResolvedValueOnce(quotaOk(10));
|
|
235
|
+
|
|
236
|
+
const worker = await dispatcher.acquireIdleWorker();
|
|
237
|
+
|
|
238
|
+
expect(worker?.tokenName).toBe("tok-B");
|
|
239
|
+
expect(workerA.tryAcquireTurn).not.toHaveBeenCalled();
|
|
240
|
+
expect(loggerInfoMock).toHaveBeenCalledWith(
|
|
241
|
+
expect.stringContaining("over_threshold"),
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
tokenId: 1,
|
|
244
|
+
tokenName: "tok-A",
|
|
245
|
+
provider: "openai",
|
|
246
|
+
threshold: 80,
|
|
247
|
+
cachedUtilization: 85,
|
|
248
|
+
windowDurationMins: 300,
|
|
249
|
+
resetsAt: 1_782_912_345,
|
|
250
|
+
limitId: "codex-primary",
|
|
251
|
+
reason: "over_threshold",
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps eligible busy workers queued instead of throwing quota errors", async () => {
|
|
257
|
+
const dispatcher = new OpenAIDispatcher();
|
|
258
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
|
|
259
|
+
addWorkers(dispatcher, [worker]);
|
|
260
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
261
|
+
readOpenAIQuotaUsageMock.mockResolvedValue(quotaOk(10));
|
|
262
|
+
|
|
263
|
+
await expect(dispatcher.acquireIdleWorker()).resolves.toBeNull();
|
|
264
|
+
const queued = dispatcher.enqueue(async (w) => w.tokenName);
|
|
265
|
+
await waitForQueue(dispatcher);
|
|
266
|
+
|
|
267
|
+
worker.releaseTurn();
|
|
268
|
+
await dispatcher.drainQueue(worker);
|
|
269
|
+
await expect(queued).resolves.toBe("tok-A");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("throws typed error when no active ready tokenId passes threshold", async () => {
|
|
273
|
+
const dispatcher = new OpenAIDispatcher();
|
|
274
|
+
addWorkers(dispatcher, [fakeWorker({ tokenId: 1, tokenName: "tok-A" })]);
|
|
275
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
276
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(90));
|
|
277
|
+
|
|
278
|
+
const error = await dispatcher.acquireIdleWorker().catch((e) => e);
|
|
279
|
+
|
|
280
|
+
expect(error).toBeInstanceOf(QuotaThresholdExceededError);
|
|
281
|
+
expect(error).toMatchObject({ code: "QUOTA_THRESHOLD_EXCEEDED" });
|
|
282
|
+
expect(error.message).toBe(
|
|
283
|
+
"All openai tokens exceeded quota threshold: tok-A (threshold 80%)",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("rechecks threshold at drain time before assigning the released worker", async () => {
|
|
288
|
+
const dispatcher = new OpenAIDispatcher();
|
|
289
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
290
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
|
|
291
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
292
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
293
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
|
|
294
|
+
const queued = dispatcher.enqueue(async (w) => w.tokenName);
|
|
295
|
+
await waitForQueue(dispatcher);
|
|
296
|
+
readOpenAIQuotaUsageMock.mockReset();
|
|
297
|
+
readOpenAIQuotaUsageMock
|
|
298
|
+
.mockResolvedValueOnce(quotaOk(90))
|
|
299
|
+
.mockResolvedValueOnce(quotaOk(90))
|
|
300
|
+
.mockResolvedValueOnce(quotaOk(10));
|
|
301
|
+
|
|
302
|
+
await dispatcher.drainQueue(workerA);
|
|
303
|
+
|
|
304
|
+
await expect(queued).resolves.toBe("tok-B");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("serializes concurrent drain calls so a queue item is not assigned twice", async () => {
|
|
308
|
+
const dispatcher = new OpenAIDispatcher();
|
|
309
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
310
|
+
addWorkers(dispatcher, [worker]);
|
|
311
|
+
const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
|
|
312
|
+
const queued = dispatcher.enqueue(execute);
|
|
313
|
+
await waitForQueue(dispatcher);
|
|
314
|
+
|
|
315
|
+
await Promise.all([dispatcher.drainQueue(worker), dispatcher.drainQueue(worker)]);
|
|
316
|
+
|
|
317
|
+
await expect(queued).resolves.toBe("tok-A");
|
|
318
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("treats quota lookup failure as fail-open", async () => {
|
|
322
|
+
const dispatcher = new OpenAIDispatcher();
|
|
323
|
+
addWorkers(dispatcher, [fakeWorker({ tokenId: 1, tokenName: "tok-A" })]);
|
|
324
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
325
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaFail("rpc timeout"));
|
|
326
|
+
|
|
327
|
+
await expect(dispatcher.acquireIdleWorker()).resolves.toMatchObject({ tokenName: "tok-A" });
|
|
328
|
+
expect(loggerWarnMock).toHaveBeenCalledWith(
|
|
329
|
+
expect.stringContaining("lookup_fail_open"),
|
|
330
|
+
expect.objectContaining({
|
|
331
|
+
tokenId: 1,
|
|
332
|
+
tokenName: "tok-A",
|
|
333
|
+
provider: "openai",
|
|
334
|
+
threshold: 80,
|
|
335
|
+
reason: "lookup_fail_open",
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("falls back from over-threshold reuse to cold on another tokenId", async () => {
|
|
341
|
+
const dispatcher = new OpenAIDispatcher();
|
|
342
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", threads: ["thread-1"] });
|
|
343
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
|
|
344
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
345
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
346
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
|
|
347
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85)).mockResolvedValueOnce(quotaOk(10));
|
|
348
|
+
const executeTurn = vi
|
|
349
|
+
.spyOn(dispatcher, "executeTurn")
|
|
350
|
+
.mockImplementation(async (worker, _req, reuseThreadId) =>
|
|
351
|
+
resultFor(worker, reuseThreadId ?? "thread-new"),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const result = await dispatcher.generate(
|
|
355
|
+
baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(result.tokenName).toBe("tok-B");
|
|
359
|
+
expect(executeTurn.mock.calls[0]?.[0]).toBe(workerB);
|
|
360
|
+
expect(executeTurn.mock.calls[0]?.[2]).toBeUndefined();
|
|
361
|
+
expect(workerA.tryAcquireTurn).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("keeps reuse when the reused token is under threshold", async () => {
|
|
365
|
+
const dispatcher = new OpenAIDispatcher();
|
|
366
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", threads: ["thread-1"] });
|
|
367
|
+
addWorkers(dispatcher, [worker]);
|
|
368
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
369
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(10));
|
|
370
|
+
const executeTurn = vi
|
|
371
|
+
.spyOn(dispatcher, "executeTurn")
|
|
372
|
+
.mockImplementation(async (selected, _req, reuseThreadId) =>
|
|
373
|
+
resultFor(selected, reuseThreadId ?? "thread-new"),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const result = await dispatcher.generate(
|
|
377
|
+
baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(result.tokenName).toBe("tok-A");
|
|
381
|
+
expect(executeTurn).toHaveBeenCalledWith(worker, expect.anything(), "thread-1");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("passes worker ttftMs into GenerateResult", async () => {
|
|
385
|
+
const dispatcher = new OpenAIDispatcher();
|
|
386
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
387
|
+
const usage = {
|
|
388
|
+
totalTokens: 12,
|
|
389
|
+
inputTokens: 5,
|
|
390
|
+
cachedInputTokens: 0,
|
|
391
|
+
outputTokens: 7,
|
|
392
|
+
reasoningOutputTokens: 0,
|
|
393
|
+
};
|
|
394
|
+
(
|
|
395
|
+
worker as unknown as {
|
|
396
|
+
executeTurn: () => Promise<{
|
|
397
|
+
text: string;
|
|
398
|
+
usage: typeof usage;
|
|
399
|
+
durationMs: number;
|
|
400
|
+
ttftMs: number | null;
|
|
401
|
+
model: string;
|
|
402
|
+
threadId: string;
|
|
403
|
+
}>;
|
|
404
|
+
}
|
|
405
|
+
).executeTurn = vi.fn(async () => ({
|
|
406
|
+
text: "ok",
|
|
407
|
+
usage,
|
|
408
|
+
durationMs: 80,
|
|
409
|
+
ttftMs: 11,
|
|
410
|
+
model: "gpt-5-codex",
|
|
411
|
+
threadId: "thread-1",
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
const result = await dispatcher.executeTurn(worker, baseReq());
|
|
415
|
+
|
|
416
|
+
expect(result.ttftMs).toBe(11);
|
|
417
|
+
expect(result.threadCoord?.threadId).toBe("thread-1");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("throws typed error when reuse is over threshold and no cold tokenId is eligible", async () => {
|
|
421
|
+
const dispatcher = new OpenAIDispatcher();
|
|
422
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", threads: ["thread-1"] });
|
|
423
|
+
addWorkers(dispatcher, [worker]);
|
|
424
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
425
|
+
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
426
|
+
|
|
427
|
+
await expect(
|
|
428
|
+
dispatcher.generate(baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } })),
|
|
429
|
+
).rejects.toBeInstanceOf(QuotaThresholdExceededError);
|
|
58
430
|
});
|
|
59
431
|
});
|