dsh-connect-minimaxcode 0.2.3 → 0.3.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,453 @@ 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
+ /** OAuth token endpoint. */
720
+ const TOKEN_PATH = "/oauth2/token";
721
+ /** Absolute path of the app's credential file for a region. */
722
+ function oauthAuthPath(region, env = process.env) {
723
+ return join(dataDir(env), "auth", "prod", region, "mcode-public", "auth.json");
724
+ }
725
+ /** Milliseconds until the credential expires; negative once it has. */
726
+ function expiresInMs$1(credential, now = Date.now()) {
727
+ return credential.expiresAtMs === void 0 ? void 0 : credential.expiresAtMs - now;
728
+ }
729
+ /** Whether the credential is close enough to expiry to be worth refreshing. */
730
+ function needsRefresh(credential, now = Date.now()) {
731
+ const remaining = expiresInMs$1(credential, now);
732
+ return remaining !== void 0 && remaining <= 12e4;
733
+ }
734
+ /**
735
+ * Parse the app's credential document.
736
+ *
737
+ * The record key is generated per install, so the record is found by value
738
+ * rather than by a hard-coded name.
739
+ */
740
+ function parseOAuthDocument(text, now = Date.now()) {
741
+ let parsed;
742
+ try {
743
+ parsed = JSON.parse(text);
744
+ } catch {
745
+ return {
746
+ state: "signed-out",
747
+ reason: "unreadable"
748
+ };
749
+ }
750
+ const records = parsed.records;
751
+ if (typeof records !== "object" || records === null) return {
752
+ state: "signed-out",
753
+ reason: "not-signed-in"
754
+ };
755
+ const record = Object.values(records)[0];
756
+ if (typeof record !== "object" || record === null) return {
757
+ state: "signed-out",
758
+ reason: "not-signed-in"
759
+ };
760
+ const entry = record;
761
+ const token = entry.accessToken;
762
+ if (typeof token !== "string" || token.length === 0) return {
763
+ state: "signed-out",
764
+ reason: "not-signed-in"
765
+ };
766
+ const expiresAtMs = typeof entry.expiresAtMs === "number" ? entry.expiresAtMs : void 0;
767
+ const credential = {
768
+ token,
769
+ refreshToken: typeof entry.refreshToken === "string" ? entry.refreshToken : void 0,
770
+ expiresAtMs,
771
+ clientId: typeof entry.clientId === "string" ? entry.clientId : MINIMAX_OAUTH_CLIENT_ID,
772
+ scopes: Array.isArray(entry.scopes) ? entry.scopes.filter((s) => typeof s === "string") : [...MINIMAX_OAUTH_SCOPES],
773
+ audience: typeof entry.audience === "string" ? entry.audience : MINIMAX_OAUTH_AUDIENCE,
774
+ generation: typeof entry.generation === "number" ? entry.generation : void 0
775
+ };
776
+ return expiresAtMs !== void 0 && expiresAtMs <= now ? {
777
+ state: "expired",
778
+ credential
779
+ } : {
780
+ state: "signed-in",
781
+ credential
782
+ };
783
+ }
784
+ /**
785
+ * Read the desktop app's OAuth credential.
786
+ *
787
+ * Every failure is a state, not an exception: an app that is not installed, or
788
+ * not signed in, is a normal condition the UI renders rather than an error the
789
+ * host logs on every request.
790
+ */
791
+ async function readOAuthCredential(region, env = process.env, now = Date.now()) {
792
+ let text;
793
+ try {
794
+ text = await readFile(oauthAuthPath(region, env), "utf8");
795
+ } catch {
796
+ return {
797
+ state: "signed-out",
798
+ reason: "app-missing"
799
+ };
800
+ }
801
+ return parseOAuthDocument(text, now);
802
+ }
803
+ /**
804
+ * Exchange the stored refresh token for a fresh access token.
805
+ *
806
+ * The authorization server rotates the refresh token, so the new pair is
807
+ * written back into the app's own file, in the app's own format, with an
808
+ * atomic replace. `generation` is left untouched on purpose: the app uses it
809
+ * to decide whether a stored credential still belongs to its own login, and
810
+ * raising it would make the app believe it had been signed in elsewhere.
811
+ */
812
+ async function refreshOAuthCredential(region, credential, env = process.env, now = Date.now(), fetchImpl = fetch) {
813
+ if (credential.refreshToken === void 0) return {
814
+ state: "expired",
815
+ credential
816
+ };
817
+ const body = new URLSearchParams({
818
+ grant_type: "refresh_token",
819
+ refresh_token: credential.refreshToken,
820
+ client_id: credential.clientId,
821
+ scope: credential.scopes.join(" "),
822
+ audience: credential.audience
823
+ });
824
+ const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH}`, {
825
+ method: "POST",
826
+ headers: {
827
+ accept: "application/json",
828
+ "content-type": "application/x-www-form-urlencoded"
829
+ },
830
+ body
831
+ });
832
+ if (!response.ok) throw new Error(`OAuth refresh failed: HTTP ${response.status}`);
833
+ const document = await response.json();
834
+ const token = document.access_token;
835
+ if (typeof token !== "string" || token.length === 0) throw new Error("OAuth refresh returned no access token");
836
+ const expiresInSec = typeof document.expires_in === "number" ? document.expires_in : void 0;
837
+ const next = {
838
+ ...credential,
839
+ token,
840
+ refreshToken: typeof document.refresh_token === "string" ? document.refresh_token : credential.refreshToken,
841
+ expiresAtMs: expiresInSec === void 0 ? void 0 : now + expiresInSec * 1e3
842
+ };
843
+ await writeOAuthCredential(region, next, env);
844
+ return {
845
+ state: "signed-in",
846
+ credential: next
847
+ };
848
+ }
849
+ /**
850
+ * Write a rotated credential back into the app's store.
851
+ *
852
+ * The document is rewritten as a whole so the app's own reader keeps seeing
853
+ * the shape it wrote, and the replace is atomic so a crash mid-write cannot
854
+ * leave a truncated credential file behind.
855
+ */
856
+ async function writeOAuthCredential(region, credential, env = process.env) {
857
+ const path = oauthAuthPath(region, env);
858
+ let document;
859
+ try {
860
+ document = JSON.parse(await readFile(path, "utf8"));
861
+ } catch {
862
+ return;
863
+ }
864
+ const records = document.records;
865
+ if (typeof records !== "object" || records === null) return;
866
+ const first = Object.entries(records)[0];
867
+ if (first === void 0) return;
868
+ const [key, record] = first;
869
+ if (typeof record !== "object" || record === null) return;
870
+ const existing = record;
871
+ const updated = {
872
+ ...existing,
873
+ accessToken: credential.token,
874
+ refreshToken: credential.refreshToken ?? existing.refreshToken,
875
+ tokenType: "Bearer",
876
+ clientId: credential.clientId,
877
+ scopes: credential.scopes,
878
+ audience: credential.audience,
879
+ expiresAtMs: credential.expiresAtMs
880
+ };
881
+ const payload = {
882
+ ...document,
883
+ records: {
884
+ ...records,
885
+ [key]: updated
886
+ }
887
+ };
888
+ const temporary = `${path}.dsh-connect-minimaxcode.tmp`;
889
+ await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
890
+ await rename(temporary, path);
891
+ }
892
+ /**
893
+ * Read the credential, refreshing it when it is close to expiry.
894
+ *
895
+ * A refresh is only attempted for a credential the app already stored, and
896
+ * only when it is actually stale: a fresh token is left alone, so the plugin
897
+ * never competes with the app for the same grant.
898
+ */
899
+ async function ensureOAuthCredential(region, env = process.env, now = Date.now(), fetchImpl = fetch) {
900
+ const state = await readOAuthCredential(region, env, now);
901
+ if (state.state === "signed-in" && !needsRefresh(state.credential, now)) return state;
902
+ if (state.state === "signed-out") return state;
903
+ try {
904
+ return await refreshOAuthCredential(region, state.credential, env, now, fetchImpl);
905
+ } catch {
906
+ return state;
907
+ }
908
+ }
909
+ /**
910
+ * Read the credit summary out of a membership response.
911
+ *
912
+ * The app's settings page prefers the summary and falls back to the flat
913
+ * balance fields, so the same fallback chain is used here.
914
+ */
915
+ function parseCreditSummary(document) {
916
+ if (typeof document !== "object" || document === null) return void 0;
917
+ const record = document;
918
+ const summary = record.op_credit_summary;
919
+ if (typeof summary === "object" && summary !== null) {
920
+ const entry = summary;
921
+ const total = toNumber(entry.total_remaining_amount);
922
+ if (total !== void 0) {
923
+ const purchased = toNumber(entry.purchased_remaining_amount) ?? 0;
924
+ return {
925
+ total,
926
+ purchased,
927
+ free: toNumber(entry.free_remaining_amount) ?? total - purchased
928
+ };
929
+ }
930
+ }
931
+ const flat = toNumber(record.opcredit_balance) ?? toNumber(record.total_remains_credit);
932
+ return flat === void 0 ? void 0 : {
933
+ total: flat,
934
+ purchased: 0,
935
+ free: flat
936
+ };
937
+ }
938
+ /** Read a numeric field the upstream may send as a string. */
939
+ function toNumber(value) {
940
+ if (typeof value === "number" && Number.isFinite(value)) return value;
941
+ if (typeof value === "string" && value.trim() !== "") {
942
+ const parsed = Number(value);
943
+ return Number.isFinite(parsed) ? parsed : void 0;
944
+ }
945
+ }
946
+ /**
947
+ * Fetch the current credit balance.
948
+ *
949
+ * @returns The balance, or `undefined` when the account has no credit summary.
950
+ */
951
+ async function fetchCreditSummary(credential, region, fetchImpl = fetch) {
952
+ const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${MEMBERSHIP_PATH}`, {
953
+ method: "POST",
954
+ headers: {
955
+ "content-type": "application/json",
956
+ authorization: `Bearer ${credential.token}`
957
+ },
958
+ body: "{}"
959
+ });
960
+ if (!response.ok) throw new Error(`membership request failed: HTTP ${response.status}`);
961
+ return parseCreditSummary(await response.json());
962
+ }
963
+ /**
964
+ * Keeps the current balance and the change between reads.
965
+ *
966
+ * The first read has nothing to compare against, so it reports no movement
967
+ * rather than pretending the whole balance was spent.
968
+ */
969
+ var MinimaxCredits = class {
970
+ #snapshot;
971
+ #error;
972
+ /** The balance as last read. */
973
+ get snapshot() {
974
+ return this.#snapshot;
975
+ }
976
+ /** Why the last read failed, when one did. */
977
+ get error() {
978
+ return this.#error;
979
+ }
980
+ /**
981
+ * Read the balance and record how much was consumed since the last read.
982
+ *
983
+ * @returns Whether the balance changed.
984
+ */
985
+ async refresh(credential, region, fetchImpl = fetch, now = Date.now()) {
986
+ try {
987
+ const summary = await fetchCreditSummary(credential, region, fetchImpl);
988
+ if (summary === void 0) {
989
+ this.#error = "membership response carried no credit summary";
990
+ return false;
991
+ }
992
+ const previous = this.#snapshot;
993
+ this.#snapshot = {
994
+ summary,
995
+ fetchedAtMs: now,
996
+ consumed: previous === void 0 ? 0 : previous.summary.total - summary.total
997
+ };
998
+ this.#error = void 0;
999
+ return previous === void 0 || previous.summary.total !== summary.total;
1000
+ } catch (error) {
1001
+ this.#error = error instanceof Error ? error.message : String(error);
1002
+ return false;
1003
+ }
1004
+ }
1005
+ };
1006
+ //#endregion
1007
+ //#region src/usage.ts
1008
+ /** An empty usage record. */
1009
+ const EMPTY_USAGE = {
1010
+ inputTokens: 0,
1011
+ outputTokens: 0,
1012
+ cacheReadTokens: 0,
1013
+ cacheWriteTokens: 0,
1014
+ reasoningTokens: 0
1015
+ };
1016
+ /** The published domestic prices of the models this plugin serves. */
1017
+ const MODEL_PRICES = {
1018
+ "MiniMax-M3.1-Flash": {
1019
+ input: 1,
1020
+ output: 4,
1021
+ cacheRead: .1
1022
+ },
1023
+ "MiniMax-M3": {
1024
+ input: 2.1,
1025
+ output: 8.4,
1026
+ cacheRead: .42
1027
+ }
1028
+ };
1029
+ /**
1030
+ * The price for one model at one input size.
1031
+ *
1032
+ * M3 is billed in two tiers: requests above 512k input tokens cost double.
1033
+ * The tier is decided by the billed input, which is the uncached input plus
1034
+ * the cache read.
1035
+ */
1036
+ function priceFor(model, billedInputTokens) {
1037
+ const price = MODEL_PRICES[model];
1038
+ if (price === void 0) return void 0;
1039
+ if (model === "MiniMax-M3" && billedInputTokens > 512e3) return {
1040
+ input: price.input * 2,
1041
+ output: price.output * 2,
1042
+ cacheRead: price.cacheRead * 2
1043
+ };
1044
+ return price;
1045
+ }
1046
+ /**
1047
+ * The equivalent amount of one request, in 元.
1048
+ *
1049
+ * Returns `undefined` for a model without a published price, rather than
1050
+ * pretending the request was free.
1051
+ */
1052
+ function estimateCny(model, usage) {
1053
+ const price = priceFor(model, usage.inputTokens + usage.cacheReadTokens);
1054
+ if (price === void 0) return void 0;
1055
+ const perMillion = (tokens, rate) => tokens / 1e6 * rate;
1056
+ return perMillion(usage.inputTokens, price.input) + perMillion(usage.outputTokens, price.output) + perMillion(usage.cacheReadTokens, price.cacheRead);
1057
+ }
1058
+ /**
1059
+ * Accumulates what the sessions routed through this plugin have used.
1060
+ *
1061
+ * The meter is the plugin's own view: it counts the requests this host made,
1062
+ * with the counters the gateway returned. It is not the account's total, and
1063
+ * it is not the credit balance — the balance comes from the account API.
1064
+ */
1065
+ var UsageMeter = class {
1066
+ #records = [];
1067
+ #byModel = /* @__PURE__ */ new Map();
1068
+ /** Every recorded request, oldest first. */
1069
+ get records() {
1070
+ return this.#records;
1071
+ }
1072
+ /** The most recent request. */
1073
+ get last() {
1074
+ return this.#records[this.#records.length - 1];
1075
+ }
1076
+ /** Record one request's usage. */
1077
+ record(model, usage) {
1078
+ const entry = {
1079
+ model,
1080
+ usage: { ...usage },
1081
+ cny: estimateCny(model, usage)
1082
+ };
1083
+ this.#records.push(entry);
1084
+ const totals = this.#byModel.get(model) ?? { ...EMPTY_USAGE };
1085
+ this.#byModel.set(model, {
1086
+ inputTokens: totals.inputTokens + usage.inputTokens,
1087
+ outputTokens: totals.outputTokens + usage.outputTokens,
1088
+ cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
1089
+ cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
1090
+ reasoningTokens: totals.reasoningTokens + usage.reasoningTokens
1091
+ });
1092
+ return entry;
1093
+ }
1094
+ /** Totals across every recorded request. */
1095
+ totals() {
1096
+ let total = { ...EMPTY_USAGE };
1097
+ for (const usage of this.#byModel.values()) total = {
1098
+ inputTokens: total.inputTokens + usage.inputTokens,
1099
+ outputTokens: total.outputTokens + usage.outputTokens,
1100
+ cacheReadTokens: total.cacheReadTokens + usage.cacheReadTokens,
1101
+ cacheWriteTokens: total.cacheWriteTokens + usage.cacheWriteTokens,
1102
+ reasoningTokens: total.reasoningTokens + usage.reasoningTokens
1103
+ };
1104
+ return total;
1105
+ }
1106
+ /** Totals per model. */
1107
+ byModel() {
1108
+ return this.#byModel;
1109
+ }
1110
+ /** The equivalent amount of everything recorded, in 元. */
1111
+ estimatedCny() {
1112
+ let total = 0;
1113
+ for (const record of this.#records) if (record.cny !== void 0) total += record.cny;
1114
+ return total;
1115
+ }
1116
+ /** Forget everything recorded. */
1117
+ reset() {
1118
+ this.#records = [];
1119
+ this.#byModel.clear();
1120
+ }
1121
+ };
1122
+ //#endregion
614
1123
  //#region src/index.ts
