dsh-connect-minimaxcode 0.2.3 → 0.4.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/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createProvider } from "@earendil-works/pi-ai";
6
6
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
7
+ import { LlmAdapter, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
7
8
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
8
- import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
9
  //#region src/auth.ts
10
10
  /**
11
11
  * Reading the MiniMax Code desktop app's own sign-in.
@@ -494,6 +494,65 @@ function toPiModel(model, baseUrl, authorization) {
494
494
  };
495
495
  }
496
496
  /**
497
+ * An adapter that reports what each request used.
498
+ *
499
+ * The usage counters ride on the stream the gateway already sends, so
500
+ * measuring costs nothing extra: the chunk is copied out of the stream and
501
+ * handed to the callback, and the chunk itself still reaches the host
502
+ * unchanged. Both entry points are covered — the plain stream and the one a
503
+ * prepared call dispatches through — because the host may use either.
504
+ */
505
+ var MeteredAdapter = class extends LlmAdapter {
506
+ #inner;
507
+ #onUsage;
508
+ constructor(inner, onUsage) {
509
+ super();
510
+ this.#inner = inner;
511
+ this.#onUsage = onUsage;
512
+ }
513
+ providerInfo(provider) {
514
+ return this.#inner.providerInfo(provider);
515
+ }
516
+ providerRetryPolicy(provider) {
517
+ return this.#inner.providerRetryPolicy(provider);
518
+ }
519
+ listModels(provider) {
520
+ return this.#inner.listModels(provider);
521
+ }
522
+ resolveModel(provider, model, signal) {
523
+ return this.#inner.resolveModel(provider, model, signal);
524
+ }
525
+ async prepareCall(provider, model, signal) {
526
+ const prepared = await this.#inner.prepareCall(provider, model, signal);
527
+ return {
528
+ ...prepared,
529
+ stream: (options) => this.#metered(prepared.stream(options), model)
530
+ };
531
+ }
532
+ stream(options) {
533
+ return this.#metered(this.#inner.stream(options), options.model);
534
+ }
535
+ /** Copy the usage out of a stream, leaving the stream itself untouched. */
536
+ #metered(chunks, model) {
537
+ const onUsage = this.#onUsage;
538
+ return { async *[Symbol.asyncIterator]() {
539
+ for await (const chunk of chunks) {
540
+ if (chunk.type === "usage") {
541
+ const usage = chunk.usage;
542
+ onUsage(model, {
543
+ inputTokens: usage.inputTokens,
544
+ outputTokens: usage.outputTokens,
545
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
546
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
547
+ reasoningTokens: usage.reasoningTokens ?? 0
548
+ });
549
+ }
550
+ yield chunk;
551
+ }
552
+ } };
553
+ }
554
+ };
555
+ /**
497
556
  * Build the DSH adapter that serves the catalog.
498
557
  *
499
558
  * The model list is rebuilt on demand rather than snapshotted, which is what
@@ -550,12 +609,13 @@ function createMinimaxCodeAdapter(options) {
550
609
  return /* @__PURE__ */ new Map([[MINIMAXCODE_PROVIDER_ID, route]]);
551
610
  };
552
611
  let profiles = buildProfiles();
