@coseung2/opencodex 2.8.0-cs.8 → 2.8.0-cs.9

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-4BpFFQjG.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-DYcdEcsX.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-OY43ubAq.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coseung2/opencodex",
3
- "version": "2.8.0-cs.8",
3
+ "version": "2.8.0-cs.9",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -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,75 @@ 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
+
38
+ /**
39
+ * opencode.go published per-model prices (USD per 1M tokens), from opencode.ai/docs/go.
40
+ * Over-cap token tiers (GPT 5.6 Luna >272K, Qwen3.7/3.6 Plus >256K) use the base tier;
41
+ * the estimate is intentionally approximate ("정확할 필요는 없고").
42
+ */
43
+ const OPENCODE_GO_PRICES: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
44
+ "grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 },
45
+ "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
46
+ "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
47
+ "glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
48
+ "kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
49
+ "kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
50
+ "kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
51
+ "mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
52
+ "mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
53
+ "minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
54
+ "minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
55
+ "minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
56
+ "qwen3.8-max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 },
57
+ "qwen3.7-max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.125 },
58
+ "qwen3.7-plus": { input: 0.4, output: 1.6, cacheRead: 0.04, cacheWrite: 0.5 },
59
+ "qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 },
60
+ "deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
61
+ "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
62
+ "hy3": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 },
63
+ };
64
+
65
+ /** opencode.go published request limits per 5-hour window (1x tier), opencode.ai/docs/go. */
66
+ const OPENCODE_GO_FIVE_HOUR_LIMITS: Record<string, { label: string; limit: number }> = {
67
+ "grok-4.5": { label: "Grok 4.5", limit: 120 },
68
+ "gpt-5.6-luna": { label: "GPT 5.6 Luna", limit: 2_050 },
69
+ "glm-5.2": { label: "GLM-5.2", limit: 880 },
70
+ "glm-5.1": { label: "GLM-5.1", limit: 880 },
71
+ "kimi-k3": { label: "Kimi K3", limit: 110 },
72
+ "kimi-k2.7-code": { label: "Kimi K2.7 Code", limit: 1_350 },
73
+ "kimi-k2.6": { label: "Kimi K2.6", limit: 1_150 },
74
+ "mimo-v2.5": { label: "MiMo-V2.5", limit: 30_100 },
75
+ "mimo-v2.5-pro": { label: "MiMo-V2.5 Pro", limit: 3_250 },
76
+ "minimax-m3": { label: "MiniMax M3", limit: 3_200 },
77
+ "minimax-m2.7": { label: "MiniMax M2.7", limit: 3_400 },
78
+ "qwen3.8-max": { label: "Qwen3.8 Max", limit: 160 },
79
+ "qwen3.7-max": { label: "Qwen3.7 Max", limit: 340 },
80
+ "qwen3.7-plus": { label: "Qwen3.7 Plus", limit: 4_300 },
81
+ "qwen3.6-plus": { label: "Qwen3.6 Plus", limit: 3_300 },
82
+ "deepseek-v4-pro": { label: "DeepSeek V4 Pro", limit: 3_450 },
83
+ "deepseek-v4-flash": { label: "DeepSeek V4 Flash", limit: 31_650 },
84
+ "hy3": { label: "Hy3", limit: 4_300 },
85
+ };
86
+
29
87
  export interface ProviderQuotaWindow {
30
88
  label: string;
31
89
  percent: number;
32
90
  resetAt?: number;
91
+ /** Text value shown instead of a percent bar (e.g. an estimated cost). */
92
+ valueLabel?: string;
33
93
  }
34
94
 
35
95
  export interface ProviderQuota {
@@ -81,7 +141,8 @@ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is Provide
81
141
  return typeof quota.fiveHourPercent === "number"
82
142
  || typeof quota.weeklyPercent === "number"
83
143
  || typeof quota.monthlyPercent === "number"
84
- || !!quota.customWindows?.some(window => typeof window.percent === "number");
144
+ || !!quota.customWindows?.some(window =>
145
+ typeof window.percent === "number" || typeof window.valueLabel === "string");
85
146
  }
86
147
 
87
148
  function providerLabel(providerId: string): string {
@@ -118,6 +179,13 @@ function normalizePercent(value: unknown): number | undefined {
118
179
  return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric));
119
180
  }
120
181
 
182
+ /** Numeric value from a billing object ({ val: 123 }) or a bare number. */
183
+ function billingValue(value: unknown): number | undefined {
184
+ if (typeof value === "number" && Number.isFinite(value)) return value;
185
+ const record = asRecord(value);
186
+ return toFiniteNumber(record?.val);
187
+ }
188
+
121
189
  function asRecord(value: unknown): Record<string, unknown> | null {
122
190
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
123
191
  }
@@ -177,14 +245,33 @@ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | nu
177
245
  const body = asRecord(await response.json().catch(() => null));
178
246
  const config = asRecord(body?.config);
