usage-panel-opencode 0.0.0-stage → 0.1.0
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 +70 -2
- package/package.json +56 -4
- package/src/config.ts +62 -0
- package/src/core/account.ts +74 -0
- package/src/core/backoff.ts +32 -0
- package/src/core/claims.ts +49 -0
- package/src/core/redact.ts +151 -0
- package/src/core/snapshot.ts +276 -0
- package/src/core/state.ts +42 -0
- package/src/core/usage-client.ts +168 -0
- package/src/core/usage-headers.ts +32 -0
- package/src/core/windows.ts +102 -0
- package/src/index.ts +761 -0
- package/src/rpc.ts +313 -0
- package/src/tui.tsx +158 -0
- package/src/ui/detail.tsx +253 -0
- package/src/ui/format.ts +13 -0
- package/src/ui/sidebar.tsx +228 -0
- package/src/ui/theme.ts +20 -0
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# usage-panel-opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Plugin para OpenCode v2 que muestra el consumo de las ventanas de cuota de una suscripción ChatGPT. Requiere Bun ≥ 1.2 y OpenCode v2; una cuenta con suscripción conectada mediante `/connect` → OpenAI → **Sign in with ChatGPT**.
|
|
4
|
+
|
|
5
|
+
## Instalación y carga
|
|
6
|
+
|
|
7
|
+
Instala las dependencias (`bun install`). Para una instalación publicada, añade el paquete y sus opciones a `opencode.jsonc` del proyecto o a la configuración de usuario:
|
|
8
|
+
|
|
9
|
+
```jsonc
|
|
10
|
+
{
|
|
11
|
+
"$schema": "https://opencode.ai/config.json",
|
|
12
|
+
"plugins": [
|
|
13
|
+
{
|
|
14
|
+
"package": "usage-panel-opencode",
|
|
15
|
+
"options": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"intervalSeconds": 60,
|
|
18
|
+
"visible": true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Para desarrollo en este repositorio, usa `"plugins": ["./"]`. El paquete incluye piezas separadas de servidor y TUI.
|
|
26
|
+
|
|
27
|
+
## Preferencias
|
|
28
|
+
|
|
29
|
+
- `enabled` (`true`): desactiva consultas, eventos e indicador al establecerlo en `false`.
|
|
30
|
+
- `intervalSeconds` (`60`): intervalo de sondeo; los valores menores se normalizan a 60 segundos.
|
|
31
|
+
- `visible` (`true`): oculta el indicador sin desactivar las lecturas.
|
|
32
|
+
|
|
33
|
+
Las preferencias se guardan en el almacenamiento del plugin. El consumo no se persiste. El RPC `openai-usage` ofrece `snapshot`, `refresh`, `setPreferences`, `start` y `stop`; `start`, `stop`, `snapshot` y `refresh` reciben el identificador de la instancia TUI. El indicador abre el detalle; la paleta ofrece detalle y actualización, con comandos `/usage-detail` (alias `/openai-usage-detail`) y `/usage-refresh` (alias `/openai-usage-refresh`).
|
|
34
|
+
|
|
35
|
+
Las opciones del bloque `options` son valores iniciales; una preferencia guardada por `setPreferences` tiene precedencia y sobrevive al reinicio. No hay un comando de ajustes integrado en la paleta. Para cambiar preferencias en ejecución, un plugin TUI auxiliar puede invocar el RPC compartido:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { Usage } from 'usage-panel-opencode/rpc'
|
|
39
|
+
|
|
40
|
+
const usage = context.client.rpc(Usage)
|
|
41
|
+
const location = context.location ?? context.data.location.default()
|
|
42
|
+
const rpcOptions = { location }
|
|
43
|
+
|
|
44
|
+
// Cambiar intervalo y ocultar el indicador; las consultas siguen activas.
|
|
45
|
+
await usage.setPreferences({ intervalSeconds: 180, visible: false }, rpcOptions)
|
|
46
|
+
|
|
47
|
+
// Reactivarlo después, incluso si la preferencia enabled quedó en false.
|
|
48
|
+
await usage.setPreferences({ enabled: true, visible: true }, rpcOptions)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Después de la llamada, el servidor devuelve y emite el estado actualizado. Para verificar la instalación, reinicia OpenCode, confirma que `/usage-detail` aparece en la paleta, abre el detalle y revisa que las preferencias indicadas se reflejen en el estado RPC. Si se cambia la configuración estática, elimina o actualiza primero las preferencias guardadas desde el RPC; estas prevalecen deliberadamente sobre `options`.
|
|
52
|
+
|
|
53
|
+
## Privacidad y fuentes
|
|
54
|
+
|
|
55
|
+
El servidor consulta únicamente `https://chatgpt.com/backend-api/wham/usage` con el OAuth activo, o utiliza porcentajes de cabeceras `x-codex-*` de respuestas OpenAI solo cuando puede correlacionarlas con la misma petición y cuenta activa. Si no puede demostrar esa asociación, las descarta. No lee la cabecera Authorization, no registra credenciales y enmascara identificadores. No se recopilan conversaciones, créditos ni límites por modelo. No se hacen peticiones de sondeo cuando el plugin está desactivado.
|
|
56
|
+
|
|
57
|
+
El alcance de los porcentajes (entre clientes o personal frente a organizativo) no está verificado para cada fuente y cuenta; el detalle lo declara como desconocido. Las cabeceras no proporcionan hora de reinicio, por lo que se muestra «no disponible». El servicio puede informar resets, pero su formato y disponibilidad dependen del proveedor. La respuesta del endpoint es un contrato observado, no una API pública garantizada.
|
|
58
|
+
|
|
59
|
+
## Resolución de problemas
|
|
60
|
+
|
|
61
|
+
- **Sin datos / conexión de API key:** se muestran cuotas de suscripción solo para OAuth de ChatGPT; una API key no equivale a una suscripción.
|
|
62
|
+
- **Requiere autenticación:** vuelve a conectar OpenAI desde `/connect`.
|
|
63
|
+
- **Error temporal o límite 429:** se conserva la última lectura si existe y se aplica espera; los intentos respetan un mínimo de 60 segundos.
|
|
64
|
+
- **Datos parciales:** comprueba el origen en detalle. La fuente de cabeceras puede carecer de plan, alguna ventana o reset.
|
|
65
|
+
- **Desactivar:** establece `enabled: false` o quita el plugin de la configuración.
|
|
66
|
+
|
|
67
|
+
El slot público de la barra lateral no informa de sus dimensiones. Para evitar expandirlo o desplazar contenido, el indicador usa un presupuesto conservador de hasta 28 columnas y reserva espacio para el contenido existente. Se oculta si no cabe el bloque completo: el estado normal requiere al menos 12 filas y un fallo inicial con acción y reintento puede requerir hasta 18; el ancho mínimo efectivo es 78 columnas. La vista compacta solo muestra 5 h y semanal; otras ventanas siguen disponibles en el detalle.
|
|
68
|
+
|
|
69
|
+
## Desarrollo y dependencias
|
|
70
|
+
|
|
71
|
+
Ejecuta `bun test`, `bunx tsc --noEmit`, `bunx biome check .` y `bun audit`. `bunfig.toml` carga el transform Solid de OpenTUI tanto en ejecución como en pruebas; la suite de aceptación renderiza el componente real con el renderer de prueba de OpenTUI. Actualiza dependencias deliberadamente: revisa cambios mayores, ejecuta todas las verificaciones y confirma compatibilidad con OpenCode v2 antes de integrar.
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-panel-opencode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"import": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"./server": {
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"import": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./tui": {
|
|
23
|
+
"types": "./src/tui.tsx",
|
|
24
|
+
"import": "./src/tui.tsx"
|
|
25
|
+
},
|
|
26
|
+
"./rpc": {
|
|
27
|
+
"types": "./src/rpc.ts",
|
|
28
|
+
"import": "./src/rpc.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"lint": "biome check ."
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@opencode/plugin": "^2.2.5"
|
|
38
|
+
},
|
|
39
|
+
"overrides": {
|
|
40
|
+
"glob": "^14.0.2",
|
|
41
|
+
"minimatch": "^11.0.1"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@opencode/theme": "*",
|
|
45
|
+
"@opentui/core": ">=0.8.0",
|
|
46
|
+
"@opentui/solid": ">=0.8.0",
|
|
47
|
+
"solid-js": ">=1.9.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "^2.0.0",
|
|
51
|
+
"@opencode/theme": "*",
|
|
52
|
+
"@opentui/core": "^0.8.0",
|
|
53
|
+
"@opentui/solid": "^0.8.0",
|
|
54
|
+
"@types/bun": "^1.2.0",
|
|
55
|
+
"solid-js": "^1.9.0",
|
|
56
|
+
"typescript": "^5.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
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
|
+
}
|