qwenproxy-cli 1.5.3 → 1.6.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.
@@ -512,6 +512,7 @@ const COOKIE_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
512
512
  const cookieCaches = new Map<string, { cookie: string; timestamp: number }>();
513
513
  const lastAccountActivity = new Map<string, number>();
514
514
  const lastKeepAliveNavigation = new Map<string, number>();
515
+ const lastProactiveTokenRefresh = new Map<string, number>();
515
516
  const profileResetQueue = new Map<string, Promise<void>>();
516
517
  let profileResetChain: Promise<void> = Promise.resolve();
517
518
  let closingAllPlaywright = false;
@@ -1228,6 +1229,11 @@ export async function getBasicHeaders(accountId: string): Promise<{
1228
1229
  throw new Error(`Playwright not initialized for account: ${accountId}`);
1229
1230
  }
1230
1231
 
1232
+ const inFlightInit = inFlightAccountInits.get(accountId);
1233
+ if (inFlightInit) {
1234
+ await inFlightInit.catch(() => {});
1235
+ }
1236
+
1231
1237
  // Acquire mutex to prevent concurrent browser access
1232
1238
  const release = await acquireAccountMutex(
1233
1239
  accountId,
@@ -3068,6 +3074,12 @@ export async function captureQwenHeaders(
3068
3074
  const { saveAuthSession } = await import("../core/database.ts");
3069
3075
  const { parseJwtExpiry } = await import("../utils/jwt.ts");
3070
3076
  const tokenExpiry = parseJwtExpiry(capturedHeaders.cookie);
3077
+ let rTok: string | undefined;
3078
+ try {
3079
+ rTok =
3080
+ (await page.evaluate(() => localStorage.getItem("refresh_token") || "")).trim() ||
3081
+ undefined;
3082
+ } catch {}
3071
3083
  saveAuthSession(accountId, {
3072
3084
  cookie: capturedHeaders.cookie,
3073
3085
  userAgent: capturedHeaders["user-agent"],
@@ -3079,6 +3091,7 @@ export async function captureQwenHeaders(
3079
3091
  secChUaPlatform: capturedHeaders["sec-ch-ua-platform"],
3080
3092
  version: capturedHeaders["version"],
3081
3093
  tokenExpiresAt: tokenExpiry || undefined,
3094
+ refreshToken: rTok,
3082
3095
  capturedAt: Date.now(),
3083
3096
  });
3084
3097
  } catch {}
@@ -3121,40 +3134,61 @@ export async function captureQwenHeaders(
3121
3134
  const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
3122
3135
  const isSuspectedGuest = isAuthUrl || (attempt >= 2 && !(await isPageLoggedIn(page, 5000)));
3123
3136
  if (isSuspectedGuest) {
3124
- const { getAccountCredentials } = await import("../core/accounts.ts");
3125
- const creds = getAccountCredentials(accountId);
3126
- if (creds && creds.email && creds.password) {
3127
- console.warn(
3128
- `⚠️ [Playwright] Session expired or guest state detected during header capture for ${accountId}; re-authenticating...`,
3137
+ // Try fast silent token refresh first using the 30-day refresh_token in-page (~200ms)
3138
+ const silentRefresh = await refreshAccountToken(accountId);
3139
+ if (silentRefresh.success) {
3140
+ console.log(
3141
+ `⚡ [Playwright] Token refreshed silently during header capture for ${accountId}`,
3129
3142
  );
3130
- armOverallDeadline();
3131
- const ok = await loginToQwen(accountId, creds.email, creds.password);
3132
- if (ok) {
3133
- // Re-login navigated; load the chat page and wait for hydration so
3134
- // the check below probes a live authenticated chat page.
3143
+ if (isAuthUrl) {
3135
3144
  await page.goto(qwenUrl("/"), {
3136
3145
  waitUntil: "domcontentloaded",
3137
3146
  timeout: Math.min(config.timeouts.navigation, timeoutMs),
3138
3147
  });
3139
- await sleep(2000);
3148
+ await sleep(1500);
3140
3149
  }
3141
- if (!ok || !(await isPageLoggedIn(page, 6000))) {
3150
+ if (await isPageLoggedIn(page, 5000)) {
3151
+ armOverallDeadline();
3152
+ }
3153
+ }
3154
+
3155
+ const stillSuspected = !silentRefresh.success || !(await isPageLoggedIn(page, 2000));
3156
+ if (stillSuspected) {
3157
+ const { getAccountCredentials } = await import("../core/accounts.ts");
3158
+ const creds = getAccountCredentials(accountId);
3159
+ if (creds && creds.email && creds.password) {
3160
+ console.warn(
3161
+ `⚠️ [Playwright] Session expired or guest state detected during header capture for ${accountId}; re-authenticating...`,
3162
+ );
3163
+ armOverallDeadline();
3164
+ const ok = await loginToQwen(accountId, creds.email, creds.password);
3165
+ if (ok) {
3166
+ // Re-login navigated; load the chat page and wait for hydration so
3167
+ // the check below probes a live authenticated chat page.
3168
+ await page.goto(qwenUrl("/"), {
3169
+ waitUntil: "domcontentloaded",
3170
+ timeout: Math.min(config.timeouts.navigation, timeoutMs),
3171
+ });
3172
+ await sleep(2000);
3173
+ }
3174
+ if (!ok || !(await isPageLoggedIn(page, 6000))) {
3175
+ settle(
3176
+ new Error(
3177
+ `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
3178
+ ),
3179
+ );
3180
+ return;
3181
+ }
3182
+ armOverallDeadline();
3183
+ if (settled || page.isClosed()) return;
3184
+ } else {
3142
3185
  settle(
3143
3186
  new Error(
3144
- `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
3187
+ `Header capture failed for ${accountId}: session expired and no credentials available for re-login (run 'qpx login')`,
3145
3188
  ),
3146
3189
  );
3147
3190
  return;
3148
3191
  }
3149
- armOverallDeadline();
3150
- if (settled || page.isClosed()) return;
3151
- } else {
3152
- settle(
3153
- new Error(
3154
- `Header capture failed for ${accountId}: session expired and no credentials available for re-login (run 'qpx login')`,
3155
- ),
3156
- );
3157
- return;
3158
3192
  }
3159
3193
  }
3160
3194
 
@@ -3593,7 +3627,15 @@ async function refreshHeadersInternal(
3593
3627
  `[Playwright] Navigation check failed during refresh for ${accountId}:`,
3594
3628
  (navErr as Error).message,
3595
3629
  );
3596
- await executeReauth();
3630
+ if (await isPageLoggedIn(page, 3000)) {
3631
+ // Still authenticated despite navigation timeout; do not escalate to slow form re-auth
3632
+ } else {
3633
+ // Try silent in-page token refresh before executing full form login
3634
+ const refreshed = await refreshAccountToken(accountId);
3635
+ if (!refreshed.success) {
3636
+ await executeReauth();
3637
+ }
3638
+ }
3597
3639
  }
3598
3640
  }
3599
3641
 
@@ -3610,6 +3652,12 @@ async function refreshHeadersInternal(
3610
3652
  const { saveAuthSession } = await import("../core/database.ts");
3611
3653
  const tokenCookie = liveCookies.find((c) => c.name === "token");
3612
3654
  const exp = tokenCookie ? parseJwtExpiry(tokenCookie.value) : undefined;
3655
+ let rTok: string | undefined;
3656
+ try {
3657
+ rTok =
3658
+ (await page.evaluate(() => localStorage.getItem("refresh_token") || "")).trim() ||
3659
+ undefined;
3660
+ } catch {}
3613
3661
  saveAuthSession(accountId, {
3614
3662
  cookie: cookieStr,
3615
3663
  userAgent: cache.headers["user-agent"] || "",
@@ -3621,6 +3669,7 @@ async function refreshHeadersInternal(
3621
3669
  secChUaPlatform: cache.headers["sec-ch-ua-platform"] || undefined,
3622
3670
  version: cache.headers["version"] || undefined,
3623
3671
  tokenExpiresAt: exp || undefined,
3672
+ refreshToken: rTok,
3624
3673
  capturedAt: Date.now(),
3625
3674
  });
3626
3675
  } catch {}
@@ -4169,8 +4218,10 @@ export async function keepAlivePlaywrightAccount(
4169
4218
  const currentUrl = page.url();
4170
4219
 
4171
4220
  // Proactive session check: if token expires within 5 minutes, refresh in-page silently using refreshAccountToken
4221
+ const lastRefreshAttempt = lastProactiveTokenRefresh.get(accountId) ?? 0;
4172
4222
  const cookie = await getCookies(accountId);
4173
- if (cookie && isTokenExpiringSoon(cookie, 5)) {
4223
+ if (cookie && isTokenExpiringSoon(cookie, 5) && now - lastRefreshAttempt > 60_000) {
4224
+ lastProactiveTokenRefresh.set(accountId, now);
4174
4225
  console.log(
4175
4226
  `💓 [SessionKeeper] Account ${accountId} access token expiring soon (<5m); silently refreshing...`,
4176
4227
  );
@@ -4233,6 +4284,7 @@ function cleanupPlaywrightAccountState(accountId: string): void {
4233
4284
  cookieCaches.delete(accountId);
4234
4285
  lastAccountActivity.delete(accountId);
4235
4286
  lastKeepAliveNavigation.delete(accountId);
4287
+ lastProactiveTokenRefresh.delete(accountId);
4236
4288
  clearFingerprintCache(accountId);
4237
4289
  // The account's context died/closed — its captured headers are stale or the
4238
4290
  // page is gone, so it must not be selected by the rotation gate until a
@@ -1540,6 +1540,12 @@ async function createQwenBrowserResponse(
1540
1540
  }
1541
1541
  }
1542
1542
 
1543
+ const inFlightPersonalizationSyncs = new Map<string, Promise<boolean>>();
1544
+
1545
+ export function getInFlightPersonalizationCount(): number {
1546
+ return inFlightPersonalizationSyncs.size;
1547
+ }
1548
+
1543
1549
  export async function syncQwenRequestPersonalization(
1544
1550
  instruction: string,
1545
1551
  accountId?: string,
@@ -1552,15 +1558,67 @@ export async function syncQwenRequestPersonalization(
1552
1558
  forceSync?: boolean;
1553
1559
  } = {},
1554
1560
  ): Promise<boolean> {
1555
- if (isAuthMockEnabled()) {
1556
- // Test hook: force the sync to report "not applied" so the fail-fast
1557
- // contract (personalization-required suite) is exercisable in mock mode.
1558
- if (process.env.TEST_PERSONALIZATION_SYNC_FAIL === "true") return false;
1559
- return true;
1561
+ const cacheKey = accountId || "global";
1562
+ const sent = textSize(instruction);
1563
+ const syncHash = sent.hash ? `${sent.hash}:${QWEN_SAFE_SETTINGS_HASH}` : null;
1564
+ const inFlightKey = `${cacheKey}:${syncHash || "empty"}`;
1565
+
1566
+ const existingInFlight = inFlightPersonalizationSyncs.get(inFlightKey);
1567
+ if (existingInFlight) {
1568
+ logger.debug("[Qwen] Personalization sync already in flight, sharing promise", {
1569
+ accountId: cacheKey,
1570
+ });
1571
+ return existingInFlight;
1560
1572
  }
1561
- // instruction pode ser vazia para limpar personalization
1562
1573
 
1563
- const cacheKey = accountId || "global";
1574
+ let syncPromise!: Promise<boolean>;
1575
+ syncPromise = (async () => {
1576
+ await Promise.resolve();
1577
+ try {
1578
+ if (isAuthMockEnabled()) {
1579
+ const delay = parseInt(process.env.TEST_PERSONALIZATION_DELAY_MS || "0", 10);
1580
+ if (delay > 0) {
1581
+ await sleep(delay);
1582
+ }
1583
+ // Test hook: force the sync to report "not applied" so the fail-fast
1584
+ // contract (personalization-required suite) is exercisable in mock mode.
1585
+ if (process.env.TEST_PERSONALIZATION_SYNC_FAIL === "true") return false;
1586
+ return true;
1587
+ }
1588
+ return await syncQwenRequestPersonalizationInternal(
1589
+ instruction,
1590
+ accountId,
1591
+ metadata,
1592
+ cacheKey,
1593
+ sent,
1594
+ syncHash,
1595
+ );
1596
+ } finally {
1597
+ if (inFlightPersonalizationSyncs.get(inFlightKey) === syncPromise) {
1598
+ inFlightPersonalizationSyncs.delete(inFlightKey);
1599
+ }
1600
+ }
1601
+ })();
1602
+
1603
+ inFlightPersonalizationSyncs.set(inFlightKey, syncPromise);
1604
+ return syncPromise;
1605
+ }
1606
+
1607
+ async function syncQwenRequestPersonalizationInternal(
1608
+ instruction: string,
1609
+ accountId: string | undefined,
1610
+ metadata: {
1611
+ model?: string;
1612
+ toolsCount?: number;
1613
+ sessionId?: string | null;
1614
+ promptChars?: number;
1615
+ forceSync?: boolean;
1616
+ },
1617
+ cacheKey: string,
1618
+ sent: ReturnType<typeof textSize>,
1619
+ syncHash: string | null,
1620
+ ): Promise<boolean> {
1621
+ // instruction pode ser vazia para limpar personalization
1564
1622
 
1565
1623
  // Proactive token renewal: refresh BEFORE attempting personalization
1566
1624
  // to avoid 401 errors that waste time on retry
@@ -1584,8 +1642,6 @@ export async function syncQwenRequestPersonalization(
1584
1642
  let currentSettings: any = null;
1585
1643
  let payload = buildQwenSettingsUpdatePayload(currentSettings, instruction);
1586
1644
 
1587
- const sent = textSize(instruction);
1588
- const syncHash = sent.hash ? `${sent.hash}:${QWEN_SAFE_SETTINGS_HASH}` : null;
1589
1645
  const bypassCache = metadata.forceSync === true;
1590
1646
 
1591
1647
  // 1. Check memory cache (skipped on forceSync)
@@ -15,6 +15,10 @@ import { isPlaywrightInitialized } from "../services/playwright.ts";
15
15
  import { getAccountConcurrencySnapshot } from "../core/account-concurrency.ts";
16
16
  import { getRssUsageSnapshot } from "../core/memory-usage.ts";
17
17
  import { metrics } from "../core/metrics.ts";
18
+ import {
19
+ getAllAccountsDailyUsage,
20
+ getPoolDailyUsageSummary,
21
+ } from "../core/daily-usage.ts";
18
22
  import type { ProxyStatusSnapshot } from "./types.ts";
19
23
 
20
24
  export function maskAccountIdentifier(idOrEmail: string): string {
@@ -153,13 +157,18 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
153
157
  }
154
158
  } catch {}
155
159
 
156
- // Attach concurrency to accounts
160
+ // Attach concurrency and daily usage to accounts
161
+ const dailyUsageMap = getAllAccountsDailyUsage();
157
162
  const accounts = cachedAccounts.map((acc) => {
158
163
  const concurrency = concurrencyMap.get(acc.id);
164
+ const daily = dailyUsageMap.get(acc.id);
159
165
  return {
160
166
  ...acc,
161
167
  activeStreams: concurrency?.active ?? 0,
162
168
  streamLimit: concurrency?.limit ?? config.concurrency.maxStreamsPerAccount,
169
+ dailyTokens: daily?.completionTokens ?? 0,
170
+ dailyUsagePercent: daily?.usagePercent ?? 0,
171
+ dailyTurns: daily?.turnsCount ?? 0,
163
172
  };
164
173
  });
165
174
  // RAM usage
@@ -220,6 +229,7 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
220
229
  cacheHitRatio,
221
230
  cacheBytesSaved,
222
231
  },
232
+ dailyUsage: getPoolDailyUsageSummary(cachedAccounts.map((a) => a.id)),
223
233
  accounts,
224
234
  };
225
235
  }
package/src/tui/types.ts CHANGED
@@ -38,6 +38,14 @@ export interface ProxyStatusSnapshot {
38
38
  cacheHitRatio?: number;
39
39
  cacheBytesSaved?: number;
40
40
  };
41
+ dailyUsage?: {
42
+ poolUsagePercent: number;
43
+ totalPromptTokens: number;
44
+ totalCompletionTokens: number;
45
+ totalTokens: number;
46
+ totalTurns: number;
47
+ poolCapacityTokens: number;
48
+ };
41
49
  accounts: Array<{
42
50
  id: string;
43
51
  emailOrName: string;
@@ -50,5 +58,8 @@ export interface ProxyStatusSnapshot {
50
58
  isInitialized?: boolean;
51
59
  activeStreams?: number;
52
60
  streamLimit?: number;
61
+ dailyTokens?: number;
62
+ dailyUsagePercent?: number;
63
+ dailyTurns?: number;
53
64
  }>;
54
65
  }
@@ -19,6 +19,7 @@ import {
19
19
  } from "../../core/accounts.ts";
20
20
  import { ServerManager } from "../server-manager.ts";
21
21
  import { config } from "../../core/config.ts";
22
+ import { renderProgressBar } from "./status-view.ts";
22
23
  export function formatCooldownReason(reason?: string | null, maxLen = 28): string {
23
24
  if (!reason) return theme.yellow("Cooldown ativo");
24
25
  if (
@@ -938,9 +939,11 @@ export class AccountsView implements TuiView {
938
939
  }
939
940
 
940
941
  // Left Panel: Accounts List Table
942
+ const showUsageCol = leftW >= 48;
943
+ const headerUsage = showUsageCol ? "Uso% " : "";
941
944
  const leftContent: string[] = [
942
945
  "",
943
- ` ${theme.dim("# Conta Status")}`,
946
+ ` ${theme.dim(`# Conta ${headerUsage}Status`)}`,
944
947
  ` ${theme.dim("───────────────────────────────────────")}`,
945
948
  ];
946
949
 
@@ -997,7 +1000,14 @@ export class AccountsView implements TuiView {
997
1000
  : theme.muted(`○ Standby `);
998
1001
  }
999
1002
 
1000
- const line = `${pointer}${num}${name}${status}`;
1003
+ const usagePct = acc.dailyUsagePercent ?? 0;
1004
+ const usagePctStr = showUsageCol
1005
+ ? (usagePct > 0
1006
+ ? (usagePct >= 80 ? theme.red(pad(`${usagePct}%`, 5)) : usagePct >= 40 ? theme.yellow(pad(`${usagePct}%`, 5)) : theme.cyan(pad(`${usagePct}%`, 5)))
1007
+ : theme.dim(pad("0%", 5))) + " "
1008
+ : "";
1009
+
1010
+ const line = `${pointer}${num}${name}${usagePctStr}${status}`;
1001
1011
  if (isHovered) {
1002
1012
  leftContent.push(theme.bgHover(line));
1003
1013
  } else if (isFocused) {
@@ -1046,6 +1056,13 @@ export class AccountsView implements TuiView {
1046
1056
  rightContent.push(` ${theme.bold("Sistema ID:")} ${theme.muted(selected.id.slice(0, 14))}`);
1047
1057
  rightContent.push(` ${theme.bold("Nível:")} ${selected.priority}`);
1048
1058
 
1059
+ const usagePct = selected.dailyUsagePercent ?? 0;
1060
+ const usageTokens = selected.dailyTokens ?? 0;
1061
+ const usageTurns = selected.dailyTurns ?? 0;
1062
+ const usageColor = usagePct >= 80 ? theme.red : usagePct >= 40 ? theme.yellow : theme.cyan;
1063
+ const usageBar = renderProgressBar(usagePct, 6, usageColor);
1064
+ rightContent.push(` ${theme.bold("Cota Hoje:")} [${usageBar}] ${usageColor(`${usagePct}%`)} ${theme.dim(`(~${Math.round(usageTokens / 1000)}k / ${usageTurns} reqs)`)}`);
1065
+
1049
1066
  const cdStatus = selected.onCooldown
1050
1067
  ? theme.yellow(`[!] Cooldown ${Math.round(selected.remainingCooldownMs / 60000)}m`)
1051
1068
  : theme.green(`${glyphs.check} Disponível`);
@@ -846,12 +846,29 @@ export class StatusView implements TuiView {
846
846
  const poolPct = accounts.length > 0 ? Math.round((availableCount / accounts.length) * 100) : 0;
847
847
  const poolColor = poolPct >= 70 ? theme.green : poolPct >= 40 ? theme.yellow : theme.red;
848
848
  const poolBar = renderProgressBar(poolPct, 8, poolColor);
849
+ const poolDailyUsage = data?.dailyUsage;
850
+ const poolUsagePct = poolDailyUsage?.poolUsagePercent ?? 0;
851
+ const poolUsageColor = poolUsagePct >= 80 ? theme.red : poolUsagePct >= 40 ? theme.yellow : theme.cyan;
852
+ const poolUsageBar = renderProgressBar(poolUsagePct, 8, poolUsageColor);
849
853
  const innerRightW = Math.max(30, rightW - 2);
850
- const emailWidth = Math.max(16, Math.min(20, innerRightW - 27));
854
+ const showUsageCol = innerRightW >= 52;
855
+ const emailWidth = showUsageCol
856
+ ? Math.max(14, Math.min(18, innerRightW - 33))
857
+ : Math.max(16, Math.min(20, innerRightW - 27));
858
+
851
859
  const rightContent: string[] = [
852
860
  ` ${theme.bold("Disponibilidade:")} [${poolBar}] ${poolColor(`${availableCount}/${accounts.length} (${poolPct}%)`)}`,
861
+ ...(poolDailyUsage
862
+ ? [
863
+ ` ${theme.bold("Cota Pool Hoje:")} [${poolUsageBar}] ${poolUsageColor(
864
+ `${poolUsagePct}%`,
865
+ )} ${theme.dim(
866
+ `(~${Math.round(poolDailyUsage.totalTokens / 1000)}k tok)`,
867
+ )}`,
868
+ ]
869
+ : []),
853
870
  ` ${theme.dim("─".repeat(Math.max(32, innerRightW - 2)))}`,
854
- ` ${theme.dim(`# ${pad("Conta", emailWidth)} Carga Status`)}`,
871
+ ` ${theme.dim(`# ${pad("Conta", emailWidth)} Carga ${showUsageCol ? "Uso% " : ""}Status`)}`,
855
872
  ` ${theme.dim("─".repeat(Math.max(32, innerRightW - 2)))}`,
856
873
  ];
857
874
 
@@ -864,7 +881,23 @@ export class StatusView implements TuiView {
864
881
  const name = pad(truncate(acc.emailOrName, emailWidth - 1), emailWidth);
865
882
  const active = acc.activeStreams || 0;
866
883
  const limit = acc.streamLimit || 1;
867
- const loadBadge = active > 0 ? theme.yellow(`[${active}/${limit}]`) : theme.dim(`[0/${limit}]`);
884
+ const usagePct = acc.dailyUsagePercent ?? 0;
885
+
886
+ let loadBadge: string;
887
+ if (active > 0) {
888
+ loadBadge = theme.yellow(`[${active}/${limit}]`);
889
+ } else if (!showUsageCol && usagePct > 0) {
890
+ const pctColor = usagePct >= 80 ? theme.red : usagePct >= 40 ? theme.yellow : theme.cyan;
891
+ loadBadge = pctColor(pad(`[${usagePct}%]`, 5));
892
+ } else {
893
+ loadBadge = theme.dim(`[0/${limit}]`);
894
+ }
895
+
896
+ const usageCol = showUsageCol
897
+ ? (usagePct > 0
898
+ ? (usagePct >= 80 ? theme.red(pad(`${usagePct}%`, 5)) : usagePct >= 40 ? theme.yellow(pad(`${usagePct}%`, 5)) : theme.cyan(pad(`${usagePct}%`, 5)))
899
+ : theme.dim(pad("0%", 5))) + " "
900
+ : "";
868
901
 
869
902
  let status = theme.green(`${glyphs.bullet} Pronto`);
870
903
  if (active > 0 && !acc.onCooldown && acc.headersReady) {
@@ -888,7 +921,7 @@ export class StatusView implements TuiView {
888
921
  ? theme.yellow(`◐ Aquecendo...`)
889
922
  : theme.muted(`○ Standby`);
890
923
  }
891
- rightContent.push(` ${num}${name} ${loadBadge} ${status}`);
924
+ rightContent.push(` ${num}${name} ${loadBadge} ${usageCol}${status}`);
892
925
  });
893
926
  }
894
927