612
+ const inner = new PiAiAdapter({
613
+ profiles: () => profiles,
614
+ resolveApiKey: options.resolveApiKey,
615
+ auth: INERT_AUTH
616
+ });
553
617
  return {
554
- adapter: new PiAiAdapter({
555
- profiles: () => profiles,
556
- resolveApiKey: options.resolveApiKey,
557
- auth: INERT_AUTH
558
- }),
618
+ adapter: options.onUsage === void 0 ? inner : new MeteredAdapter(inner, options.onUsage),
559
619
  invalidate: () => {
560
620
  profiles = buildProfiles();
561
621
  }
@@ -586,7 +646,9 @@ function statusDocument(deps) {
586
646
  catalogSource: deps.catalogSource,
587
647
  ...deps.catalogError === void 0 ? {} : { catalogError: deps.catalogError },
588
648
  models: deps.models,
589
- refreshing: deps.refreshing
649
+ refreshing: deps.refreshing,
650
+ ...deps.credits === void 0 ? {} : { credits: deps.credits },
651
+ ...deps.usage === void 0 ? {} : { usage: deps.usage }
590
652
  };
591
653
  if (auth.state === "expired") return {
592
654
  ...base,
@@ -611,6 +673,576 @@ function isLoopback(host) {
611
673
  return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
612
674
  }
613
675
  //#endregion
676
+ //#region src/credits.ts
677
+ /**
678
+ * The MiniMax Code account's credit balance.
679
+ *
680
+ * Two credentials exist on this machine and they are not interchangeable:
681
+ *
682
+ * - `local-runtime.auth.json` holds a JWT the model gateway accepts. It cannot
683
+ * read account data: every credit endpoint answers `401`.
684
+ * - `auth/prod/<region>/mcode-public/auth.json` holds the desktop app's OAuth
685
+ * token (`mmoat_`). That is the credential the account endpoints accept.
686
+ *
687
+ * The balance itself comes from the membership endpoint the desktop app calls
688
+ * itself: `POST /matrix/api/v1/commerce/get_membership_info`, whose response
689
+ * carries `op_credit_summary` — the same three numbers the app's own settings
690
+ * page renders ("remaining credits", free vs purchased).
691
+ *
692
+ * Nothing here performs a login. The plugin may refresh an *expired* token
693
+ * with the refresh token the app already stored, and writes the rotated pair
694
+ * back in the app's own format so the app keeps working; it never starts a
695
+ * sign-in, never revokes, and never touches the app's accounts.
696
+ *
697
+ * @module dsh-connect-minimaxcode/credits
698
+ */
699
+ /** The desktop app's own OAuth client id. */
700
+ const MINIMAX_OAUTH_CLIENT_ID = "mcode-public";
701
+ /** The scope the app requests. */
702
+ const MINIMAX_OAUTH_SCOPES = ["agent.default"];
703
+ /** The audience the app's tokens are issued for. */
704
+ const MINIMAX_OAUTH_AUDIENCE = "agent-backend";
705
+ /** Authorization server per region. */
706
+ const MINIMAX_AUTH_HOSTS = {
707
+ cn: "https://account.minimax.cn",
708
+ io: "https://account.minimax.io",
709
+ com: "https://account.minimaxi.com"
710
+ };
711
+ /** Gateway serving the account endpoints per region. */
712
+ const MINIMAX_ACCOUNT_HOSTS = {
713
+ cn: "https://agent.minimax.cn",
714
+ io: "https://agent.minimax.io",
715
+ com: "https://agent.minimaxi.com"
716
+ };
717
+ /** Membership endpoint: carries the credit summary. */
718
+ const MEMBERSHIP_PATH = "/matrix/api/v1/commerce/get_membership_info";
719
+ /** Credit ledger endpoint: per-grant amounts and expiry. */
720
+ const CREDIT_DETAILS_PATH = "/minimax-cloud/api/v1/credit/details";
721
+ /** OAuth token endpoint. */
722
+ const TOKEN_PATH = "/oauth2/token";
723
+ /** Absolute path of the app's credential file for a region. */
724
+ function oauthAuthPath(region, env = process.env) {
725
+ return join(dataDir(env), "auth", "prod", region, "mcode-public", "auth.json");
726
+ }
727
+ /** Milliseconds until the credential expires; negative once it has. */
728
+ function expiresInMs$1(credential, now = Date.now()) {
729
+ return credential.expiresAtMs === void 0 ? void 0 : credential.expiresAtMs - now;
730
+ }
731
+ /** Whether the credential is close enough to expiry to be worth refreshing. */
732
+ function needsRefresh(credential, now = Date.now()) {
733
+ const remaining = expiresInMs$1(credential, now);
734
+ return remaining !== void 0 && remaining <= 12e4;
735
+ }
736
+ /**
737
+ * Parse the app's credential document.
738
+ *
739
+ * The record key is generated per install, so the record is found by value
740
+ * rather than by a hard-coded name.
741
+ */
742
+ function parseOAuthDocument(text, now = Date.now()) {
743
+ let parsed;
744
+ try {
745
+ parsed = JSON.parse(text);
746
+ } catch {
747
+ return {
748
+ state: "signed-out",
749
+ reason: "unreadable"
750
+ };
751
+ }
752
+ const records = parsed.records;
753
+ if (typeof records !== "object" || records === null) return {
754
+ state: "signed-out",
755
+ reason: "not-signed-in"
756
+ };
757
+ const record = Object.values(records)[0];
758
+ if (typeof record !== "object" || record === null) return {
759
+ state: "signed-out",
760
+ reason: "not-signed-in"
761
+ };
762
+ const entry = record;
763
+ const token = entry.accessToken;
764
+ if (typeof token !== "string" || token.length === 0) return {
765
+ state: "signed-out",
766
+ reason: "not-signed-in"
767
+ };
768
+ const expiresAtMs = typeof entry.expiresAtMs === "number" ? entry.expiresAtMs : void 0;
769
+ const credential = {
770
+ token,
771
+ refreshToken: typeof entry.refreshToken === "string" ? entry.refreshToken : void 0,
772
+ expiresAtMs,
773
+ clientId: typeof entry.clientId === "string" ? entry.clientId : MINIMAX_OAUTH_CLIENT_ID,
774
+ scopes: Array.isArray(entry.scopes) ? entry.scopes.filter((s) => typeof s === "string") : [...MINIMAX_OAUTH_SCOPES],
775
+ audience: typeof entry.audience === "string" ? entry.audience : MINIMAX_OAUTH_AUDIENCE,
776
+ generation: typeof entry.generation === "number" ? entry.generation : void 0
777
+ };
778
+ return expiresAtMs !== void 0 && expiresAtMs <= now ? {
779
+ state: "expired",
780
+ credential
781
+ } : {
782
+ state: "signed-in",
783
+ credential
784
+ };
785
+ }
786
+ /**
787
+ * Read the desktop app's OAuth credential.
788
+ *
789
+ * Every failure is a state, not an exception: an app that is not installed, or
790
+ * not signed in, is a normal condition the UI renders rather than an error the
791
+ * host logs on every request.
792
+ */
793
+ async function readOAuthCredential(region, env = process.env, now = Date.now()) {
794
+ let text;
795
+ try {
796
+ text = await readFile(oauthAuthPath(region, env), "utf8");
797
+ } catch {
798
+ return {
799
+ state: "signed-out",
800
+ reason: "app-missing"
801
+ };
802
+ }
803
+ return parseOAuthDocument(text, now);
804
+ }
805
+ /**
806
+ * Exchange the stored refresh token for a fresh access token.
807
+ *
808
+ * The authorization server rotates the refresh token, so the new pair is
809
+ * written back into the app's own file, in the app's own format, with an
810
+ * atomic replace. `generation` is left untouched on purpose: the app uses it
811
+ * to decide whether a stored credential still belongs to its own login, and
812
+ * raising it would make the app believe it had been signed in elsewhere.
813
+ */
814
+ async function refreshOAuthCredential(region, credential, env = process.env, now = Date.now(), fetchImpl = fetch) {
815
+ if (credential.refreshToken === void 0) return {
816
+ state: "expired",
817
+ credential
818
+ };
819
+ const body = new URLSearchParams({
820
+ grant_type: "refresh_token",
821
+ refresh_token: credential.refreshToken,
822
+ client_id: credential.clientId,
823
+ scope: credential.scopes.join(" "),
824
+ audience: credential.audience
825
+ });
826
+ const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH}`, {
827
+ method: "POST",
828
+ headers: {
829
+ accept: "application/json",
830
+ "content-type": "application/x-www-form-urlencoded"
831
+ },
832
+ body
833
+ });
834
+ if (!response.ok) throw new Error(`OAuth refresh failed: HTTP ${response.status}`);
835
+ const document = await response.json();
836
+ const token = document.access_token;
837
+ if (typeof token !== "string" || token.length === 0) throw new Error("OAuth refresh returned no access token");
838
+ const expiresInSec = typeof document.expires_in === "number" ? document.expires_in : void 0;
839
+ const next = {
840
+ ...credential,
841
+ token,
842
+ refreshToken: typeof document.refresh_token === "string" ? document.refresh_token : credential.refreshToken,
843
+ expiresAtMs: expiresInSec === void 0 ? void 0 : now + expiresInSec * 1e3
844
+ };
845
+ await writeOAuthCredential(region, next, env);
846
+ return {
847
+ state: "signed-in",
848
+ credential: next
849
+ };
850
+ }
851
+ /**
852
+ * Write a rotated credential back into the app's store.
853
+ *
854
+ * The document is rewritten as a whole so the app's own reader keeps seeing
855
+ * the shape it wrote, and the replace is atomic so a crash mid-write cannot
856
+ * leave a truncated credential file behind.
857
+ */
858
+ async function writeOAuthCredential(region, credential, env = process.env) {
859
+ const path = oauthAuthPath(region, env);
860
+ let document;
861
+ try {
862
+ document = JSON.parse(await readFile(path, "utf8"));
863
+ } catch {
864
+ return;
865
+ }
866
+ const records = document.records;
867
+ if (typeof records !== "object" || records === null) return;
868
+ const first = Object.entries(records)[0];
869
+ if (first === void 0) return;
870
+ const [key, record] = first;
871
+ if (typeof record !== "object" || record === null) return;
872
+ const existing = record;
873
+ const updated = {
874
+ ...existing,
875
+ accessToken: credential.token,
876
+ refreshToken: credential.refreshToken ?? existing.refreshToken,
877
+ tokenType: "Bearer",
878
+ clientId: credential.clientId,
879
+ scopes: credential.scopes,
880
+ audience: credential.audience,
881
+ expiresAtMs: credential.expiresAtMs
882
+ };
883
+ const payload = {
884
+ ...document,
885
+ records: {
886
+ ...records,
887
+ [key]: updated
888
+ }
889
+ };
890
+ const temporary = `${path}.dsh-connect-minimaxcode.tmp`;
891
+ await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
892
+ await rename(temporary, path);
893
+ }
894
+ /**
895
+ * Read the credential, refreshing it when it is close to expiry.
896
+ *
897
+ * A refresh is only attempted for a credential the app already stored, and
898
+ * only when it is actually stale: a fresh token is left alone, so the plugin
899
+ * never competes with the app for the same grant.
900
+ */
901
+ async function ensureOAuthCredential(region, env = process.env, now = Date.now(), fetchImpl = fetch) {
902
+ const state = await readOAuthCredential(region, env, now);
903
+ if (state.state === "signed-in" && !needsRefresh(state.credential, now)) return state;
904
+ if (state.state === "signed-out") return state;
905
+ try {
906
+ return await refreshOAuthCredential(region, state.credential, env, now, fetchImpl);
907
+ } catch {
908
+ return state;
909
+ }
910
+ }
911
+ /**
912
+ * Read the credit summary out of a membership response.
913
+ *
914
+ * The app's settings page prefers the summary and falls back to the flat
915
+ * balance fields, so the same fallback chain is used here.
916
+ */
917
+ function parseCreditSummary(document) {
918
+ if (typeof document !== "object" || document === null) return void 0;
919
+ const record = document;
920
+ const summary = record.op_credit_summary;
921
+ if (typeof summary === "object" && summary !== null) {
922
+ const entry = summary;
923
+ const total = toNumber(entry.total_remaining_amount);
924
+ if (total !== void 0) {
925
+ const purchased = toNumber(entry.purchased_remaining_amount) ?? 0;
926
+ return {
927
+ total,
928
+ purchased,
929
+ free: toNumber(entry.free_remaining_amount) ?? total - purchased
930
+ };
931
+ }
932
+ }
933
+ const flat = toNumber(record.opcredit_balance) ?? toNumber(record.total_remains_credit);
934
+ return flat === void 0 ? void 0 : {
935
+ total: flat,
936
+ purchased: 0,
937
+ free: flat
938
+ };
939
+ }
940
+ /** Read a numeric field the upstream may send as a string. */
941
+ function toNumber(value) {
942
+ if (typeof value === "number" && Number.isFinite(value)) return value;
943
+ if (typeof value === "string" && value.trim() !== "") {
944
+ const parsed = Number(value);
945
+ return Number.isFinite(parsed) ? parsed : void 0;
946
+ }
947
+ }
948
+ /**
949
+ * Fetch the current credit balance.
950
+ *
951
+ * @returns The balance, or `undefined` when the account has no credit summary.
952
+ */
953
+ async function fetchCreditSummary(credential, region, fetchImpl = fetch) {
954
+ const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${MEMBERSHIP_PATH}`, {
955
+ method: "POST",
956
+ headers: {
957
+ "content-type": "application/json",
958
+ authorization: `Bearer ${credential.token}`
959
+ },
960
+ body: "{}"
961
+ });
962
+ if (!response.ok) throw new Error(`membership request failed: HTTP ${response.status}`);
963
+ return parseCreditSummary(await response.json());
964
+ }
965
+ /**
966
+ * Read the credit lots out of a ledger response.
967
+ *
968
+ * Lots that carry no expiry are ignored: a lot without an expiry date would
969
+ * otherwise be treated as one that never expires.
970
+ */
971
+ function parseCreditLots(document) {
972
+ if (typeof document !== "object" || document === null) return [];
973
+ const details = document.details;
974
+ if (!Array.isArray(details)) return [];
975
+ const lots = [];
976
+ for (const entry of details) {
977
+ if (typeof entry !== "object" || entry === null) continue;
978
+ const record = entry;
979
+ const expireAtMs = toNumber(record.expire_at_ms);
980
+ if (expireAtMs === void 0) continue;
981
+ const granted = toNumber(record.granted_amount) ?? 0;
982
+ const remaining = toNumber(record.remaining_amount) ?? 0;
983
+ lots.push({
984
+ granted,
985
+ remaining,
986
+ consumed: toNumber(record.consumed_amount) ?? Math.max(0, granted - remaining),
987
+ grantedAtMs: toNumber(record.granted_at_ms) ?? 0,
988
+ expireAtMs,
989
+ creditType: toNumber(record.credit_type)
990
+ });
991
+ }
992
+ return lots.sort((a, b) => a.expireAtMs - b.expireAtMs);
993
+ }
994
+ /**
995
+ * Fetch the credit ledger.
996
+ *
997
+ * The ledger is paginated, so every page is read. The number of pages comes
998
+ * from the account's own `total_count` rather than from a guess, and a lot
999
+ * count that stops growing ends the walk.
1000
+ */
1001
+ async function fetchCreditLots(credential, region, fetchImpl = fetch) {
1002
+ const pageSize = 100;
1003
+ const lots = [];
1004
+ let page = 1;
1005
+ let total = Number.POSITIVE_INFINITY;
1006
+ while (lots.length < total && page <= 50) {
1007
+ const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${CREDIT_DETAILS_PATH}?page=${page}&page_size=${pageSize}`, { headers: { authorization: `Bearer ${credential.token}` } });
1008
+ if (!response.ok) throw new Error(`credit ledger request failed: HTTP ${response.status}`);
1009
+ const document = await response.json();
1010
+ const batch = parseCreditLots(document);
1011
+ lots.push(...batch);
1012
+ const count = toNumber(document.total_count);
1013
+ if (count !== void 0) total = count;
1014
+ if (batch.length < pageSize) break;
1015
+ page += 1;
1016
+ }
1017
+ return lots;
1018
+ }
1019
+ /**
1020
+ * Summarize what is about to expire.
1021
+ *
1022
+ * Only lots that still hold credits count: a lot that was granted and used up
1023
+ * expires with nothing to lose, and counting it would make every account look
1024
+ * like it is about to lose credits.
1025
+ */
1026
+ function summarizeExpiry(lots, now = Date.now()) {
1027
+ const alive = lots.filter((lot) => lot.remaining > 0 && lot.expireAtMs > now);
1028
+ const within = (days) => alive.filter((lot) => lot.expireAtMs - now <= days * 864e5);
1029
+ const week = within(7);
1030
+ const month = within(30);
1031
+ return {
1032
+ expiring: week.reduce((sum, lot) => sum + lot.remaining, 0),
1033
+ expiringLots: week.length,
1034
+ expiringSoon: month.reduce((sum, lot) => sum + lot.remaining, 0),
1035
+ nextExpiryAtMs: alive[0]?.expireAtMs,
1036
+ upcoming: alive.slice(0, 5).map((lot) => ({
1037
+ amount: lot.remaining,
1038
+ expireAtMs: lot.expireAtMs
1039
+ })),
1040
+ totalRemaining: lots.reduce((sum, lot) => sum + lot.remaining, 0)
1041
+ };
1042
+ }
1043
+ /**
1044
+ * Keeps the current balance and the change between reads.
1045
+ *
1046
+ * The first read has nothing to compare against, so it reports no movement
1047
+ * rather than pretending the whole balance was spent.
1048
+ */
1049
+ var MinimaxCredits = class {
1050
+ #snapshot;
1051
+ #error;
1052
+ #lots = [];
1053
+ #expiry;
1054
+ #ledgerAtMs;
1055
+ #ledgerError;
1056
+ /** The balance as last read. */
1057
+ get snapshot() {
1058
+ return this.#snapshot;
1059
+ }
1060
+ /** Why the last read failed, when one did. */
1061
+ get error() {
1062
+ return this.#error;
1063
+ }
1064
+ /** The credit lots as last read. */
1065
+ get lots() {
1066
+ return this.#lots;
1067
+ }
1068
+ /** What the lots say about expiry, when the ledger could be read. */
1069
+ get expiry() {
1070
+ return this.#expiry;
1071
+ }
1072
+ /** When the ledger was last read. */
1073
+ get ledgerAtMs() {
1074
+ return this.#ledgerAtMs;
1075
+ }
1076
+ /** Why the ledger could not be read, when it could not. */
1077
+ get ledgerError() {
1078
+ return this.#ledgerError;
1079
+ }
1080
+ /**
1081
+ * Read the balance and record how much was consumed since the last read.
1082
+ *
1083
+ * The credit ledger is read alongside it, but a ledger that cannot be read
1084
+ * never hides the balance: the two come from different endpoints and only
1085
+ * the balance is essential.
1086
+ *
1087
+ * @returns Whether the balance changed.
1088
+ */
1089
+ async refresh(credential, region, fetchImpl = fetch, now = Date.now()) {
1090
+ let changed = false;
1091
+ try {
1092
+ const summary = await fetchCreditSummary(credential, region, fetchImpl);
1093
+ if (summary === void 0) this.#error = "membership response carried no credit summary";
1094
+ else {
1095
+ const previous = this.#snapshot;
1096
+ this.#snapshot = {
1097
+ summary,
1098
+ fetchedAtMs: now,
1099
+ consumed: previous === void 0 ? 0 : previous.summary.total - summary.total
1100
+ };
1101
+ this.#error = void 0;
1102
+ changed = previous === void 0 || previous.summary.total !== summary.total;
1103
+ }
1104
+ } catch (error) {
1105
+ this.#error = error instanceof Error ? error.message : String(error);
1106
+ }
1107
+ await this.refreshLedger(credential, region, fetchImpl, now);
1108
+ return changed;
1109
+ }
1110
+ /**
1111
+ * Read the credit ledger and work out what is about to expire.
1112
+ *
1113
+ * A failure here is remembered but never thrown: the balance is what the
1114
+ * user needs most, and a ledger that is temporarily unavailable must not
1115
+ * take it down with it.
1116
+ */
1117
+ async refreshLedger(credential, region, fetchImpl = fetch, now = Date.now()) {
1118
+ try {
1119
+ const lots = await fetchCreditLots(credential, region, fetchImpl);
1120
+ this.#lots = lots;
1121
+ this.#expiry = summarizeExpiry(lots, now);
1122
+ this.#ledgerAtMs = now;
1123
+ this.#ledgerError = void 0;
1124
+ } catch (error) {
1125
+ this.#ledgerError = error instanceof Error ? error.message : String(error);
1126
+ }
1127
+ }
1128
+ };
1129
+ //#endregion
1130
+ //#region src/usage.ts
1131
+ /** An empty usage record. */
1132
+ const EMPTY_USAGE = {
1133
+ inputTokens: 0,
1134
+ outputTokens: 0,
1135
+ cacheReadTokens: 0,
1136
+ cacheWriteTokens: 0,
1137
+ reasoningTokens: 0
1138
+ };
1139
+ /** The published domestic prices of the models this plugin serves. */
1140
+ const MODEL_PRICES = {
1141
+ "MiniMax-M3.1-Flash": {
1142
+ input: 1,
1143
+ output: 4,
1144
+ cacheRead: .1
1145
+ },
1146
+ "MiniMax-M3": {
1147
+ input: 2.1,
1148
+ output: 8.4,
1149
+ cacheRead: .42
1150
+ }
1151
+ };
1152
+ /**
1153
+ * The price for one model at one input size.
1154
+ *
1155
+ * M3 is billed in two tiers: requests above 512k input tokens cost double.
1156
+ * The tier is decided by the billed input, which is the uncached input plus
1157
+ * the cache read.
1158
+ */
1159
+ function priceFor(model, billedInputTokens) {
1160
+ const price = MODEL_PRICES[model];
1161
+ if (price === void 0) return void 0;
1162
+ if (model === "MiniMax-M3" && billedInputTokens > 512e3) return {
1163
+ input: price.input * 2,
1164
+ output: price.output * 2,
1165
+ cacheRead: price.cacheRead * 2
1166
+ };
1167
+ return price;
1168
+ }
1169
+ /**
1170
+ * The equivalent amount of one request, in 元.
1171
+ *
1172
+ * Returns `undefined` for a model without a published price, rather than
1173
+ * pretending the request was free.
1174
+ */
1175
+ function estimateCny(model, usage) {
1176
+ const price = priceFor(model, usage.inputTokens + usage.cacheReadTokens);
1177
+ if (price === void 0) return void 0;
1178
+ const perMillion = (tokens, rate) => tokens / 1e6 * rate;
1179
+ return perMillion(usage.inputTokens, price.input) + perMillion(usage.outputTokens, price.output) + perMillion(usage.cacheReadTokens, price.cacheRead);
1180
+ }
1181
+ /**
1182
+ * Accumulates what the sessions routed through this plugin have used.
1183
+ *
1184
+ * The meter is the plugin's own view: it counts the requests this host made,
1185
+ * with the counters the gateway returned. It is not the account's total, and
1186
+ * it is not the credit balance — the balance comes from the account API.
1187
+ */
1188
+ var UsageMeter = class {
1189
+ #records = [];
1190
+ #byModel = /* @__PURE__ */ new Map();
1191
+ /** Every recorded request, oldest first. */
1192
+ get records() {
1193
+ return this.#records;
1194
+ }
1195
+ /** The most recent request. */
1196
+ get last() {
1197
+ return this.#records[this.#records.length - 1];
1198
+ }
1199
+ /** Record one request's usage. */
1200
+ record(model, usage) {
1201
+ const entry = {
1202
+ model,
1203
+ usage: { ...usage },
1204
+ cny: estimateCny(model, usage)
1205
+ };
1206
+ this.#records.push(entry);
1207
+ const totals = this.#byModel.get(model) ?? { ...EMPTY_USAGE };
1208
+ this.#byModel.set(model, {
1209
+ inputTokens: totals.inputTokens + usage.inputTokens,
1210
+ outputTokens: totals.outputTokens + usage.outputTokens,
1211
+ cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
1212
+ cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
1213
+ reasoningTokens: totals.reasoningTokens + usage.reasoningTokens
1214
+ });
1215
+ return entry;
1216
+ }
1217
+ /** Totals across every recorded request. */
1218
+ totals() {
1219
+ let total = { ...EMPTY_USAGE };
1220
+ for (const usage of this.#byModel.values()) total = {
1221
+ inputTokens: total.inputTokens + usage.inputTokens,
1222
+ outputTokens: total.outputTokens + usage.outputTokens,
1223
+ cacheReadTokens: total.cacheReadTokens + usage.cacheReadTokens,
1224
+ cacheWriteTokens: total.cacheWriteTokens + usage.cacheWriteTokens,
1225
+ reasoningTokens: total.reasoningTokens + usage.reasoningTokens
1226
+ };
1227
+ return total;
1228
+ }
1229
+ /** Totals per model. */
1230
+ byModel() {
1231
+ return this.#byModel;
1232
+ }
1233
+ /** The equivalent amount of everything recorded, in 元. */
1234
+ estimatedCny() {
1235
+ let total = 0;
1236
+ for (const record of this.#records) if (record.cny !== void 0) total += record.cny;
1237
+ return total;
1238
+ }
1239
+ /** Forget everything recorded. */
1240
+ reset() {
1241
+ this.#records = [];
1242
+ this.#byModel.clear();
1243
+ }
1244
+ };
1245
+ //#endregion
614
1246
  //#region src/index.ts
615
1247
  /** Loader entry name; also the id the bundle patch inserts. */
616
1248
  const name = "dsh-connect-minimaxcode";
@@ -624,6 +1256,13 @@ function pickRegion(preferred) {
624
1256
  /** How often the card may pull a fresh catalog. */
625
1257
  const CATALOG_TTL_MS = 3e5;
626
1258
  /**
1259
+ * How often the account's credit balance may be re-read.
1260
+ *
1261
+ * The balance is a live counter upstream, but it is an account-wide value:
1262
+ * reading it too often buys nothing except load on the account API.
1263
+ */
1264
+ const CREDIT_TTL_MS = 3e4;
1265
+ /**
627
1266
  * Compose the plugin.
628
1267
  *
629
1268
  * The credential is re-read on every request rather than cached at start: the
@@ -634,10 +1273,14 @@ const CATALOG_TTL_MS = 3e5;
634
1273
  */
635
1274
  function apply(ctx) {
636
1275
  const catalog = new MinimaxCatalog();
1276
+ const credits = new MinimaxCredits();
1277
+ const meter = new UsageMeter();
637
1278
  let region = "cn";
638
1279
  let credential;
639
1280
  let lastCatalogAtMs = 0;
1281
+ let lastCreditAtMs = 0;
640
1282
  let refreshing = false;
1283
+ let creditState = "unavailable";
641
1284
  const readNow = async () => {
642
1285
  const state = await readCredential();
643
1286
  if (state.state !== "signed-in") {
@@ -659,25 +1302,97 @@ function apply(ctx) {
659
1302
  refreshing = false;
660
1303
  }
661
1304
  };
1305
+ /**
1306
+ * Read the account's credit balance, at most once per TTL.
1307
+ *
1308
+ * The account API needs the desktop app's own OAuth token, not the model
1309
+ * gateway's JWT, so this reads a second credential file. A stale token is
1310
+ * refreshed through the app's own grant and written back in the app's own
1311
+ * format, which keeps the app signed in.
1312
+ */
1313
+ const refreshCredits = async () => {
1314
+ const now = Date.now();
1315
+ if (now - lastCreditAtMs < CREDIT_TTL_MS) return;
1316
+ lastCreditAtMs = now;
1317
+ const state = await ensureOAuthCredential(region, process.env, now);
1318
+ if (state.state === "signed-out") {
1319
+ creditState = "signed-out";
1320
+ return;
1321
+ }
1322
+ if (state.state === "expired") {
1323
+ creditState = "expired";
1324
+ return;
1325
+ }
1326
+ await credits.refresh(state.credential, region, fetch, now);
1327
+ creditState = credits.snapshot === void 0 ? "unavailable" : "ok";
1328
+ };
1329
+ /** The credit balance as the card and the browser half read it. */
1330
+ const buildCreditStatus = () => {
1331
+ const snapshot = credits.snapshot;
1332
+ const expiry = credits.expiry;
1333
+ if (snapshot === void 0) return {
1334
+ state: creditState,
1335
+ ...credits.error === void 0 ? {} : { error: credits.error }
1336
+ };
1337
+ return {
1338
+ state: "ok",
1339
+ total: snapshot.summary.total,
1340
+ purchased: snapshot.summary.purchased,
1341
+ free: snapshot.summary.free,
1342
+ consumed: snapshot.consumed,
1343
+ updatedAtMs: snapshot.fetchedAtMs,
1344
+ ...expiry === void 0 ? {} : {
1345
+ expiring: expiry.expiring,
1346
+ expiringLots: expiry.expiringLots,
1347
+ expiringSoon: expiry.expiringSoon,
1348
+ nextExpiryAtMs: expiry.nextExpiryAtMs,
1349
+ upcoming: expiry.upcoming
1350
+ },
1351
+ ...credits.ledgerAtMs === void 0 ? {} : { ledgerAtMs: credits.ledgerAtMs },
1352
+ ...credits.ledgerError === void 0 ? {} : { ledgerError: credits.ledgerError }
1353
+ };
1354
+ };
1355
+ /** What this host's own requests have used. */
1356
+ const buildUsageStatus = () => {
1357
+ const totals = meter.totals();
1358
+ const last = meter.last;
1359
+ return {
1360
+ inputTokens: totals.inputTokens,
1361
+ outputTokens: totals.outputTokens,
1362
+ cacheReadTokens: totals.cacheReadTokens,
1363
+ cacheWriteTokens: totals.cacheWriteTokens,
1364
+ reasoningTokens: totals.reasoningTokens,
1365
+ estimatedCny: meter.estimatedCny(),
1366
+ ...last?.cny === void 0 ? {} : { lastCny: last.cny },
1367
+ ...last === void 0 ? {} : { lastModel: last.model },
1368
+ requests: meter.records.length
1369
+ };
1370
+ };
662
1371
  const { adapter, invalidate } = createMinimaxCodeAdapter({
663
1372
  catalog,
664
1373
  region: () => region,
665
1374
  resolveApiKey: async () => (await readNow())?.token,
666
- bearerToken: () => bearerToken()
1375
+ bearerToken: () => bearerToken(),
1376
+ onUsage: (model, usage) => {
1377
+ meter.record(model, usage);
1378
+ refreshCredits();
1379
+ }
667
1380
  });
668
1381
  ctx.llm.registerAdapter(["minimaxcode"], adapter);
669
1382
  /** Build the document the card renders, without ever exposing the token. */
670
1383
  const buildStatus = async () => {
671
1384
  const state = await readCredential();
1385
+ const auth = state.state === "signed-in" ? {
1386
+ state: "signed-in",
1387
+ daysRemaining: Math.ceil(expiresInMs(state.credential.claims) / 864e5),
1388
+ expiry: expiryLevel(state.credential.claims)
1389
+ } : state.state === "expired" ? { state: "expired" } : {
1390
+ state: "signed-out",
1391
+ reason: state.reason
1392
+ };
1393
+ await refreshCredits();
672
1394
  return statusDocument({
673
- auth: state.state === "signed-in" ? {
674
- state: "signed-in",
675
- daysRemaining: Math.ceil(expiresInMs(state.credential.claims) / 864e5),
676
- expiry: expiryLevel(state.credential.claims)
677
- } : state.state === "expired" ? { state: "expired" } : {
678
- state: "signed-out",
679
- reason: state.reason
680
- },
1395
+ auth,
681
1396
  region,
682
1397
  catalogSource: catalog.source,
683
1398
  catalogError: catalog.error,
@@ -686,7 +1401,9 @@ function apply(ctx) {
686
1401
  name: modelName,
687
1402
  contextWindow
688
1403
  })),
689
- refreshing
1404
+ refreshing,
1405
+ credits: buildCreditStatus(),
1406
+ usage: buildUsageStatus()
690
1407
  });
691
1408
  };
692
1409
  (async () => {
@@ -719,4 +1436,4 @@ function apply(ctx) {
719
1436
  });
720
1437
  }
721
1438
  //#endregion
722
- export { FALLBACK_MODELS, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MinimaxCatalog, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, expiresInMs, expiryLevel, inject, name, parseAuthDocument, parseCatalog, readCredential, statusDocument };
1439
+ export { EMPTY_USAGE, FALLBACK_MODELS, MEMBERSHIP_PATH, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MODEL_PRICES, MinimaxCatalog, MinimaxCredits, UsageMeter, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, ensureOAuthCredential, estimateCny, expiresInMs, expiryLevel, fetchCreditSummary, inject, name, parseAuthDocument, parseCatalog, parseCreditSummary, parseOAuthDocument, priceFor, readCredential, readOAuthCredential, refreshOAuthCredential, statusDocument, writeOAuthCredential };