179
247
  const currentPeriod = asRecord(config?.currentPeriod);
248
+ // A weekly window is the meter contract; other shapes (spend-cap etc.) fail closed.
180
249
  if (currentPeriod?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
181
- const creditUsagePercent = config?.creditUsagePercent;
182
- if (typeof creditUsagePercent !== "number" || !Number.isFinite(creditUsagePercent) || creditUsagePercent < 0 || creditUsagePercent > 100) return null;
183
250
  if (typeof currentPeriod.end !== "string" || !currentPeriod.end.trim()) return null;
184
251
  const resetAt = normalizeResetAt(currentPeriod.end);
185
252
  if (resetAt === undefined) return null;
253
+
254
+ let weeklyPercent: number | undefined;
255
+ if (typeof config?.creditUsagePercent === "number"
256
+ && Number.isFinite(config.creditUsagePercent)
257
+ && config.creditUsagePercent >= 0
258
+ && config.creditUsagePercent <= 100) {
259
+ weeklyPercent = config.creditUsagePercent;
260
+ } else {
261
+ // Unified-billing shape exposes on-demand usage vs cap as { val } objects.
262
+ const cap = billingValue(config?.onDemandCap);
263
+ const used = billingValue(config?.onDemandUsed);
264
+ if (cap !== undefined && cap > 0 && used !== undefined) {
265
+ weeklyPercent = normalizePercent((used / cap) * 100);
266
+ } else if (used === 0) {
267
+ // A zero-usage unified account still owns the weekly meter; report 0% so the
268
+ // provider stays in the usage section instead of being reclassified as no-quota.
269
+ weeklyPercent = 0;
270
+ }
271
+ }
272
+ if (weeklyPercent === undefined) return null;
186
273
  const quota: ProviderQuota = {
187
- weeklyPercent: creditUsagePercent,
274
+ weeklyPercent,
188
275
  weeklyResetAt: resetAt,
189
276
  updatedAt: Date.now(),
190
277
  };
@@ -226,7 +313,7 @@ async function fetchKiroUsageQuota(
226
313
 
227
314
  const response = await fetch(url, {
228
315
  headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
229
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
316
+ signal: AbortSignal.timeout(KIRO_QUOTA_TIMEOUT_MS),
230
317
  });
231
318
  if (!response.ok) return null;
232
319
  const body = asRecord(await response.json().catch(() => null));
@@ -702,6 +789,129 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
702
789
  return quota ? report(provider, "kimi:usages", quota) : null;
703
790
  }
704
791
 
792
+ function isCanonicalOpencodeGoBaseUrl(baseUrl: string | undefined): boolean {
793
+ return normalizedBaseUrl(baseUrl ?? "") === normalizedBaseUrl(OPENCODE_GO_BASE_URL);
794
+ }
795
+
796
+ interface OpencodeGoUsageEstimate {
797
+ costUsd: number;
798
+ priced: boolean;
799
+ perKeyCostUsd: Map<string, { costUsd: number; priced: boolean }>;
800
+ windowCounts: Map<string, number>;
801
+ }
802
+
803
+ /**
804
+ * Read the local usage log once and estimate opencode.go usage: 30-day cost from the
805
+ * published per-model prices (cache-aware), and 5-hour request counts per model.
806
+ *
807
+ * Attribution: the pool's ACTIVE key serves every provider-bound request (loopback
808
+ * traffic carries no client key id), so all rows are charged to it. Rows written before
809
+ * a key switch stay with the currently active key — an intentional approximation.
810
+ */
811
+ function estimateOpencodeGoUsage(name: string, config: OcxProviderConfig): OpencodeGoUsageEstimate | null {
812
+ if (!isCanonicalOpencodeGoBaseUrl(config.baseUrl)) return null;
813
+ const now = Date.now();
814
+ const fiveHourAgo = now - OPENCODE_GO_FIVE_HOUR_MS;
815
+ const monthAgo = now - OPENCODE_GO_COST_WINDOW_MS;
816
+ const pool = config.apiKeyPool ?? [];
817
+ const activeKey = resolveEnvValue(config.apiKey)?.trim() ?? config.apiKey;
818
+ const activeKeyId = activeKey ? pool.find(entry => entry.key === activeKey)?.id : undefined;
819
+
820
+ const estimate: OpencodeGoUsageEstimate = {
821
+ costUsd: 0,
822
+ priced: false,
823
+ perKeyCostUsd: new Map(pool.map(entry => [entry.id, { costUsd: 0, priced: false }])),
824
+ windowCounts: new Map(),
825
+ };
826
+ for (const entry of readUsageEntries()) {
827
+ if (entry.provider !== name || entry.status !== 200) continue;
828
+ const usage = entry.usage;
829
+ if (!usage || typeof usage.inputTokens !== "number") continue;
830
+ const timestamp = entry.timestamp ?? 0;
831
+ if (timestamp >= monthAgo) {
832
+ const price = OPENCODE_GO_PRICES[entry.model];
833
+ if (price) {
834
+ const cachedRead = usage.cachedInputTokens ?? 0;
835
+ const cachedWrite = usage.cacheCreationInputTokens ?? 0;
836
+ const uncached = Math.max(0, usage.inputTokens - cachedRead - cachedWrite);
837
+ const amount = (
838
+ uncached * price.input
839
+ + cachedRead * price.cacheRead
840
+ + cachedWrite * price.cacheWrite
841
+ + usage.outputTokens * price.output
842
+ ) / 1_000_000;
843
+ estimate.costUsd += amount;
844
+ estimate.priced = true;
845
+ if (activeKeyId) {
846
+ const bucket = estimate.perKeyCostUsd.get(activeKeyId) ?? { costUsd: 0, priced: false };
847
+ bucket.costUsd += amount;
848
+ bucket.priced = true;
849
+ estimate.perKeyCostUsd.set(activeKeyId, bucket);
850
+ }
851
+ }
852
+ }
853
+ if (timestamp >= fiveHourAgo && OPENCODE_GO_FIVE_HOUR_LIMITS[entry.model]) {
854
+ estimate.windowCounts.set(entry.model, (estimate.windowCounts.get(entry.model) ?? 0) + 1);
855
+ }
856
+ }
857
+ return estimate;
858
+ }
859
+
860
+ /**
861
+ * opencode.go has no per-key billing API (the console endpoint is session-authenticated),
862
+ * so the quota is estimated locally from the traffic we already proxy:
863
+ * - the 30-day estimated cost uses the published per-model prices with the cache split
864
+ * our own usage log records, which matches the console's per-request costs;
865
+ * - the 5-hour window rows use the published request limits (1x tier) as the quota.
866
+ */
867
+ function fetchOpencodeGoQuota(name: string, config: OcxProviderConfig): ProviderQuotaReport | null {
868
+ const estimate = estimateOpencodeGoUsage(name, config);
869
+ if (!estimate) return null;
870
+ const now = Date.now();
871
+
872
+ const customWindows: ProviderQuotaWindow[] = [{
873
+ label: "추산 비용 · 30일",
874
+ percent: 0,
875
+ valueLabel: `~$${estimate.costUsd.toFixed(2)}`,
876
+ }];
877
+ for (const [model, count] of [...estimate.windowCounts.entries()].sort((a, b) => b[1] - a[1])) {
878
+ const { label, limit } = OPENCODE_GO_FIVE_HOUR_LIMITS[model]!;
879
+ customWindows.push({
880
+ label: `5h · ${label}`,
881
+ percent: normalizePercent((count / limit) * 100) ?? 0,
882
+ resetAt: now + OPENCODE_GO_FIVE_HOUR_MS,
883
+ });
884
+ }
885
+ return report(name, "opencode-go:docs-estimate", {
886
+ customWindows,
887
+ updatedAt: now,
888
+ });
889
+ }
890
+
891
+ /**
892
+ * Per-key estimated-cost quota for every connected key of a canonical opencode.go
893
+ * provider. Each key gets a value row even at $0.00 so the notch pool shows all keys.
894
+ */
895
+ export function opencodeGoKeyQuotaEstimates(config: OcxConfig, name: string): Record<string, ProviderQuota> | null {
896
+ const provider = config.providers[name];
897
+ if (!provider) return null;
898
+ const estimate = estimateOpencodeGoUsage(name, provider);
899
+ if (!estimate) return null;
900
+ const now = Date.now();
901
+ const out: Record<string, ProviderQuota> = {};
902
+ for (const [keyId, bucket] of estimate.perKeyCostUsd) {
903
+ out[keyId] = {
904
+ customWindows: [{
905
+ label: "추산 비용 · 30일",
906
+ percent: 0,
907
+ valueLabel: `~$${bucket.costUsd.toFixed(2)}`,
908
+ }],
909
+ updatedAt: now,
910
+ };
911
+ }
912
+ return out;
913
+ }
914
+
705
915
  /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
706
916
  async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
707
917
  let accessToken: string;
@@ -982,6 +1192,11 @@ async function maybeFetchProviderQuota(
982
1192
  if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name, forceRefresh);
983
1193
  if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
984
1194
  if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
1195
+ // opencode.go is a key-auth subscription: estimate usage locally from the traffic log.
1196
+ if (provider.authMode !== "oauth" && provider.authMode !== "forward"
1197
+ && isCanonicalOpencodeGoBaseUrl(provider.baseUrl)) {
1198
+ return fetchOpencodeGoQuota(name, provider);
1199
+ }
985
1200
  // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
986
1201
  // host and only for real key auth — forward/local modes carry no credential of ours.
987
1202
  if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
@@ -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
- return jsonResponse(listProviderApiKeys(config, name));
420
+ const { opencodeGoKeyQuotaEstimates } = await import("../../providers/quota");
421
+ const result = listProviderApiKeys(config, name);
422
+ const keyQuotas = 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 };