dsh-connect-minimaxcode 0.2.2 → 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.
@@ -233,20 +233,27 @@ const MINIMAXCODE_PROVIDER_ID = "minimaxcode";
233
233
  * models the desktop app is known to ship.
234
234
  *
235
235
  * Deliberately small and conservative: a fallback entry overstates nothing we
236
- * could not confirm.
236
+ * could not confirm. Last verified against the live catalog on 2026-10-19,
237
+ * when upstream retired `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` and
238
+ * promoted the model previously served anonymously (internally "space bunny")
239
+ * as `MiniMax-M3.1-Flash`.
237
240
  */
238
241
  const FALLBACK_MODELS = [{
239
- id: "MiniMax-M2.7",
240
- name: "M2.7",
241
- contextWindow: 2e5,
242
+ id: "MiniMax-M3",
243
+ name: "M3",
244
+ contextWindow: 512e3,
242
245
  maxOutputTokens: 128e3,
243
246
  toolCall: true,
244
247
  reasoning: true,
245
- inputModalities: ["text"],
246
- attachment: false
248
+ inputModalities: [
249
+ "text",
250
+ "image",
251
+ "video"
252
+ ],
253
+ attachment: true
247
254
  }, {
248
- id: "MiniMax-M3",
249
- name: "M3",
255
+ id: "MiniMax-M3.1-Flash",
256
+ name: "M3.1-Flash",
250
257
  contextWindow: 512e3,
251
258
  maxOutputTokens: 128e3,
252
259
  toolCall: true,
@@ -487,6 +494,65 @@ function toPiModel(model, baseUrl, authorization) {
487
494
  };
488
495
  }
489
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
+ /**
490
556
  * Build the DSH adapter that serves the catalog.
491
557
  *
492
558
  * The model list is rebuilt on demand rather than snapshotted, which is what
@@ -543,12 +609,13 @@ function createMinimaxCodeAdapter(options) {
543
609
  return /* @__PURE__ */ new Map([[MINIMAXCODE_PROVIDER_ID, route]]);
544
610
  };
545
611
  let profiles = buildProfiles();
612
+ const inner = new PiAiAdapter({
613
+ profiles: () => profiles,
614
+ resolveApiKey: options.resolveApiKey,
615
+ auth: INERT_AUTH
616
+ });
546
617
  return {
547
- adapter: new PiAiAdapter({
548
- profiles: () => profiles,
549
- resolveApiKey: options.resolveApiKey,
550
- auth: INERT_AUTH
551
- }),
618
+ adapter: options.onUsage === void 0 ? inner : new MeteredAdapter(inner, options.onUsage),
552
619
  invalidate: () => {
553
620
  profiles = buildProfiles();
554
621
  }
@@ -579,7 +646,9 @@ function statusDocument(deps) {
579
646
  catalogSource: deps.catalogSource,
580
647
  ...deps.catalogError === void 0 ? {} : { catalogError: deps.catalogError },
581
648
  models: deps.models,
582
- refreshing: deps.refreshing
649
+ refreshing: deps.refreshing,
650
+ ...deps.credits === void 0 ? {} : { credits: deps.credits },
651
+ ...deps.usage === void 0 ? {} : { usage: deps.usage }
583
652
  };
584
653
  if (auth.state === "expired") return {
585
654
  ...base,
@@ -604,6 +673,453 @@ function isLoopback(host) {
604
673
  return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
605
674
  }
606
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
607
1123
  //#region src/index.ts
608
1124
  /** Loader entry name; also the id the bundle patch inserts. */
609
1125
  const name = "dsh-connect-minimaxcode";
@@ -617,6 +1133,13 @@ function pickRegion(preferred) {
617
1133
  /** How often the card may pull a fresh catalog. */
618
1134
  const CATALOG_TTL_MS = 3e5;
619
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
+ /**
620
1143
  * Compose the plugin.
621
1144
  *
622
1145
  * The credential is re-read on every request rather than cached at start: the
@@ -627,10 +1150,14 @@ const CATALOG_TTL_MS = 3e5;
627
1150
  */
628
1151
  function apply(ctx) {
629
1152
  const catalog = new MinimaxCatalog();
1153
+ const credits = new MinimaxCredits();
1154
+ const meter = new UsageMeter();
630
1155
  let region = "cn";
631
1156
  let credential;
632
1157
  let lastCatalogAtMs = 0;
1158
+ let lastCreditAtMs = 0;
633
1159
  let refreshing = false;
1160
+ let creditState = "unavailable";
634
1161
  const readNow = async () => {
635
1162
  const state = await readCredential();
636
1163
  if (state.state !== "signed-in") {
@@ -652,25 +1179,87 @@ function apply(ctx) {
652
1179
  refreshing = false;
653
1180
  }
654
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
+ };
655
1238
  const { adapter, invalidate } = createMinimaxCodeAdapter({
656
1239
  catalog,
657
1240
  region: () => region,
658
1241
  resolveApiKey: async () => (await readNow())?.token,
659
- bearerToken: () => bearerToken()
1242
+ bearerToken: () => bearerToken(),
1243
+ onUsage: (model, usage) => {
1244
+ meter.record(model, usage);
1245
+ refreshCredits();
1246
+ }
660
1247
  });
661
1248
  ctx.llm.registerAdapter(["minimaxcode"], adapter);
662
1249
  /** Build the document the card renders, without ever exposing the token. */
663
1250
  const buildStatus = async () => {
664
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();
665
1261
  return statusDocument({
666
- auth: state.state === "signed-in" ? {
667
- state: "signed-in",
668
- daysRemaining: Math.ceil(expiresInMs(state.credential.claims) / 864e5),
669
- expiry: expiryLevel(state.credential.claims)
670
- } : state.state === "expired" ? { state: "expired" } : {
671
- state: "signed-out",
672
- reason: state.reason
673
- },
1262
+ auth,
674
1263
  region,
675
1264
  catalogSource: catalog.source,
676
1265
  catalogError: catalog.error,
@@ -679,7 +1268,9 @@ function apply(ctx) {
679
1268
  name: modelName,
680
1269
  contextWindow
681
1270
  })),
682
- refreshing
1271
+ refreshing,
1272
+ credits: buildCreditStatus(),
1273
+ usage: buildUsageStatus()
683
1274
  });
684
1275
  };
685
1276
  (async () => {
@@ -712,4 +1303,4 @@ function apply(ctx) {
712
1303
  });
713
1304
  }
714
1305
  //#endregion
715
- 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 };