-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathusage.js
More file actions
123 lines (117 loc) · 4.09 KB
/
Copy pathusage.js
File metadata and controls
123 lines (117 loc) · 4.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
/**
* Usage gateway helpers for the official OpenCode Go usage endpoint:
*
* GET <baseUrl> (default https://opencode.ai/zen/go/v1/usage)
* Authorization: Bearer <apiKey>
*
* Response (undocumented, community-verified):
* { "usage": { "rolling": {status, percent, resetsAt},
* "weekly": {status, percent, resetsAt},
* "monthly": {status, percent, resetsAt} } }
*
* Parsing is defensive: the endpoint is not part of OpenCode's public docs,
* so any shape drift degrades to a coded error the card renders, never a crash.
*
* @module dsh-opencode-go-pool/usage
*/
/** Coded failure for one usage query; `code` is a stable machine key. */
export class UsageError extends Error {
constructor(code, message) {
super(message)
this.code = code
}
}
function pickWindow(value) {
if (!value || typeof value !== 'object') return null
const percent = typeof value.percent === 'number' ? value.percent : Number(value.percent)
return {
status: typeof value.status === 'string' ? value.status : null,
percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null,
resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : null,
}
}
/**
* Query the usage endpoint once for one key.
* @param {object} options
* @param {string} options.baseUrl
* @param {string} options.apiKey
* @param {number} [options.timeoutMs]
* @param {Function} [options.fetchImpl] - injectable fetch for tests.
* @returns {Promise<{rolling: object|null, weekly: object|null, monthly: object|null}>}
* @throws {UsageError} with codes: network | unauthorized | http-<status> | bad-json
*/
export async function fetchUsage({ baseUrl, apiKey, timeoutMs = 15000, fetchImpl }) {
const impl = fetchImpl ?? globalThis.fetch
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
let res
try {
res = await impl(baseUrl, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
signal: controller.signal,
})
} catch {
throw new UsageError('network', `usage request failed: ${baseUrl}`)
} finally {
clearTimeout(timer)
}
if (res.status === 401) {
throw new UsageError('unauthorized', 'usage endpoint rejected the key (401)')
}
if (!res.ok) {
throw new UsageError(`http-${res.status}`, `usage endpoint answered HTTP ${res.status}`)
}
let body
try {
body = await res.json()
} catch {
throw new UsageError('bad-json', 'usage endpoint answered with non-JSON')
}
const usage = body && typeof body === 'object' && body.usage ? body.usage : body
return {
rolling: pickWindow(usage && usage.rolling),
weekly: pickWindow(usage && usage.weekly),
monthly: pickWindow(usage && usage.monthly),
}
}
/**
* Small TTL cache with in-flight dedupe: the client card polls every 30s,
* several keys query in parallel, and concurrent pollers share one request
* per key instead of stampeding the endpoint.
*/
export class UsageCache {
/**
* @param {object} [options]
* @param {number} [options.ttlMs] - freshness window for successful values (default 15000).
* @param {() => number} [options.now] - clock injection for tests.
*/
constructor({ ttlMs = 15000, now = () => Date.now() } = {}) {
this.ttlMs = ttlMs
this.now = now
this.entries = new Map()
this.inflight = new Map()
}
/**
* @param {string} key - cache key (pool key id).
* @param {() => Promise<any>} fetcher - runs only on a miss; failures are never cached.
* @returns {Promise<any>} fresh or cached value.
*/
async get(key, fetcher) {
const hit = this.entries.get(key)
if (hit && this.now() - hit.at < this.ttlMs) return hit.value
const inflight = this.inflight.get(key)
if (inflight) return inflight
const promise = Promise.resolve()
.then(fetcher)
.then(value => {
this.entries.set(key, { value, at: this.now() })
return value
})
.finally(() => { this.inflight.delete(key) })
this.inflight.set(key, promise)
return promise
}
invalidate(key) {
this.entries.delete(key)
}
}