@coseung2/opencodex 2.8.0-cs.1 → 2.8.0-cs.10
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.md +9 -0
- package/bin/ocx-notch.mjs +27 -1
- package/bin/ocx.mjs +0 -0
- package/gui/dist/assets/{index-CAnnes06.js → index-BvAys666.js} +3 -3
- package/gui/dist/assets/{index-OY43ubAq.css → index-CxisOo-q.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/packages/ocx-notch/README.md +7 -3
- package/src/adapters/kiro-constants.ts +1 -1
- package/src/adapters/kiro.ts +29 -6
- package/src/cli/index.ts +67 -29
- package/src/cli/status.ts +4 -3
- package/src/config.ts +4 -3
- package/src/providers/quota.ts +312 -18
- package/src/providers/registry.ts +1 -0
- package/src/responses/parser.ts +9 -1
- package/src/responses/state.ts +19 -2
- package/src/server/lifecycle.ts +23 -0
- package/src/server/management/oauth-account-routes.ts +11 -1
- package/src/server/memory-watchdog.ts +72 -7
- package/src/server/proxy-liveness.ts +5 -1
- package/src/service.ts +14 -7
- package/src/types.ts +2 -0
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
package/src/providers/quota.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
|
3
3
|
import { resolveEnvValue } from "../config";
|
|
4
4
|
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
5
5
|
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
6
|
+
import { readUsageEntries } from "../usage/log";
|
|
6
7
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
7
8
|
import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
|
|
8
9
|
import type { KiroOAuthMetadata } from "../oauth/types";
|
|
@@ -20,16 +21,52 @@ const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
|
20
21
|
|
|
21
22
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
22
23
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
24
|
+
/** Kiro's getUsageLimits endpoint cold-starts slowly after idle periods (>8s seen); keep a
|
|
25
|
+
* dedicated, more generous bound so a flaky first hit does not drop the provider into the
|
|
26
|
+
* "no quota" bucket for the rest of the negative-cache window. */
|
|
27
|
+
const KIRO_QUOTA_TIMEOUT_MS = 20_000;
|
|
23
28
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
24
29
|
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
|
|
25
30
|
const KIRO_USAGE_LIMITS_PATH = "getUsageLimits";
|
|
26
31
|
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
27
32
|
const LAST_GOOD_MAX_AGE_MS = 30 * 60_000;
|
|
28
33
|
|
|
34
|
+
const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
35
|
+
const OPENCODE_GO_COST_WINDOW_MS = 30 * 86_400_000;
|
|
36
|
+
const OPENCODE_GO_FIVE_HOUR_MS = 5 * 3_600_000;
|
|
37
|
+
const OPENCODE_GO_WEEK_MS = 7 * 86_400_000;
|
|
38
|
+
const OPENCODE_GO_USAGE_PATH = "/usage";
|
|
39
|
+
|
|
40
|
+
/** opencode.go published request limits per window (1x tier), opencode.ai/docs/go. */
|
|
41
|
+
const OPENCODE_GO_LIMITS: Record<string, { label: string; fiveHour: number; weekly: number; monthly: number }> = {
|
|
42
|
+
"grok-4.5": { label: "Grok 4.5", fiveHour: 120, weekly: 300, monthly: 600 },
|
|
43
|
+
"gpt-5.6-luna": { label: "GPT 5.6 Luna", fiveHour: 2_050, weekly: 5_100, monthly: 10_250 },
|
|
44
|
+
"glm-5.2": { label: "GLM-5.2", fiveHour: 880, weekly: 2_150, monthly: 4_300 },
|
|
45
|
+
"glm-5.1": { label: "GLM-5.1", fiveHour: 880, weekly: 2_150, monthly: 4_300 },
|
|
46
|
+
"kimi-k3": { label: "Kimi K3", fiveHour: 110, weekly: 250, monthly: 490 },
|
|
47
|
+
"kimi-k2.7-code": { label: "Kimi K2.7 Code", fiveHour: 1_350, weekly: 3_380, monthly: 6_750 },
|
|
48
|
+
"kimi-k2.6": { label: "Kimi K2.6", fiveHour: 1_150, weekly: 2_880, monthly: 5_750 },
|
|
49
|
+
"mimo-v2.5": { label: "MiMo-V2.5", fiveHour: 30_100, weekly: 75_200, monthly: 150_400 },
|
|
50
|
+
"mimo-v2.5-pro": { label: "MiMo-V2.5 Pro", fiveHour: 3_250, weekly: 8_150, monthly: 16_300 },
|
|
51
|
+
"minimax-m3": { label: "MiniMax M3", fiveHour: 3_200, weekly: 8_000, monthly: 16_000 },
|
|
52
|
+
"minimax-m2.7": { label: "MiniMax M2.7", fiveHour: 3_400, weekly: 8_500, monthly: 17_000 },
|
|
53
|
+
"qwen3.8-max": { label: "Qwen3.8 Max", fiveHour: 160, weekly: 400, monthly: 810 },
|
|
54
|
+
"qwen3.7-max": { label: "Qwen3.7 Max", fiveHour: 340, weekly: 840, monthly: 1_690 },
|
|
55
|
+
"qwen3.7-plus": { label: "Qwen3.7 Plus", fiveHour: 4_300, weekly: 10_800, monthly: 21_600 },
|
|
56
|
+
"qwen3.6-plus": { label: "Qwen3.6 Plus", fiveHour: 3_300, weekly: 8_200, monthly: 16_300 },
|
|
57
|
+
"deepseek-v4-pro": { label: "DeepSeek V4 Pro", fiveHour: 3_450, weekly: 8_550, monthly: 17_150 },
|
|
58
|
+
"deepseek-v4-flash": { label: "DeepSeek V4 Flash", fiveHour: 31_650, weekly: 79_050, monthly: 158_150 },
|
|
59
|
+
"hy3": { label: "Hy3", fiveHour: 4_300, weekly: 10_750, monthly: 21_500 },
|
|
60
|
+
};
|
|
61
|
+
|
|
29
62
|
export interface ProviderQuotaWindow {
|
|
30
63
|
label: string;
|
|
31
64
|
percent: number;
|
|
32
65
|
resetAt?: number;
|
|
66
|
+
/** Text value shown instead of a percent bar (e.g. an estimated cost). */
|
|
67
|
+
valueLabel?: string;
|
|
68
|
+
/** Compact percent segments rendered as narrow bars on ONE row (label/percent per segment). */
|
|
69
|
+
segments?: { label: string; percent: number; resetAt?: number }[];
|
|
33
70
|
}
|
|
34
71
|
|
|
35
72
|
export interface ProviderQuota {
|
|
@@ -81,7 +118,8 @@ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is Provide
|
|
|
81
118
|
return typeof quota.fiveHourPercent === "number"
|
|
82
119
|
|| typeof quota.weeklyPercent === "number"
|
|
83
120
|
|| typeof quota.monthlyPercent === "number"
|
|
84
|
-
|| !!quota.customWindows?.some(window =>
|
|
121
|
+
|| !!quota.customWindows?.some(window =>
|
|
122
|
+
typeof window.percent === "number" || typeof window.valueLabel === "string");
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
function providerLabel(providerId: string): string {
|
|
@@ -118,6 +156,13 @@ function normalizePercent(value: unknown): number | undefined {
|
|
|
118
156
|
return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric));
|
|
119
157
|
}
|
|
120
158
|
|
|
159
|
+
/** Numeric value from a billing object ({ val: 123 }) or a bare number. */
|
|
160
|
+
function billingValue(value: unknown): number | undefined {
|
|
161
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
162
|
+
const record = asRecord(value);
|
|
163
|
+
return toFiniteNumber(record?.val);
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
122
167
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
123
168
|
}
|
|
@@ -157,11 +202,6 @@ async function fetchChatGptForwardQuota(
|
|
|
157
202
|
return quota ? report(provider, "chatgpt:wham", quota) : null;
|
|
158
203
|
}
|
|
159
204
|
|
|
160
|
-
function centsValue(value: unknown): number | undefined {
|
|
161
|
-
const rec = asRecord(value);
|
|
162
|
-
return rec ? toFiniteNumber(rec.val) : undefined;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
205
|
async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
166
206
|
let accessToken: string;
|
|
167
207
|
try {
|
|
@@ -169,25 +209,50 @@ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | nu
|
|
|
169
209
|
} catch {
|
|
170
210
|
return null;
|
|
171
211
|
}
|
|
172
|
-
const response = await fetch("https://cli-chat-proxy.grok.com/v1/billing", {
|
|
173
|
-
headers: {
|
|
212
|
+
const response = await fetch("https://cli-chat-proxy.grok.com/v1/billing?format=credits", {
|
|
213
|
+
headers: {
|
|
214
|
+
Accept: "application/json",
|
|
215
|
+
Authorization: `Bearer ${accessToken}`,
|
|
216
|
+
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
217
|
+
"x-grok-client-mode": "cli",
|
|
218
|
+
},
|
|
174
219
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
175
220
|
});
|
|
176
221
|
if (!response.ok) return null;
|
|
177
222
|
const body = asRecord(await response.json().catch(() => null));
|
|
178
223
|
const config = asRecord(body?.config);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
224
|
+
const currentPeriod = asRecord(config?.currentPeriod);
|
|
225
|
+
// A weekly window is the meter contract; other shapes (spend-cap etc.) fail closed.
|
|
226
|
+
if (currentPeriod?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
|
|
227
|
+
if (typeof currentPeriod.end !== "string" || !currentPeriod.end.trim()) return null;
|
|
228
|
+
const resetAt = normalizeResetAt(currentPeriod.end);
|
|
229
|
+
if (resetAt === undefined) return null;
|
|
230
|
+
|
|
231
|
+
let weeklyPercent: number | undefined;
|
|
232
|
+
if (typeof config?.creditUsagePercent === "number"
|
|
233
|
+
&& Number.isFinite(config.creditUsagePercent)
|
|
234
|
+
&& config.creditUsagePercent >= 0
|
|
235
|
+
&& config.creditUsagePercent <= 100) {
|
|
236
|
+
weeklyPercent = config.creditUsagePercent;
|
|
237
|
+
} else {
|
|
238
|
+
// Unified-billing shape exposes on-demand usage vs cap as { val } objects.
|
|
239
|
+
const cap = billingValue(config?.onDemandCap);
|
|
240
|
+
const used = billingValue(config?.onDemandUsed);
|
|
241
|
+
if (cap !== undefined && cap > 0 && used !== undefined) {
|
|
242
|
+
weeklyPercent = normalizePercent((used / cap) * 100);
|
|
243
|
+
} else if (used === 0) {
|
|
244
|
+
// A zero-usage unified account still owns the weekly meter; report 0% so the
|
|
245
|
+
// provider stays in the usage section instead of being reclassified as no-quota.
|
|
246
|
+
weeklyPercent = 0;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (weeklyPercent === undefined) return null;
|
|
185
250
|
const quota: ProviderQuota = {
|
|
186
|
-
|
|
187
|
-
|
|
251
|
+
weeklyPercent,
|
|
252
|
+
weeklyResetAt: resetAt,
|
|
188
253
|
updatedAt: Date.now(),
|
|
189
254
|
};
|
|
190
|
-
return report(provider, "xai:grok-
|
|
255
|
+
return report(provider, "xai:grok-credits", quota);
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
|
|
@@ -225,7 +290,7 @@ async function fetchKiroUsageQuota(
|
|
|
225
290
|
|
|
226
291
|
const response = await fetch(url, {
|
|
227
292
|
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
228
|
-
signal: AbortSignal.timeout(
|
|
293
|
+
signal: AbortSignal.timeout(KIRO_QUOTA_TIMEOUT_MS),
|
|
229
294
|
});
|
|
230
295
|
if (!response.ok) return null;
|
|
231
296
|
const body = asRecord(await response.json().catch(() => null));
|
|
@@ -701,6 +766,230 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
|
|
|
701
766
|
return quota ? report(provider, "kimi:usages", quota) : null;
|
|
702
767
|
}
|
|
703
768
|
|
|
769
|
+
function isCanonicalOpencodeGoBaseUrl(baseUrl: string | undefined): boolean {
|
|
770
|
+
return normalizedBaseUrl(baseUrl ?? "") === normalizedBaseUrl(OPENCODE_GO_BASE_URL);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
interface OpencodeGoUsageEstimate {
|
|
774
|
+
/** model -> request count in each window (status 200). */
|
|
775
|
+
fiveHourCounts: Map<string, number>;
|
|
776
|
+
weeklyCounts: Map<string, number>;
|
|
777
|
+
monthlyCounts: Map<string, number>;
|
|
778
|
+
/** key id -> 30-day request count (all traffic attributed to the active key). */
|
|
779
|
+
perKeyMonthlyCounts: Map<string, number>;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Read the local usage log once and count opencode.go requests per model per window.
|
|
784
|
+
* The console's allocation percent is request-count based (each model has published
|
|
785
|
+
* 5h/week/month request limits on opencode.ai/docs/go), so we compare like with like.
|
|
786
|
+
*
|
|
787
|
+
* Attribution: the pool's ACTIVE key serves every provider-bound request (loopback
|
|
788
|
+
* traffic carries no client key id), so all rows are charged to it. Rows written before
|
|
789
|
+
* a key switch stay with the currently active key — an intentional approximation.
|
|
790
|
+
*/
|
|
791
|
+
function estimateOpencodeGoUsage(name: string, config: OcxProviderConfig): OpencodeGoUsageEstimate | null {
|
|
792
|
+
if (!isCanonicalOpencodeGoBaseUrl(config.baseUrl)) return null;
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
const fiveHourAgo = now - OPENCODE_GO_FIVE_HOUR_MS;
|
|
795
|
+
const monthAgo = now - OPENCODE_GO_COST_WINDOW_MS;
|
|
796
|
+
const pool = config.apiKeyPool ?? [];
|
|
797
|
+
const activeKey = resolveEnvValue(config.apiKey)?.trim() ?? config.apiKey;
|
|
798
|
+
const activeKeyId = activeKey ? pool.find(entry => entry.key === activeKey)?.id : undefined;
|
|
799
|
+
|
|
800
|
+
const estimate: OpencodeGoUsageEstimate = {
|
|
801
|
+
fiveHourCounts: new Map(),
|
|
802
|
+
weeklyCounts: new Map(),
|
|
803
|
+
monthlyCounts: new Map(),
|
|
804
|
+
perKeyMonthlyCounts: new Map(pool.map(entry => [entry.id, 0])),
|
|
805
|
+
};
|
|
806
|
+
for (const entry of readUsageEntries()) {
|
|
807
|
+
if (entry.provider !== name || entry.status !== 200) continue;
|
|
808
|
+
const timestamp = entry.timestamp ?? 0;
|
|
809
|
+
if (!OPENCODE_GO_LIMITS[entry.model]) continue;
|
|
810
|
+
if (timestamp >= fiveHourAgo) {
|
|
811
|
+
estimate.fiveHourCounts.set(entry.model, (estimate.fiveHourCounts.get(entry.model) ?? 0) + 1);
|
|
812
|
+
}
|
|
813
|
+
if (timestamp >= now - OPENCODE_GO_WEEK_MS) {
|
|
814
|
+
estimate.weeklyCounts.set(entry.model, (estimate.weeklyCounts.get(entry.model) ?? 0) + 1);
|
|
815
|
+
}
|
|
816
|
+
if (timestamp >= monthAgo && activeKeyId) {
|
|
817
|
+
estimate.monthlyCounts.set(entry.model, (estimate.monthlyCounts.get(entry.model) ?? 0) + 1);
|
|
818
|
+
estimate.perKeyMonthlyCounts.set(activeKeyId, (estimate.perKeyMonthlyCounts.get(activeKeyId) ?? 0) + 1);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return estimate;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
interface OpencodeGoUsageWindow {
|
|
825
|
+
percent?: number;
|
|
826
|
+
resetAt?: number;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Real allocation windows from the opencode.go usage endpoint (key-scoped).
|
|
831
|
+
* The console shows exactly these percents and reset times, so this replaces the
|
|
832
|
+
* local estimate whenever the endpoint answers.
|
|
833
|
+
*/
|
|
834
|
+
async function fetchOpencodeGoUsageApi(apiKey: string | undefined): Promise<{
|
|
835
|
+
fiveHour: OpencodeGoUsageWindow;
|
|
836
|
+
weekly: OpencodeGoUsageWindow;
|
|
837
|
+
monthly: OpencodeGoUsageWindow;
|
|
838
|
+
} | null> {
|
|
839
|
+
if (!apiKey?.trim()) return null;
|
|
840
|
+
const response = await fetch(`${OPENCODE_GO_BASE_URL}${OPENCODE_GO_USAGE_PATH}`, {
|
|
841
|
+
headers: {
|
|
842
|
+
Accept: "application/json",
|
|
843
|
+
Authorization: `Bearer ${apiKey.trim()}`,
|
|
844
|
+
},
|
|
845
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
846
|
+
});
|
|
847
|
+
if (!response.ok) return null;
|
|
848
|
+
const body = asRecord(await response.json().catch(() => null));
|
|
849
|
+
const usage = asRecord(body?.usage);
|
|
850
|
+
if (!usage) return null;
|
|
851
|
+
const parse = (raw: unknown): OpencodeGoUsageWindow => {
|
|
852
|
+
const window = asRecord(raw);
|
|
853
|
+
const percent = normalizePercent(toFiniteNumber(window?.percent));
|
|
854
|
+
const resetAt = normalizeResetAt(window?.resetsAt);
|
|
855
|
+
return {
|
|
856
|
+
...(percent !== undefined ? { percent } : {}),
|
|
857
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
const fiveHour = parse(usage.rolling);
|
|
861
|
+
const weekly = parse(usage.weekly);
|
|
862
|
+
const monthly = parse(usage.monthly);
|
|
863
|
+
if (fiveHour.percent === undefined && weekly.percent === undefined && monthly.percent === undefined) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
return { fiveHour, weekly, monthly };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function opencodeGoSegments(api: NonNullable<Awaited<ReturnType<typeof fetchOpencodeGoUsageApi>>>): NonNullable<ProviderQuotaWindow["segments"]> {
|
|
870
|
+
const segments: NonNullable<ProviderQuotaWindow["segments"]> = [];
|
|
871
|
+
const push = (label: string, window: OpencodeGoUsageWindow) => {
|
|
872
|
+
if (window.percent === undefined) return;
|
|
873
|
+
segments.push({
|
|
874
|
+
label,
|
|
875
|
+
percent: window.percent,
|
|
876
|
+
...(window.resetAt !== undefined ? { resetAt: window.resetAt } : {}),
|
|
877
|
+
});
|
|
878
|
+
};
|
|
879
|
+
push("5h", api.fiveHour);
|
|
880
|
+
push("Weekly", api.weekly);
|
|
881
|
+
push("Monthly", api.monthly);
|
|
882
|
+
return segments;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* opencode.go allocation: prefer the key-scoped usage endpoint (exact console
|
|
887
|
+
* percents + real reset times); fall back to the local request-count estimate
|
|
888
|
+
* against published limits when the endpoint is unreachable or rejects.
|
|
889
|
+
*/
|
|
890
|
+
async function fetchOpencodeGoQuota(name: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
|
|
891
|
+
const activeKey = resolveEnvValue(config.apiKey)?.trim() ?? config.apiKey;
|
|
892
|
+
const api = await fetchOpencodeGoUsageApi(activeKey).catch(() => null);
|
|
893
|
+
if (api) {
|
|
894
|
+
const segments = opencodeGoSegments(api);
|
|
895
|
+
if (segments.length > 0) {
|
|
896
|
+
return report(name, "opencode-go:usage-api", {
|
|
897
|
+
customWindows: [{
|
|
898
|
+
// Row label intentionally empty: the segments carry their own labels.
|
|
899
|
+
label: "",
|
|
900
|
+
percent: 0,
|
|
901
|
+
segments,
|
|
902
|
+
}],
|
|
903
|
+
updatedAt: Date.now(),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Fallback: dominant model's local request counts against published limits.
|
|
909
|
+
const estimate = estimateOpencodeGoUsage(name, config);
|
|
910
|
+
if (!estimate) return null;
|
|
911
|
+
const dominant = [...estimate.monthlyCounts.entries()]
|
|
912
|
+
.sort((a, b) => b[1] - a[1] || (estimate.weeklyCounts.get(b[0]) ?? 0) - (estimate.weeklyCounts.get(a[0]) ?? 0))[0]?.[0];
|
|
913
|
+
if (!dominant) return null;
|
|
914
|
+
const { fiveHour, weekly, monthly } = OPENCODE_GO_LIMITS[dominant]!;
|
|
915
|
+
const now = Date.now();
|
|
916
|
+
return report(name, "opencode-go:docs-estimate", {
|
|
917
|
+
customWindows: [{
|
|
918
|
+
label: "",
|
|
919
|
+
percent: 0,
|
|
920
|
+
segments: [
|
|
921
|
+
{
|
|
922
|
+
label: "5h",
|
|
923
|
+
percent: normalizePercent(((estimate.fiveHourCounts.get(dominant) ?? 0) / fiveHour) * 100) ?? 0,
|
|
924
|
+
resetAt: now + OPENCODE_GO_FIVE_HOUR_MS,
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
label: "Weekly",
|
|
928
|
+
percent: normalizePercent(((estimate.weeklyCounts.get(dominant) ?? 0) / weekly) * 100) ?? 0,
|
|
929
|
+
resetAt: now + OPENCODE_GO_WEEK_MS,
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
label: "Monthly",
|
|
933
|
+
percent: normalizePercent(((estimate.monthlyCounts.get(dominant) ?? 0) / monthly) * 100) ?? 0,
|
|
934
|
+
resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
}],
|
|
938
|
+
updatedAt: now,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Per-key monthly-allocation percent for every connected key: the usage endpoint
|
|
944
|
+
* answers per key, so each pool key reports its own real monthly percent. Keys the
|
|
945
|
+
* endpoint rejects fall back to the local 30-day request estimate.
|
|
946
|
+
*/
|
|
947
|
+
export async function opencodeGoKeyQuotaEstimates(config: OcxConfig, name: string): Promise<Record<string, ProviderQuota> | null> {
|
|
948
|
+
const provider = config.providers[name];
|
|
949
|
+
if (!provider) return null;
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
const out: Record<string, ProviderQuota> = {};
|
|
952
|
+
const pool = provider.apiKeyPool ?? [];
|
|
953
|
+
if (pool.length > 0) {
|
|
954
|
+
const results = await Promise.all(pool.map(async entry => {
|
|
955
|
+
const api = await fetchOpencodeGoUsageApi(entry.key).catch(() => null);
|
|
956
|
+
return [entry.id, api?.monthly] as const;
|
|
957
|
+
}));
|
|
958
|
+
for (const [keyId, monthly] of results) {
|
|
959
|
+
if (monthly?.percent === undefined) continue;
|
|
960
|
+
out[keyId] = {
|
|
961
|
+
customWindows: [{
|
|
962
|
+
label: "월간 할당",
|
|
963
|
+
percent: monthly.percent,
|
|
964
|
+
...(monthly.resetAt !== undefined ? { resetAt: monthly.resetAt } : {}),
|
|
965
|
+
}],
|
|
966
|
+
updatedAt: now,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// Fallback for keys the endpoint did not answer.
|
|
971
|
+
const estimate = estimateOpencodeGoUsage(name, provider);
|
|
972
|
+
if (estimate) {
|
|
973
|
+
const dominant = [...estimate.monthlyCounts.entries()]
|
|
974
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
975
|
+
if (dominant) {
|
|
976
|
+
const monthlyLimit = OPENCODE_GO_LIMITS[dominant]!.monthly;
|
|
977
|
+
for (const [keyId, count] of estimate.perKeyMonthlyCounts) {
|
|
978
|
+
if (out[keyId]) continue;
|
|
979
|
+
out[keyId] = {
|
|
980
|
+
customWindows: [{
|
|
981
|
+
label: "월간 할당",
|
|
982
|
+
percent: normalizePercent((count / monthlyLimit) * 100) ?? 0,
|
|
983
|
+
resetAt: now + OPENCODE_GO_COST_WINDOW_MS,
|
|
984
|
+
}],
|
|
985
|
+
updatedAt: now,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
991
|
+
}
|
|
992
|
+
|
|
704
993
|
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
705
994
|
async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
706
995
|
let accessToken: string;
|
|
@@ -981,6 +1270,11 @@ async function maybeFetchProviderQuota(
|
|
|
981
1270
|
if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name, forceRefresh);
|
|
982
1271
|
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
983
1272
|
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
|
|
1273
|
+
// opencode.go is a key-auth subscription: estimate usage locally from the traffic log.
|
|
1274
|
+
if (provider.authMode !== "oauth" && provider.authMode !== "forward"
|
|
1275
|
+
&& isCanonicalOpencodeGoBaseUrl(provider.baseUrl)) {
|
|
1276
|
+
return fetchOpencodeGoQuota(name, provider);
|
|
1277
|
+
}
|
|
984
1278
|
// Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
|
|
985
1279
|
// host and only for real key auth — forward/local modes carry no credential of ours.
|
|
986
1280
|
if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
|
|
@@ -728,6 +728,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
728
728
|
baseUrl: "https://runtime.us-east-1.kiro.dev",
|
|
729
729
|
authKind: "oauth",
|
|
730
730
|
oauthId: "kiro",
|
|
731
|
+
dashboardPreset: true,
|
|
731
732
|
note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.",
|
|
732
733
|
models: KIRO_MODELS,
|
|
733
734
|
defaultModel: "kiro-auto",
|
package/src/responses/parser.ts
CHANGED
|
@@ -267,6 +267,7 @@ const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "
|
|
|
267
267
|
|
|
268
268
|
export function parseRequest(body: unknown): OcxParsedRequest {
|
|
269
269
|
const replayedInputPrefixLength = previousResponseReplayPrefixLength(body);
|
|
270
|
+
let replayedMessagePrefixLength = 0;
|
|
270
271
|
const parsed = responsesRequestSchema.safeParse(body);
|
|
271
272
|
if (!parsed.success) {
|
|
272
273
|
throw new Error(`responses parse error: ${parsed.error.message}`);
|
|
@@ -305,6 +306,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
305
306
|
messages.push({ role: "user", content: data.input, timestamp: now });
|
|
306
307
|
} else if (data.input) {
|
|
307
308
|
for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) {
|
|
309
|
+
if (inputIndex === replayedInputPrefixLength) replayedMessagePrefixLength = messages.length;
|
|
308
310
|
const item = data.input[inputIndex];
|
|
309
311
|
const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
|
|
310
312
|
|
|
@@ -571,6 +573,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
571
573
|
});
|
|
572
574
|
}
|
|
573
575
|
}
|
|
576
|
+
if (replayedInputPrefixLength > 0 && replayedInputPrefixLength >= data.input.length) {
|
|
577
|
+
replayedMessagePrefixLength = messages.length;
|
|
578
|
+
}
|
|
574
579
|
}
|
|
575
580
|
|
|
576
581
|
const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
|
|
@@ -637,7 +642,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
637
642
|
stream: data.stream === true,
|
|
638
643
|
options,
|
|
639
644
|
_rawBody: body,
|
|
640
|
-
...(replayedInputPrefixLength > 0 ? {
|
|
645
|
+
...(replayedInputPrefixLength > 0 ? {
|
|
646
|
+
_replayPrefixLen: replayedInputPrefixLength,
|
|
647
|
+
_replayMessagePrefixLen: replayedMessagePrefixLength,
|
|
648
|
+
} : {}),
|
|
641
649
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
642
650
|
...(imageGen ? { _imageGeneration: imageGen } : {}),
|
|
643
651
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
package/src/responses/state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import { atomicWriteFileAsync, getConfigDir } from "../config";
|
|
4
5
|
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
5
6
|
import type { OcxProviderContinuationState } from "../types";
|
|
@@ -584,6 +585,14 @@ function inputItems(input: unknown): unknown[] {
|
|
|
584
585
|
return [input];
|
|
585
586
|
}
|
|
586
587
|
|
|
588
|
+
function startsWithReplayPrefix(input: unknown[], prefix: unknown[]): boolean {
|
|
589
|
+
if (prefix.length === 0 || input.length < prefix.length) return false;
|
|
590
|
+
for (let index = 0; index < prefix.length; index++) {
|
|
591
|
+
if (!isDeepStrictEqual(input[index], prefix[index])) return false;
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
|
|
587
596
|
function pruneResponses(at = now()): void {
|
|
588
597
|
for (const [id, state] of states) {
|
|
589
598
|
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
|
|
@@ -710,11 +719,19 @@ export function expandPreviousResponseInput(body: unknown): unknown {
|
|
|
710
719
|
replayFailures.set(request, materialized.failure);
|
|
711
720
|
return body;
|
|
712
721
|
}
|
|
722
|
+
const previousItems = materialized.state.items;
|
|
723
|
+
const currentItems = inputItems(request.input);
|
|
724
|
+
// Some Responses clients send the complete prior input while still supplying
|
|
725
|
+
// previous_response_id. Treat an exact stored prefix as already expanded instead of prepending
|
|
726
|
+
// it a second time; keep the provenance marker so downstream adapters can distinguish replayed
|
|
727
|
+
// history from this turn's new input.
|
|
713
728
|
const expanded = {
|
|
714
729
|
...request,
|
|
715
|
-
input:
|
|
730
|
+
input: startsWithReplayPrefix(currentItems, previousItems)
|
|
731
|
+
? [...currentItems]
|
|
732
|
+
: [...previousItems, ...currentItems],
|
|
716
733
|
};
|
|
717
|
-
replayedInputPrefixLengths.set(expanded,
|
|
734
|
+
replayedInputPrefixLengths.set(expanded, previousItems.length);
|
|
718
735
|
return expanded;
|
|
719
736
|
}
|
|
720
737
|
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ActiveTurnLease extends AdmissionLease {
|
|
|
34
34
|
const activeTurns = new Map<AbortController, ActiveTurnLease>();
|
|
35
35
|
const admittedTurns = new Set<ActiveTurnLease>();
|
|
36
36
|
const knownTurnControllers = new WeakSet<AbortController>();
|
|
37
|
+
export type ActiveTurnsIdleListener = () => void;
|
|
38
|
+
const activeTurnsIdleListeners = new Set<ActiveTurnsIdleListener>();
|
|
37
39
|
let turnReleaseMisses = 0;
|
|
38
40
|
let draining = false;
|
|
39
41
|
let recyclingForExit = false;
|
|
@@ -41,6 +43,26 @@ let _serverRef: ReturnType<typeof Bun.serve> | undefined;
|
|
|
41
43
|
|
|
42
44
|
export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
|
|
43
45
|
export function setDraining(value: boolean): void { draining = value; }
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to the synchronous transition where the final admitted turn is
|
|
48
|
+
* released. The callback runs after the lease and admission gate are settled,
|
|
49
|
+
* so an observer can perform an idle-only action without racing a request on
|
|
50
|
+
* the event loop. Returns an unsubscribe function for bounded observers.
|
|
51
|
+
*/
|
|
52
|
+
export function onActiveTurnsIdle(listener: ActiveTurnsIdleListener): () => void {
|
|
53
|
+
activeTurnsIdleListeners.add(listener);
|
|
54
|
+
return () => { activeTurnsIdleListeners.delete(listener); };
|
|
55
|
+
}
|
|
56
|
+
function notifyActiveTurnsIdle(): void {
|
|
57
|
+
if (admittedTurns.size !== 0) return;
|
|
58
|
+
for (const listener of [...activeTurnsIdleListeners]) {
|
|
59
|
+
try {
|
|
60
|
+
listener();
|
|
61
|
+
} catch {
|
|
62
|
+
// An idle observer must never break turn release or request teardown.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
export function tryAdmitTurn(): ActiveTurnLease | null {
|
|
45
67
|
const gateLease = turnGate.tryAcquire();
|
|
46
68
|
if (!gateLease) return null;
|
|
@@ -68,6 +90,7 @@ export function tryAdmitTurn(): ActiveTurnLease | null {
|
|
|
68
90
|
}
|
|
69
91
|
controllers.clear();
|
|
70
92
|
gateLease.release();
|
|
93
|
+
notifyActiveTurnsIdle();
|
|
71
94
|
},
|
|
72
95
|
};
|
|
73
96
|
admittedTurns.add(lease);
|
|
@@ -417,7 +417,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
417
417
|
const name = (url.searchParams.get("name") ?? "").trim();
|
|
418
418
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
419
419
|
const { listProviderApiKeys } = await import("../../providers/api-keys");
|
|
420
|
-
|
|
420
|
+
const { opencodeGoKeyQuotaEstimates } = await import("../../providers/quota");
|
|
421
|
+
const result = listProviderApiKeys(config, name);
|
|
422
|
+
const keyQuotas = await opencodeGoKeyQuotaEstimates(config, name);
|
|
423
|
+
if (!keyQuotas) return jsonResponse(result);
|
|
424
|
+
return jsonResponse({
|
|
425
|
+
...result,
|
|
426
|
+
keys: result.keys.map(key => ({
|
|
427
|
+
...key,
|
|
428
|
+
...(keyQuotas[key.id] ? { quota: keyQuotas[key.id] } : {}),
|
|
429
|
+
})),
|
|
430
|
+
});
|
|
421
431
|
}
|
|
422
432
|
if (url.pathname === "/api/providers/keys" && req.method === "POST") {
|
|
423
433
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
|