@robhowley/pi-openrouter 0.9.0 → 0.10.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 +46 -5
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +529 -0
- package/extensions/openrouter/__tests__/index.test.ts +112 -363
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +288 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +158 -30
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/status-bar.ts +101 -0
- package/extensions/openrouter/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { setLocalUsageDir, dedupeLocalUsageEvents } from '../local-usage.js';
|
|
6
|
+
import {
|
|
7
|
+
calculateOpenRouterStatusStats,
|
|
8
|
+
formatOpenRouterStatusBar,
|
|
9
|
+
loadOpenRouterStatusBar,
|
|
10
|
+
loadOpenRouterStatusStats,
|
|
11
|
+
type OpenRouterStatusStats,
|
|
12
|
+
} from '../status-bar.js';
|
|
13
|
+
import type { LocalUsageEvent } from '../types.js';
|
|
14
|
+
|
|
15
|
+
let testDir: string;
|
|
16
|
+
|
|
17
|
+
function createLocalUsageEvent(
|
|
18
|
+
id: string,
|
|
19
|
+
completedAt: string,
|
|
20
|
+
cost: number,
|
|
21
|
+
overrides: Partial<LocalUsageEvent> = {},
|
|
22
|
+
): LocalUsageEvent {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
generationId: `${id}-generation`,
|
|
26
|
+
sessionId: 'session-test',
|
|
27
|
+
completedAt,
|
|
28
|
+
requests: 1,
|
|
29
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
promptTokens: 10,
|
|
32
|
+
completionTokens: 5,
|
|
33
|
+
cost,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeDailyFile(
|
|
39
|
+
dateUtc: string,
|
|
40
|
+
rows: Array<LocalUsageEvent | string>,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const content = rows
|
|
43
|
+
.map((row) => (typeof row === 'string' ? row : JSON.stringify(row)))
|
|
44
|
+
.join('\n');
|
|
45
|
+
await fs.writeFile(path.join(testDir, `${dateUtc}.jsonl`), `${content}\n`, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
testDir = path.join(
|
|
50
|
+
os.tmpdir(),
|
|
51
|
+
`pi-openrouter-status-bar-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
52
|
+
);
|
|
53
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
54
|
+
setLocalUsageDir(testDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
setLocalUsageDir(null);
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.doUnmock('../local-usage.js');
|
|
61
|
+
vi.doUnmock('../status-bar.js');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore cleanup errors.
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('calculateOpenRouterStatusStats', () => {
|
|
71
|
+
it('returns null for no local events', () => {
|
|
72
|
+
expect(calculateOpenRouterStatusStats([], '2026-05-22')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null when the full 30-day window totals zero local spend', () => {
|
|
76
|
+
expect(
|
|
77
|
+
calculateOpenRouterStatusStats(
|
|
78
|
+
[
|
|
79
|
+
createLocalUsageEvent('today-zero', '2026-05-22T09:15:00.000Z', 0),
|
|
80
|
+
createLocalUsageEvent('older-zero', '2026-05-12T09:15:00.000Z', 0),
|
|
81
|
+
],
|
|
82
|
+
'2026-05-22',
|
|
83
|
+
),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('includes only UTC-today spend and divides by exactly 30 calendar days', () => {
|
|
88
|
+
const stats = calculateOpenRouterStatusStats(
|
|
89
|
+
[
|
|
90
|
+
createLocalUsageEvent('today', '2026-05-22T09:15:00.000Z', 3),
|
|
91
|
+
createLocalUsageEvent('yesterday', '2026-05-21T09:15:00.000Z', 9),
|
|
92
|
+
],
|
|
93
|
+
'2026-05-22',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(stats).toEqual({
|
|
97
|
+
todayLocalSpend: 3,
|
|
98
|
+
averageLocalDailySpendLast30Days: 0.4,
|
|
99
|
+
burnRateMultiplier: 7.5,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses only the today-29 through today window', () => {
|
|
104
|
+
const stats = calculateOpenRouterStatusStats(
|
|
105
|
+
[
|
|
106
|
+
createLocalUsageEvent('old', '2026-04-22T12:00:00.000Z', 100),
|
|
107
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 6),
|
|
108
|
+
createLocalUsageEvent('recent', '2026-05-21T12:00:00.000Z', 3),
|
|
109
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 1.5),
|
|
110
|
+
],
|
|
111
|
+
'2026-05-22',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(stats).toEqual({
|
|
115
|
+
todayLocalSpend: 1.5,
|
|
116
|
+
averageLocalDailySpendLast30Days: 0.35,
|
|
117
|
+
burnRateMultiplier: 1.5 / 0.35,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('deduplicates event ids exactly once across the full 30-day window', () => {
|
|
122
|
+
const events = [
|
|
123
|
+
createLocalUsageEvent('duplicate-id', '2026-05-20T12:00:00.000Z', 1),
|
|
124
|
+
createLocalUsageEvent('duplicate-id', '2026-05-22T12:00:00.000Z', 99),
|
|
125
|
+
createLocalUsageEvent('unique-id', '2026-05-22T13:00:00.000Z', 2),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
expect(dedupeLocalUsageEvents(events)).toHaveLength(2);
|
|
129
|
+
expect(calculateOpenRouterStatusStats(events, '2026-05-22')).toEqual({
|
|
130
|
+
todayLocalSpend: 2,
|
|
131
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
132
|
+
burnRateMultiplier: 20,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('formatOpenRouterStatusBar', () => {
|
|
138
|
+
it('formats the representative status text exactly', () => {
|
|
139
|
+
const stats: OpenRouterStatusStats = {
|
|
140
|
+
todayLocalSpend: 2.14,
|
|
141
|
+
averageLocalDailySpendLast30Days: 1.64,
|
|
142
|
+
burnRateMultiplier: 1.3,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $2.14 today · 1.3x 30d avg');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('formats $0.00 today · 0.0x 30d avg when prior 30-day spend exists but today spend is zero', () => {
|
|
149
|
+
const stats: OpenRouterStatusStats = {
|
|
150
|
+
todayLocalSpend: 0,
|
|
151
|
+
averageLocalDailySpendLast30Days: 0.5,
|
|
152
|
+
burnRateMultiplier: 0,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $0.00 today · 0.0x 30d avg');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('omits the multiplier when given a stats object with no denominator', () => {
|
|
159
|
+
expect(
|
|
160
|
+
formatOpenRouterStatusBar({
|
|
161
|
+
todayLocalSpend: 2.14,
|
|
162
|
+
averageLocalDailySpendLast30Days: 0,
|
|
163
|
+
burnRateMultiplier: null,
|
|
164
|
+
}),
|
|
165
|
+
).toBe('OR $2.14 today');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('loadOpenRouterStatusStats and loadOpenRouterStatusBar', () => {
|
|
170
|
+
it('returns an empty result for empty local usage data', async () => {
|
|
171
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
172
|
+
|
|
173
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
174
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns an empty result when the 30-day window has only zero-cost local rows', async () => {
|
|
178
|
+
await writeDailyFile('2026-05-12', [
|
|
179
|
+
createLocalUsageEvent('older-zero', '2026-05-12T12:00:00.000Z', 0),
|
|
180
|
+
]);
|
|
181
|
+
await writeDailyFile('2026-05-22', [
|
|
182
|
+
createLocalUsageEvent('today-zero', '2026-05-22T12:00:00.000Z', 0),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
186
|
+
|
|
187
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
188
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('requests local usage only from today-29 through today', async () => {
|
|
192
|
+
vi.resetModules();
|
|
193
|
+
const actualLocalUsage =
|
|
194
|
+
await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
195
|
+
const readLocalUsage = vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockResolvedValue([
|
|
198
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 1),
|
|
199
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
vi.doMock('../local-usage.js', () => ({
|
|
203
|
+
...actualLocalUsage,
|
|
204
|
+
readLocalUsage,
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
const { loadOpenRouterStatusStats: loadMockedStats } = await import('../status-bar.js');
|
|
208
|
+
|
|
209
|
+
await expect(loadMockedStats(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
210
|
+
todayLocalSpend: 2,
|
|
211
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
212
|
+
burnRateMultiplier: 20,
|
|
213
|
+
});
|
|
214
|
+
expect(readLocalUsage).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(readLocalUsage).toHaveBeenCalledWith({
|
|
216
|
+
fromDateUtc: '2026-04-23',
|
|
217
|
+
toDateUtc: '2026-05-22',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('tolerates missing files and malformed rows while returning a ready status', async () => {
|
|
222
|
+
await writeDailyFile('2026-05-10', [
|
|
223
|
+
createLocalUsageEvent('older', '2026-05-10T12:00:00.000Z', 4),
|
|
224
|
+
'{not json}',
|
|
225
|
+
]);
|
|
226
|
+
await writeDailyFile('2026-05-22', [
|
|
227
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
228
|
+
'',
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
232
|
+
|
|
233
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toEqual({
|
|
234
|
+
todayLocalSpend: 2,
|
|
235
|
+
averageLocalDailySpendLast30Days: 0.2,
|
|
236
|
+
burnRateMultiplier: 10,
|
|
237
|
+
});
|
|
238
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({
|
|
239
|
+
kind: 'ready',
|
|
240
|
+
text: 'OR $2.00 today · 10.0x 30d avg',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns a failed result when the local usage read path throws unexpectedly', async () => {
|
|
245
|
+
vi.resetModules();
|
|
246
|
+
|
|
247
|
+
const readLocalUsage = vi.fn().mockRejectedValue(new Error('disk exploded'));
|
|
248
|
+
vi.doMock('../local-usage.js', async () => {
|
|
249
|
+
const actual = await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
250
|
+
return {
|
|
251
|
+
...actual,
|
|
252
|
+
readLocalUsage,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const { loadOpenRouterStatusBar: loadMockedBar } = await import('../status-bar.js');
|
|
257
|
+
|
|
258
|
+
await expect(loadMockedBar(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
259
|
+
kind: 'failed',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { KeyInfo, KeyStatus } from './account-types.js';
|
|
|
3
3
|
|
|
4
4
|
// Re-export error types from client.ts
|
|
5
5
|
import { AuthError, ApiError } from './client.js';
|
|
6
|
+
import { normalizeSdkKeyMetadata } from './normalizers.js';
|
|
6
7
|
|
|
7
8
|
let client: OpenRouter | null = null;
|
|
8
9
|
|
|
@@ -33,8 +34,6 @@ export async function getAccountCredits(): Promise<number | null> {
|
|
|
33
34
|
// Key Management API
|
|
34
35
|
// =============================================================================
|
|
35
36
|
|
|
36
|
-
import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
|
|
37
|
-
|
|
38
37
|
// Workspace ID for the default workspace (empty string) - used when workspaceId is not specified
|
|
39
38
|
const DEFAULT_WORKSPACE_ID = '';
|
|
40
39
|
|
|
@@ -61,7 +60,9 @@ export async function getAllKeys(): Promise<KeyInfo[] | null> {
|
|
|
61
60
|
const response = await client.apiKeys.list({ workspaceId, includeDisabled: true });
|
|
62
61
|
const rawKeys = response.data;
|
|
63
62
|
|
|
64
|
-
const keys = rawKeys.map((raw) =>
|
|
63
|
+
const keys = rawKeys.map((raw) =>
|
|
64
|
+
keyMetadataToKeyInfo(normalizeSdkKeyMetadata(raw), workspace.name),
|
|
65
|
+
);
|
|
65
66
|
allKeys.push(...keys);
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -81,7 +82,7 @@ export async function getCurrentKey(): Promise<KeyInfo | null> {
|
|
|
81
82
|
if (!client) return null;
|
|
82
83
|
try {
|
|
83
84
|
const response = await client.apiKeys.getCurrentKeyMetadata();
|
|
84
|
-
return
|
|
85
|
+
return keyMetadataToKeyInfo(normalizeSdkKeyMetadata(response.data), 'Current Workspace');
|
|
85
86
|
} catch (err) {
|
|
86
87
|
throw mapSdkError(err);
|
|
87
88
|
}
|
|
@@ -91,60 +92,11 @@ export async function getCurrentKey(): Promise<KeyInfo | null> {
|
|
|
91
92
|
// Helper Functions
|
|
92
93
|
// =============================================================================
|
|
93
94
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
// remaining is number | null in both types
|
|
101
|
-
const remainingValue = raw.limitRemaining;
|
|
102
|
-
|
|
103
|
-
// Determine BYOK status
|
|
104
|
-
let byok: 'incl' | 'excl' | '?' = '?';
|
|
105
|
-
if (raw.includeByokInLimit === true) {
|
|
106
|
-
byok = 'incl';
|
|
107
|
-
} else if (raw.includeByokInLimit === false) {
|
|
108
|
-
byok = 'excl';
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Determine reset cadence
|
|
112
|
-
let resetCadence: 'monthly' | 'daily' | 'never' | 'partial' = 'partial';
|
|
113
|
-
if (raw.limitReset) {
|
|
114
|
-
const reset = raw.limitReset.toLowerCase();
|
|
115
|
-
if (reset === 'monthly') {
|
|
116
|
-
resetCadence = 'monthly';
|
|
117
|
-
} else if (reset === 'daily') {
|
|
118
|
-
resetCadence = 'daily';
|
|
119
|
-
} else if (reset === 'never') {
|
|
120
|
-
resetCadence = 'never';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Determine hash (ListData has hash, GetCurrentKeyData doesn't)
|
|
125
|
-
const hash = 'hash' in raw ? (raw as ListData).hash : 'unknown';
|
|
126
|
-
|
|
127
|
-
// Determine name (ListData has name, GetCurrentKeyData doesn't - use label as fallback)
|
|
128
|
-
const name = 'name' in raw ? (raw as ListData).name : raw.label;
|
|
129
|
-
|
|
130
|
-
// Get disabled status (ListData has it, GetCurrentKeyData doesn't)
|
|
131
|
-
const disabled = 'disabled' in raw ? (raw as ListData).disabled : false;
|
|
132
|
-
|
|
133
|
-
// For exactOptionalPropertyTypes, we need to handle optional properties carefully
|
|
134
|
-
// The SDK returns number | null but KeyInfo expects number | undefined (or just number)
|
|
135
|
-
// We use type assertion to tell TypeScript that limit/remaining are either number or not set
|
|
136
|
-
|
|
137
|
-
// When limitValue is null, we set limit to undefined (or omit it)
|
|
138
|
-
// When limitValue is a number, we keep it as is
|
|
139
|
-
let limit: number | undefined;
|
|
140
|
-
if (limitValue !== null) {
|
|
141
|
-
limit = limitValue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
let remaining: number | undefined;
|
|
145
|
-
if (remainingValue !== null) {
|
|
146
|
-
remaining = remainingValue;
|
|
147
|
-
}
|
|
95
|
+
function keyMetadataToKeyInfo(
|
|
96
|
+
metadata: ReturnType<typeof normalizeSdkKeyMetadata>,
|
|
97
|
+
workspaceName: string,
|
|
98
|
+
): KeyInfo {
|
|
99
|
+
const { name, label, used, limit, remaining, resetCadence, byok, hash, disabled } = metadata;
|
|
148
100
|
|
|
149
101
|
// Calculate status based on usage percentage
|
|
150
102
|
let status: KeyStatus;
|
|
@@ -167,10 +119,9 @@ function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string):
|
|
|
167
119
|
}
|
|
168
120
|
}
|
|
169
121
|
|
|
170
|
-
// Create the object
|
|
171
122
|
const keyInfo: KeyInfo = {
|
|
172
123
|
name,
|
|
173
|
-
label
|
|
124
|
+
label,
|
|
174
125
|
status,
|
|
175
126
|
used,
|
|
176
127
|
spend: used, // spend is the same as usage (in USD)
|
|
@@ -181,7 +132,6 @@ function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string):
|
|
|
181
132
|
workspaceName,
|
|
182
133
|
};
|
|
183
134
|
|
|
184
|
-
// Set optional properties explicitly
|
|
185
135
|
if (limit !== undefined) {
|
|
186
136
|
keyInfo.limit = limit;
|
|
187
137
|
}
|