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/README.md +107 -39
- package/lib/client.js +199 -0
- package/lib/index.d.ts +279 -4
- package/lib/index.js +618 -27
- package/package.json +16 -5
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-
|
|
240
|
-
name: "
|
|
241
|
-
contextWindow:
|
|
242
|
+
id: "MiniMax-M3",
|
|
243
|
+
name: "M3",
|
|
244
|
+
contextWindow: 512e3,
|
|
242
245
|
maxOutputTokens: 128e3,
|
|
243
246
|
toolCall: true,
|
|
244
247
|
reasoning: true,
|
|
245
|
-
inputModalities: [
|
|
246
|
-
|
|
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
|
|
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
|
|
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 };
|