615
1124
  /** Loader entry name; also the id the bundle patch inserts. */
616
1125
  const name = "dsh-connect-minimaxcode";
@@ -624,6 +1133,13 @@ function pickRegion(preferred) {
624
1133
  /** How often the card may pull a fresh catalog. */
625
1134
  const CATALOG_TTL_MS = 3e5;
626
1135
  /**
1136
+ * How often the account's credit balance may be re-read.
1137
+ *
1138
+ * The balance is a live counter upstream, but it is an account-wide value:
1139
+ * reading it too often buys nothing except load on the account API.
1140
+ */
1141
+ const CREDIT_TTL_MS = 3e4;
1142
+ /**
627
1143
  * Compose the plugin.
628
1144
  *
629
1145
  * The credential is re-read on every request rather than cached at start: the
@@ -634,10 +1150,14 @@ const CATALOG_TTL_MS = 3e5;
634
1150
  */
635
1151
  function apply(ctx) {
636
1152
  const catalog = new MinimaxCatalog();
1153
+ const credits = new MinimaxCredits();
1154
+ const meter = new UsageMeter();
637
1155
  let region = "cn";
638
1156
  let credential;
639
1157
  let lastCatalogAtMs = 0;
1158
+ let lastCreditAtMs = 0;
640
1159
  let refreshing = false;
1160
+ let creditState = "unavailable";
641
1161
  const readNow = async () => {
642
1162
  const state = await readCredential();
643
1163
  if (state.state !== "signed-in") {
@@ -659,25 +1179,87 @@ function apply(ctx) {
659
1179
  refreshing = false;
660
1180
  }
661
1181
  };
1182
+ /**
1183
+ * Read the account's credit balance, at most once per TTL.
1184
+ *
1185
+ * The account API needs the desktop app's own OAuth token, not the model
1186
+ * gateway's JWT, so this reads a second credential file. A stale token is
1187
+ * refreshed through the app's own grant and written back in the app's own
1188
+ * format, which keeps the app signed in.
1189
+ */
1190
+ const refreshCredits = async () => {
1191
+ const now = Date.now();
1192
+ if (now - lastCreditAtMs < CREDIT_TTL_MS) return;
1193
+ lastCreditAtMs = now;
1194
+ const state = await ensureOAuthCredential(region, process.env, now);
1195
+ if (state.state === "signed-out") {
1196
+ creditState = "signed-out";
1197
+ return;
1198
+ }
1199
+ if (state.state === "expired") {
1200
+ creditState = "expired";
1201
+ return;
1202
+ }
1203
+ await credits.refresh(state.credential, region, fetch, now);
1204
+ creditState = credits.snapshot === void 0 ? "unavailable" : "ok";
1205
+ };
1206
+ /** The credit balance as the card and the browser half read it. */
1207
+ const buildCreditStatus = () => {
1208
+ const snapshot = credits.snapshot;
1209
+ if (snapshot === void 0) return {
1210
+ state: creditState,
1211
+ ...credits.error === void 0 ? {} : { error: credits.error }
1212
+ };
1213
+ return {
1214
+ state: "ok",
1215
+ total: snapshot.summary.total,
1216
+ purchased: snapshot.summary.purchased,
1217
+ free: snapshot.summary.free,
1218
+ consumed: snapshot.consumed,
1219
+ updatedAtMs: snapshot.fetchedAtMs
1220
+ };
1221
+ };
1222
+ /** What this host's own requests have used. */
1223
+ const buildUsageStatus = () => {
1224
+ const totals = meter.totals();
1225
+ const last = meter.last;
1226
+ return {
1227
+ inputTokens: totals.inputTokens,
1228
+ outputTokens: totals.outputTokens,
1229
+ cacheReadTokens: totals.cacheReadTokens,
1230
+ cacheWriteTokens: totals.cacheWriteTokens,
1231
+ reasoningTokens: totals.reasoningTokens,
1232
+ estimatedCny: meter.estimatedCny(),
1233
+ ...last?.cny === void 0 ? {} : { lastCny: last.cny },
1234
+ ...last === void 0 ? {} : { lastModel: last.model },
1235
+ requests: meter.records.length
1236
+ };
1237
+ };
662
1238
  const { adapter, invalidate } = createMinimaxCodeAdapter({
663
1239
  catalog,
664
1240
  region: () => region,
665
1241
  resolveApiKey: async () => (await readNow())?.token,
666
- bearerToken: () => bearerToken()
1242
+ bearerToken: () => bearerToken(),
1243
+ onUsage: (model, usage) => {
1244
+ meter.record(model, usage);
1245
+ refreshCredits();
1246
+ }
667
1247
  });
668
1248
  ctx.llm.registerAdapter(["minimaxcode"], adapter);
669
1249
  /** Build the document the card renders, without ever exposing the token. */
670
1250
  const buildStatus = async () => {
671
1251
  const state = await readCredential();
1252
+ const auth = state.state === "signed-in" ? {
1253
+ state: "signed-in",
1254
+ daysRemaining: Math.ceil(expiresInMs(state.credential.claims) / 864e5),
1255
+ expiry: expiryLevel(state.credential.claims)
1256
+ } : state.state === "expired" ? { state: "expired" } : {
1257
+ state: "signed-out",
1258
+ reason: state.reason
1259
+ };
1260
+ await refreshCredits();
672
1261
  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
- },
1262
+ auth,
681
1263
  region,
682
1264
  catalogSource: catalog.source,
683
1265
  catalogError: catalog.error,
@@ -686,7 +1268,9 @@ function apply(ctx) {
686
1268
  name: modelName,
687
1269
  contextWindow
688
1270
  })),
689
- refreshing
1271
+ refreshing,
1272
+ credits: buildCreditStatus(),
1273
+ usage: buildUsageStatus()
690
1274
  });
691
1275
  };
692
1276
  (async () => {
@@ -719,4 +1303,4 @@ function apply(ctx) {
719
1303
  });
720
1304
  }
721
1305
  //#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 };
1306
+ 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 };