usage-panel-opencode 0.0.0-stage → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "usage-panel-opencode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "OpenCode v2 plugin for viewing ChatGPT subscription usage.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "engines": {
8
+ "bun": ">=1.2.0"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "dist"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "import": "./src/index.ts"
18
+ },
19
+ "./server": {
20
+ "types": "./src/index.ts",
21
+ "import": "./src/index.ts"
22
+ },
23
+ "./tui": {
24
+ "types": "./src/tui.tsx",
25
+ "import": "./dist/tui.js"
26
+ },
27
+ "./rpc": {
28
+ "types": "./src/rpc.ts",
29
+ "import": "./src/rpc.ts"
30
+ }
31
+ },
32
+ "scripts": {
33
+ "build": "bun run scripts/build.ts",
34
+ "prepack": "bun run build",
35
+ "test": "bun test",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint": "biome check ."
38
+ },
39
+ "dependencies": {
40
+ "@opencode/plugin": "^2.2.5"
41
+ },
42
+ "overrides": {
43
+ "glob": "^14.0.2",
44
+ "minimatch": "^11.0.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@opencode/theme": "*",
48
+ "@opentui/core": ">=0.8.0",
49
+ "@opentui/solid": ">=0.8.0",
50
+ "solid-js": ">=1.9.0"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.0.0",
54
+ "@opencode/theme": "*",
55
+ "@opentui/core": "^0.8.0",
56
+ "@opentui/solid": "^0.8.0",
57
+ "@types/bun": "^1.2.0",
58
+ "solid-js": "^1.9.0",
59
+ "typescript": "^5.0.0"
60
+ }
61
+ }
package/src/config.ts ADDED
@@ -0,0 +1,62 @@
1
+ export interface PanelPreferences {
2
+ enabled: boolean;
3
+ intervalSeconds: number;
4
+ visible: boolean;
5
+ }
6
+
7
+ export const DEFAULT_PREFERENCES: Readonly<PanelPreferences> = Object.freeze({
8
+ enabled: true,
9
+ intervalSeconds: 60,
10
+ visible: true,
11
+ });
12
+
13
+ const PREFERENCE_KEYS = ['enabled', 'intervalSeconds', 'visible'] as const;
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+
19
+ export function normalizeIntervalSeconds(value: unknown): number {
20
+ if (typeof value !== 'number' || !Number.isInteger(value))
21
+ return DEFAULT_PREFERENCES.intervalSeconds;
22
+ return Math.max(60, value);
23
+ }
24
+
25
+ function readStaticOptions(options: unknown): PanelPreferences {
26
+ const values = isRecord(options) ? options : {};
27
+
28
+ return {
29
+ enabled: typeof values.enabled === 'boolean' ? values.enabled : DEFAULT_PREFERENCES.enabled,
30
+ intervalSeconds: normalizeIntervalSeconds(values.intervalSeconds),
31
+ visible: typeof values.visible === 'boolean' ? values.visible : DEFAULT_PREFERENCES.visible,
32
+ };
33
+ }
34
+
35
+ function isSavedPreferences(value: unknown): value is PanelPreferences {
36
+ if (!isRecord(value)) return false;
37
+ if (
38
+ Object.keys(value).some(
39
+ (key) => !PREFERENCE_KEYS.includes(key as (typeof PREFERENCE_KEYS)[number]),
40
+ )
41
+ ) {
42
+ return false;
43
+ }
44
+
45
+ return (
46
+ PREFERENCE_KEYS.every((key) => Object.hasOwn(value, key)) &&
47
+ typeof value.enabled === 'boolean' &&
48
+ typeof value.intervalSeconds === 'number' &&
49
+ Number.isInteger(value.intervalSeconds) &&
50
+ value.intervalSeconds >= 60 &&
51
+ typeof value.visible === 'boolean'
52
+ );
53
+ }
54
+
55
+ /** Resolves startup settings. Valid stored preferences override static options. */
56
+ export function resolvePreferences(options?: unknown, stored?: unknown): PanelPreferences {
57
+ if (stored === undefined) return readStaticOptions(options);
58
+ if (isSavedPreferences(stored)) return { ...stored };
59
+
60
+ // Corrupt persisted values are discarded as a whole, as specified by the storage contract.
61
+ return { ...DEFAULT_PREFERENCES };
62
+ }
@@ -0,0 +1,74 @@
1
+ import type { CredentialKind, SubscriptionAccount } from '../rpc';
2
+ import { readAccountClaims } from './claims';
3
+ import { maskAccountId, redactText, sanitizeText } from './redact';
4
+
5
+ export interface ResolvedAccount {
6
+ account: SubscriptionAccount;
7
+ /** Ephemeral OAuth access token used only by the request client. */
8
+ accessToken: string | null;
9
+ /** Raw identifier is kept out of the public account entity and used only for the request header. */
10
+ usageAccountId: string | null;
11
+ /** Non-secret key used to discard data when the active account changes. */
12
+ identity: string;
13
+ }
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+
19
+ function getString(value: unknown): string | null {
20
+ return typeof value === 'string' && value.length > 0 ? value : null;
21
+ }
22
+
23
+ function planOrganization(planHint: string | null): boolean | null {
24
+ if (!planHint) return null;
25
+ const plan = planHint.toLowerCase();
26
+ if (['business', 'team', 'enterprise', 'edu'].includes(plan)) return true;
27
+ if (['plus', 'pro', 'prolite', 'free'].includes(plan)) return false;
28
+ return null;
29
+ }
30
+
31
+ function credentialKind(credential: Record<string, unknown> | null): CredentialKind {
32
+ if (credential?.type === 'oauth') return 'oauth';
33
+ if (credential?.type === 'key') return 'key';
34
+ return 'none';
35
+ }
36
+
37
+ /** Resolves a connection and credential without putting secrets on the public account entity. */
38
+ export function resolveAccount(
39
+ connectionValue: unknown,
40
+ credentialValue: unknown,
41
+ ): ResolvedAccount {
42
+ const connection = isRecord(connectionValue) ? connectionValue : null;
43
+ const credential = isRecord(credentialValue) ? credentialValue : null;
44
+ const kind = credentialKind(credential);
45
+ const accessToken = kind === 'oauth' ? getString(credential?.access) : null;
46
+ const claims = readAccountClaims(accessToken);
47
+ const labelValue = getString(connection?.label) ?? getString(connection?.name);
48
+ const label = labelValue
49
+ ? sanitizeText(
50
+ redactText(labelValue, { secrets: [accessToken], accountIds: [claims.accountId] }),
51
+ 100,
52
+ )
53
+ : null;
54
+ const expiresValue = kind === 'oauth' ? credential?.expires : null;
55
+ const expiresAt =
56
+ typeof expiresValue === 'number' && Number.isFinite(expiresValue) ? expiresValue : null;
57
+ const connectionId = getString(connection?.id) ?? getString(connection?.connectionID);
58
+ const identity = `${kind}:${connectionId ?? 'connection'}:${claims.accountId ?? label ?? 'unknown'}`;
59
+
60
+ return {
61
+ account: {
62
+ credentialKind: kind,
63
+ ...(label ? { label } : {}),
64
+ ...(claims.accountId ? { accountId: maskAccountId(claims.accountId) } : {}),
65
+ ...(claims.planHint
66
+ ? { planHint: claims.planHint, organization: planOrganization(claims.planHint) }
67
+ : {}),
68
+ ...(kind === 'oauth' ? { expiresAt } : {}),
69
+ },
70
+ accessToken,
71
+ usageAccountId: claims.accountId,
72
+ identity,
73
+ };
74
+ }
@@ -0,0 +1,32 @@
1
+ import type { UsageClientErrorKind } from './usage-client';
2
+
3
+ const MIN_ATTEMPT_INTERVAL_MS = 60_000;
4
+ const FIRST_RETRY_DELAY_MS = 60_000;
5
+ const MAX_RETRY_DELAY_MS = 15 * 60_000;
6
+
7
+ export function canAttempt(lastAttemptAt: number | null | undefined, now: number): boolean {
8
+ return (
9
+ lastAttemptAt === null ||
10
+ lastAttemptAt === undefined ||
11
+ now - lastAttemptAt >= MIN_ATTEMPT_INTERVAL_MS
12
+ );
13
+ }
14
+
15
+ /** Returns a retry delay, or null when the failure must wait for a credential change. */
16
+ export function nextRetryDelay(
17
+ failureCount: number,
18
+ kind: UsageClientErrorKind,
19
+ retryAfterMs?: number,
20
+ ): number | null {
21
+ if (kind === 'unauthorized' || kind === 'forbidden') return null;
22
+ const attempt = Number.isFinite(failureCount) ? Math.max(0, Math.floor(failureCount)) : 0;
23
+ const exponential = Math.min(
24
+ MAX_RETRY_DELAY_MS,
25
+ FIRST_RETRY_DELAY_MS * 2 ** Math.min(attempt, 20),
26
+ );
27
+ const retryAfter =
28
+ typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs)
29
+ ? Math.max(0, retryAfterMs)
30
+ : 0;
31
+ return Math.max(MIN_ATTEMPT_INTERVAL_MS, exponential, retryAfter);
32
+ }
@@ -0,0 +1,49 @@
1
+ import { redactText } from './redact';
2
+
3
+ const ACCOUNT_ID_CLAIM = 'https://api.openai.com/auth.chatgpt_account_id';
4
+ const PLAN_TYPE_CLAIM = 'https://api.openai.com/auth.chatgpt_plan_type';
5
+ const AUTH_CLAIM = 'https://api.openai.com/auth';
6
+ const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;
7
+
8
+ export interface AccountClaims {
9
+ accountId: string | null;
10
+ planHint: string | null;
11
+ }
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ function decodePayload(token: unknown): Record<string, unknown> | null {
18
+ if (typeof token !== 'string') return null;
19
+ const parts = token.split('.');
20
+ if (parts.length < 2 || !parts[1]) return null;
21
+
22
+ try {
23
+ const normalized = parts[1].replaceAll('-', '+').replaceAll('_', '/');
24
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
25
+ const binary = atob(padded);
26
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
27
+ const payload: unknown = JSON.parse(new TextDecoder().decode(bytes));
28
+ return isRecord(payload) ? payload : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Reads optional display/routing hints from a JWT-shaped token; it does not authenticate the token. */
35
+ export function readAccountClaims(token: unknown): AccountClaims {
36
+ const payload = decodePayload(token);
37
+ if (!payload) return { accountId: null, planHint: null };
38
+
39
+ const nestedAuth = payload[AUTH_CLAIM];
40
+ const auth = isRecord(nestedAuth) ? nestedAuth : {};
41
+ const rawAccountId = payload[ACCOUNT_ID_CLAIM] ?? auth.chatgpt_account_id;
42
+ const rawPlanHint = payload[PLAN_TYPE_CLAIM] ?? auth.chatgpt_plan_type;
43
+ const accountId =
44
+ typeof rawAccountId === 'string' && ACCOUNT_ID_PATTERN.test(rawAccountId) ? rawAccountId : null;
45
+ const planText =
46
+ typeof rawPlanHint === 'string' ? redactText(rawPlanHint, { maxLength: 80 }) : '';
47
+
48
+ return { accountId, planHint: planText.length > 0 ? planText : null };
49
+ }
@@ -0,0 +1,151 @@
1
+ export interface RedactionOptions {
2
+ accountIds?: readonly (string | null | undefined)[];
3
+ secrets?: readonly (string | null | undefined)[];
4
+ maxLength?: number;
5
+ }
6
+
7
+ const DEFAULT_MAX_LENGTH = 240;
8
+ const TOKEN_ASSIGNMENT =
9
+ /\b((?:access|refresh|id)[_-]?token|api[_-]?key|token)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi;
10
+ const AUTHORIZATION_HEADER =
11
+ /\b(authorization\s*[:=]\s*)(?:bearer\s+)?(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi;
12
+ const ACCOUNT_ID_FIELD =
13
+ /(\b(?:chatgpt[-_]?account[-_]?id|account[-_]?id)\s*[:=]\s*["']?)([A-Za-z0-9_-]{4,64})/gi;
14
+
15
+ function safeString(value: unknown): string {
16
+ return typeof value === 'string' ? value : '';
17
+ }
18
+
19
+ function validLimit(value: number | undefined): number {
20
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
21
+ ? value
22
+ : DEFAULT_MAX_LENGTH;
23
+ }
24
+
25
+ function stripTerminalSequences(text: string): string {
26
+ let result = '';
27
+
28
+ for (let index = 0; index < text.length; index += 1) {
29
+ if (text.charCodeAt(index) !== 27) {
30
+ result += text[index];
31
+ continue;
32
+ }
33
+
34
+ const next = text.charCodeAt(index + 1);
35
+ if (next === 91) {
36
+ index += 2;
37
+ while (
38
+ index < text.length &&
39
+ !(text.charCodeAt(index) >= 64 && text.charCodeAt(index) <= 126)
40
+ ) {
41
+ index += 1;
42
+ }
43
+ } else if (next === 93) {
44
+ index += 2;
45
+ while (index < text.length) {
46
+ const code = text.charCodeAt(index);
47
+ if (code === 7) break;
48
+ if (code === 27 && text.charCodeAt(index + 1) === 92) {
49
+ index += 1;
50
+ break;
51
+ }
52
+ index += 1;
53
+ }
54
+ } else {
55
+ index += 1;
56
+ }
57
+ }
58
+
59
+ return result;
60
+ }
61
+
62
+ function stripUnsafeCharacters(text: string): string {
63
+ return Array.from(text, (character) => {
64
+ const codePoint = character.codePointAt(0) ?? 0;
65
+ if (codePoint === 9) return ' ';
66
+ if (
67
+ codePoint <= 31 ||
68
+ (codePoint >= 127 && codePoint <= 159) ||
69
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
70
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
71
+ ) {
72
+ return '';
73
+ }
74
+ return character;
75
+ }).join('');
76
+ }
77
+
78
+ function replaceExactValues(text: string, values: RedactionOptions['secrets']): string {
79
+ let result = text;
80
+ for (const value of values ?? []) {
81
+ if (typeof value === 'string' && value.length > 0)
82
+ result = result.split(value).join('[redacted]');
83
+ }
84
+ return result;
85
+ }
86
+
87
+ function maskIdentifier(value: string): string {
88
+ return maskAccountId(value) ?? '[redacted]';
89
+ }
90
+
91
+ /** Returns a display-safe account suffix. Very short identifiers are omitted rather than exposed. */
92
+ export function maskAccountId(value: unknown): string | null {
93
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{4,64}$/.test(value) || value.length <= 4) {
94
+ return null;
95
+ }
96
+ return `…${value.slice(-4)}`;
97
+ }
98
+
99
+ /** Removes terminal control sequences, line breaks, and bidirectional control characters. */
100
+ export function sanitizeText(value: unknown, maxLength = DEFAULT_MAX_LENGTH): string {
101
+ const limit = validLimit(maxLength);
102
+ const text = stripUnsafeCharacters(
103
+ stripTerminalSequences(safeString(value)).replace(/\r\n?|\n/g, ' '),
104
+ )
105
+ .replace(/\s{2,}/g, ' ')
106
+ .trim();
107
+
108
+ if (text.length <= limit) return text;
109
+ return `${text.slice(0, limit - 1)}…`;
110
+ }
111
+
112
+ /** Redacts known secrets and common credential formats, then returns safe single-line text. */
113
+ export function redactText(value: unknown, options: RedactionOptions = {}): string {
114
+ let text = safeString(value);
115
+
116
+ text = replaceExactValues(text, options.secrets);
117
+ for (const accountId of options.accountIds ?? []) {
118
+ if (typeof accountId === 'string' && accountId.length > 0) {
119
+ text = text.split(accountId).join(maskIdentifier(accountId));
120
+ }
121
+ }
122
+
123
+ text = text
124
+ .replace(AUTHORIZATION_HEADER, '$1[redacted]')
125
+ .replace(/\bbearer\s+[^\s,;}\]]+/gi, 'Bearer [redacted]')
126
+ .replace(TOKEN_ASSIGNMENT, '$1=[redacted]')
127
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}/gi, '[redacted]')
128
+ .replace(/\beyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]+){1,2}\b/g, '[redacted]')
129
+ .replace(ACCOUNT_ID_FIELD, (_match, prefix: string, accountId: string) => {
130
+ return `${prefix}${maskIdentifier(accountId)}`;
131
+ });
132
+
133
+ return sanitizeText(text, options.maxLength);
134
+ }
135
+
136
+ /** Extracts only an error message; stack traces and attached response bodies are never returned. */
137
+ export function redactError(error: unknown, options: RedactionOptions = {}): string {
138
+ let message: string;
139
+ if (error instanceof Error) {
140
+ message = error.message;
141
+ } else if (typeof error === 'string') {
142
+ message = error;
143
+ } else if (typeof error === 'object' && error !== null && 'message' in error) {
144
+ const candidate = error.message;
145
+ message = typeof candidate === 'string' ? candidate : 'Unknown error';
146
+ } else {
147
+ message = 'Unknown error';
148
+ }
149
+
150
+ return redactText(message, options);
151
+ }