@acarmisc/backstage-plugin-litellm-backend 0.2.1 → 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/config.d.ts +29 -0
- package/dist/bridge.d.ts +69 -0
- package/dist/bridge.js +144 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +70 -3
- package/dist/index.cjs.js +1623 -14
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/router.d.ts +6 -0
- package/dist/router.js +75 -1
- package/package.json +6 -4
package/config.d.ts
CHANGED
|
@@ -97,5 +97,34 @@ export interface Config {
|
|
|
97
97
|
metadata?: Record<string, string>;
|
|
98
98
|
}>;
|
|
99
99
|
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* CLI bridge — exposes /api/litellm/bridge/* for CLI clients (Abby) that
|
|
103
|
+
* authenticate with a Keycloak access token (JWKS-verified). Lets them
|
|
104
|
+
* list/mint virtual keys without holding the master key. Disabled by
|
|
105
|
+
* default; enable explicitly when the CLI is in use.
|
|
106
|
+
*/
|
|
107
|
+
bridge?: {
|
|
108
|
+
/**
|
|
109
|
+
* When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
|
|
110
|
+
* routes and verify caller JWTs against the Keycloak realm JWKS.
|
|
111
|
+
* @default false
|
|
112
|
+
*/
|
|
113
|
+
enabled?: boolean;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Keycloak realm issuer used to fetch JWKS and verify the token issuer,
|
|
117
|
+
* e.g. https://auth.ces.abssrv.it/realms/solution-innovation.
|
|
118
|
+
* Required when enabled.
|
|
119
|
+
*/
|
|
120
|
+
issuer?: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* OIDC public client the CLI uses (default "abby-cli"). The token's
|
|
124
|
+
* azp (or aud) must equal this.
|
|
125
|
+
* @default "abby-cli"
|
|
126
|
+
*/
|
|
127
|
+
clientId?: string;
|
|
128
|
+
};
|
|
100
129
|
};
|
|
101
130
|
}
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Config } from '@backstage/config';
|
|
2
|
+
import { LiteLLMClient } from './client';
|
|
3
|
+
import { GenerateKeyRequest, GenerateKeyResponse, ProvisioningDefaults, UserInfo, VirtualKey } from './types';
|
|
4
|
+
/** Claims extracted from a verified Keycloak access token. */
|
|
5
|
+
export interface BridgeClaims {
|
|
6
|
+
sub: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
preferred_username?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
/** Authorized party — Keycloak sets this to the client_id of the requester. */
|
|
11
|
+
azp?: string;
|
|
12
|
+
aud?: string | string[];
|
|
13
|
+
}
|
|
14
|
+
/** A token verifier pluggable for tests. */
|
|
15
|
+
export interface TokenVerifier {
|
|
16
|
+
verify(token: string): Promise<BridgeClaims>;
|
|
17
|
+
}
|
|
18
|
+
export interface BridgeConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** Keycloak realm issuer, e.g. https://auth.ces.abssrv.it/realms/solution-innovation. */
|
|
21
|
+
issuer?: string;
|
|
22
|
+
/** OIDC public client the CLI uses; checked against azp / aud. */
|
|
23
|
+
clientId: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function readBridgeConfig(config: Config): BridgeConfig;
|
|
26
|
+
/** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
|
|
27
|
+
export declare class BridgeConfigError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** Thrown when a presented token fails verification → maps to HTTP 401. */
|
|
30
|
+
export declare class BridgeAuthError extends Error {
|
|
31
|
+
readonly status = 401;
|
|
32
|
+
}
|
|
33
|
+
export interface KeycloakJWTVerifierOptions {
|
|
34
|
+
issuer: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Verifies a Keycloak access token against the realm JWKS and ensures it was
|
|
39
|
+
* issued for {@link clientId} (via azp, falling back to aud). Uses jose's
|
|
40
|
+
* remote JWKS client (cached, with cooldown on errors).
|
|
41
|
+
*/
|
|
42
|
+
export declare class KeycloakJWTVerifier implements TokenVerifier {
|
|
43
|
+
private readonly issuer;
|
|
44
|
+
private readonly clientId;
|
|
45
|
+
private readonly jwks;
|
|
46
|
+
constructor(opts: KeycloakJWTVerifierOptions);
|
|
47
|
+
verify(token: string): Promise<BridgeClaims>;
|
|
48
|
+
}
|
|
49
|
+
/** Builds the default verifier from config, or throws BridgeConfigError. */
|
|
50
|
+
export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
|
|
51
|
+
/** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
|
|
52
|
+
export declare function resolveBridgeUserId(claims: BridgeClaims): string;
|
|
53
|
+
/**
|
|
54
|
+
* Ensures a LiteLLM user exists for the verified identity. If the user is
|
|
55
|
+
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
56
|
+
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
57
|
+
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
58
|
+
*/
|
|
59
|
+
export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
60
|
+
info: (...args: unknown[]) => void;
|
|
61
|
+
}): Promise<UserInfo>;
|
|
62
|
+
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
|
63
|
+
export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
64
|
+
info: (...args: unknown[]) => void;
|
|
65
|
+
}): Promise<VirtualKey[]>;
|
|
66
|
+
/** Mints a new virtual key for the caller (provisioning the user first if needed). */
|
|
67
|
+
export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
68
|
+
info: (...args: unknown[]) => void;
|
|
69
|
+
}, request: Partial<GenerateKeyRequest>): Promise<GenerateKeyResponse>;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeycloakJWTVerifier = exports.BridgeAuthError = exports.BridgeConfigError = void 0;
|
|
4
|
+
exports.readBridgeConfig = readBridgeConfig;
|
|
5
|
+
exports.newDefaultVerifier = newDefaultVerifier;
|
|
6
|
+
exports.resolveBridgeUserId = resolveBridgeUserId;
|
|
7
|
+
exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
|
|
8
|
+
exports.bridgeListKeys = bridgeListKeys;
|
|
9
|
+
exports.bridgeGenerateKey = bridgeGenerateKey;
|
|
10
|
+
/**
|
|
11
|
+
* Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
|
|
12
|
+
* holding the LiteLLM master key.
|
|
13
|
+
*
|
|
14
|
+
* Trust model: the Backstage backend holds the master key (as it already does
|
|
15
|
+
* for the UI). A CLI authenticates with its Keycloak access token (the same
|
|
16
|
+
* Keycloak realm Backstage uses). This module verifies that JWT against the
|
|
17
|
+
* realm JWKS, resolves the caller to a LiteLLM user_id, ensures that user
|
|
18
|
+
* exists (provisioning from claims if enabled), and then lists/generates keys
|
|
19
|
+
* via the existing master-key-authed {@link LiteLLMClient}.
|
|
20
|
+
*
|
|
21
|
+
* Unlike the UI endpoints in router.ts, the bridge routes do NOT call
|
|
22
|
+
* Backstage's `auth.authenticate` (which expects a Backstage-issued token);
|
|
23
|
+
* they verify the raw Keycloak JWT themselves.
|
|
24
|
+
*/
|
|
25
|
+
const jose_1 = require("jose");
|
|
26
|
+
const provisioning_1 = require("./provisioning");
|
|
27
|
+
function readBridgeConfig(config) {
|
|
28
|
+
const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
|
|
29
|
+
const issuer = config.getOptionalString('litellm.bridge.issuer');
|
|
30
|
+
const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
|
|
31
|
+
return { enabled, issuer, clientId };
|
|
32
|
+
}
|
|
33
|
+
/** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
|
|
34
|
+
class BridgeConfigError extends Error {
|
|
35
|
+
}
|
|
36
|
+
exports.BridgeConfigError = BridgeConfigError;
|
|
37
|
+
/** Thrown when a presented token fails verification → maps to HTTP 401. */
|
|
38
|
+
class BridgeAuthError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super(...arguments);
|
|
41
|
+
this.status = 401;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.BridgeAuthError = BridgeAuthError;
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a Keycloak access token against the realm JWKS and ensures it was
|
|
47
|
+
* issued for {@link clientId} (via azp, falling back to aud). Uses jose's
|
|
48
|
+
* remote JWKS client (cached, with cooldown on errors).
|
|
49
|
+
*/
|
|
50
|
+
class KeycloakJWTVerifier {
|
|
51
|
+
constructor(opts) {
|
|
52
|
+
this.issuer = opts.issuer.replace(/\/$/, '');
|
|
53
|
+
this.clientId = opts.clientId;
|
|
54
|
+
this.jwks = (0, jose_1.createRemoteJWKSet)(new URL(`${this.issuer}/protocol/openid-connect/certs`));
|
|
55
|
+
}
|
|
56
|
+
async verify(token) {
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
const result = await (0, jose_1.jwtVerify)(token, this.jwks, {
|
|
60
|
+
issuer: this.issuer,
|
|
61
|
+
});
|
|
62
|
+
payload = result.payload;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// Expired, bad signature, wrong issuer, malformed, JWKS unreachable, etc.
|
|
66
|
+
throw new BridgeAuthError(`invalid keycloak token: ${err.message ?? err}`);
|
|
67
|
+
}
|
|
68
|
+
const azp = payload.azp;
|
|
69
|
+
const aud = payload.aud;
|
|
70
|
+
const audMatches = Array.isArray(aud)
|
|
71
|
+
? aud.includes(this.clientId)
|
|
72
|
+
: aud === this.clientId;
|
|
73
|
+
if (azp !== this.clientId && !audMatches) {
|
|
74
|
+
throw new BridgeAuthError(`token not issued for client "${this.clientId}" (azp=${azp ?? 'none'})`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
sub: payload.sub ?? '',
|
|
78
|
+
email: payload.email,
|
|
79
|
+
preferred_username: payload
|
|
80
|
+
.preferred_username,
|
|
81
|
+
name: payload.name,
|
|
82
|
+
azp,
|
|
83
|
+
aud,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.KeycloakJWTVerifier = KeycloakJWTVerifier;
|
|
88
|
+
/** Builds the default verifier from config, or throws BridgeConfigError. */
|
|
89
|
+
function newDefaultVerifier(cfg) {
|
|
90
|
+
if (!cfg.issuer) {
|
|
91
|
+
throw new BridgeConfigError('litellm.bridge.issuer is required when litellm.bridge.enabled is true ' +
|
|
92
|
+
'(e.g. https://auth.ces.abssrv.it/realms/solution-innovation)');
|
|
93
|
+
}
|
|
94
|
+
return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
|
|
95
|
+
}
|
|
96
|
+
/** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
|
|
97
|
+
function resolveBridgeUserId(claims) {
|
|
98
|
+
return claims.email ?? claims.preferred_username ?? claims.sub;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ensures a LiteLLM user exists for the verified identity. If the user is
|
|
102
|
+
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
103
|
+
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
104
|
+
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
105
|
+
*/
|
|
106
|
+
async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger) {
|
|
107
|
+
const userId = resolveBridgeUserId(claims);
|
|
108
|
+
const existing = await client.getUserInfo(userId);
|
|
109
|
+
if (existing)
|
|
110
|
+
return existing;
|
|
111
|
+
if (!provisioningEnabled) {
|
|
112
|
+
throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.', false);
|
|
113
|
+
}
|
|
114
|
+
const profile = {
|
|
115
|
+
email: claims.email ?? claims.preferred_username,
|
|
116
|
+
displayName: claims.name ?? claims.preferred_username,
|
|
117
|
+
};
|
|
118
|
+
const created = await (0, provisioning_1.provisionUser)(client, userId, provisioningDefaults, profile, undefined, logger);
|
|
119
|
+
if (!created) {
|
|
120
|
+
throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'Provisioning attempted but returned no user — check LiteLLM logs', true, 500);
|
|
121
|
+
}
|
|
122
|
+
return created;
|
|
123
|
+
}
|
|
124
|
+
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
|
125
|
+
async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger) {
|
|
126
|
+
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
|
|
127
|
+
return client.listKeys(resolveBridgeUserId(claims));
|
|
128
|
+
}
|
|
129
|
+
/** Mints a new virtual key for the caller (provisioning the user first if needed). */
|
|
130
|
+
async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request) {
|
|
131
|
+
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
|
|
132
|
+
const userId = resolveBridgeUserId(claims);
|
|
133
|
+
const enriched = {
|
|
134
|
+
...request,
|
|
135
|
+
user_id: userId,
|
|
136
|
+
metadata: {
|
|
137
|
+
...(request.metadata ?? {}),
|
|
138
|
+
created_via: 'abby-cli',
|
|
139
|
+
created_by: userId,
|
|
140
|
+
created_at_iso: new Date().toISOString(),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return client.generateKey(enriched);
|
|
144
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export declare class LiteLLMClient {
|
|
|
8
8
|
/**
|
|
9
9
|
* Returns null when the user is not found in LiteLLM (404).
|
|
10
10
|
* Throws on all other errors so callers know something went wrong.
|
|
11
|
+
*
|
|
12
|
+
* LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
|
|
13
|
+
* `teams` as an array of full team objects, not team_id strings. We flatten
|
|
14
|
+
* `user_info` onto the top level and reduce `teams` to a string[] of ids so
|
|
15
|
+
* the rest of the code can rely on the UserInfo contract.
|
|
11
16
|
*/
|
|
12
17
|
getUserInfo(userId?: string): Promise<UserInfo | null>;
|
|
13
18
|
createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
|
|
@@ -48,6 +53,16 @@ export declare class LiteLLMClient {
|
|
|
48
53
|
deleteKeys(request: DeleteKeyRequest): Promise<{
|
|
49
54
|
success: boolean;
|
|
50
55
|
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Returns the proxy's model catalogue normalised to the ModelInfo shape.
|
|
58
|
+
*
|
|
59
|
+
* Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
|
|
60
|
+
* and per-token costs. Falls back to OpenAI-compatible `/models` (which only
|
|
61
|
+
* returns `{id}`) so the dropdown still works on installs where `/model/info`
|
|
62
|
+
* isn't reachable. Without this normalisation the UI saw blank labels and
|
|
63
|
+
* a single "other" group because `/models` doesn't populate `model_name`
|
|
64
|
+
* or `mode`.
|
|
65
|
+
*/
|
|
51
66
|
listModels(): Promise<ModelInfo[]>;
|
|
52
67
|
getTeamInfo(teamId: string): Promise<TeamInfo>;
|
|
53
68
|
private emptyUsage;
|
package/dist/client.js
CHANGED
|
@@ -36,11 +36,34 @@ class LiteLLMClient {
|
|
|
36
36
|
/**
|
|
37
37
|
* Returns null when the user is not found in LiteLLM (404).
|
|
38
38
|
* Throws on all other errors so callers know something went wrong.
|
|
39
|
+
*
|
|
40
|
+
* LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
|
|
41
|
+
* `teams` as an array of full team objects, not team_id strings. We flatten
|
|
42
|
+
* `user_info` onto the top level and reduce `teams` to a string[] of ids so
|
|
43
|
+
* the rest of the code can rely on the UserInfo contract.
|
|
39
44
|
*/
|
|
40
45
|
async getUserInfo(userId) {
|
|
41
46
|
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
|
|
42
47
|
try {
|
|
43
|
-
|
|
48
|
+
const raw = await this.request(`/user/info${query}`);
|
|
49
|
+
const inner = raw?.user_info ?? {};
|
|
50
|
+
const teamIds = Array.isArray(raw?.teams)
|
|
51
|
+
? raw.teams
|
|
52
|
+
.map((t) => (typeof t === 'string' ? t : t?.team_id))
|
|
53
|
+
.filter((t) => typeof t === 'string')
|
|
54
|
+
: [];
|
|
55
|
+
return {
|
|
56
|
+
user_id: raw?.user_id ?? inner.user_id ?? userId ?? '',
|
|
57
|
+
user_email: inner.user_email ?? raw?.user_email,
|
|
58
|
+
email: inner.email ?? raw?.email,
|
|
59
|
+
teams: teamIds,
|
|
60
|
+
models: inner.models ?? raw?.models,
|
|
61
|
+
max_budget: inner.max_budget ?? raw?.max_budget,
|
|
62
|
+
spend: inner.spend ?? raw?.spend,
|
|
63
|
+
current_spend: inner.current_spend ?? raw?.current_spend,
|
|
64
|
+
soft_limit: inner.soft_limit ?? raw?.soft_limit,
|
|
65
|
+
hard_limit: inner.hard_limit ?? raw?.hard_limit,
|
|
66
|
+
};
|
|
44
67
|
}
|
|
45
68
|
catch (err) {
|
|
46
69
|
if (err.status === 404)
|
|
@@ -143,9 +166,53 @@ class LiteLLMClient {
|
|
|
143
166
|
body: JSON.stringify(request),
|
|
144
167
|
});
|
|
145
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Returns the proxy's model catalogue normalised to the ModelInfo shape.
|
|
171
|
+
*
|
|
172
|
+
* Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
|
|
173
|
+
* and per-token costs. Falls back to OpenAI-compatible `/models` (which only
|
|
174
|
+
* returns `{id}`) so the dropdown still works on installs where `/model/info`
|
|
175
|
+
* isn't reachable. Without this normalisation the UI saw blank labels and
|
|
176
|
+
* a single "other" group because `/models` doesn't populate `model_name`
|
|
177
|
+
* or `mode`.
|
|
178
|
+
*/
|
|
146
179
|
async listModels() {
|
|
147
|
-
|
|
148
|
-
|
|
180
|
+
try {
|
|
181
|
+
const response = await this.request('/model/info');
|
|
182
|
+
const data = Array.isArray(response?.data) ? response.data : [];
|
|
183
|
+
const normalised = data.map((m) => {
|
|
184
|
+
const info = m.model_info ?? {};
|
|
185
|
+
const params = m.litellm_params ?? {};
|
|
186
|
+
return {
|
|
187
|
+
model_name: m.model_name ?? params.model ?? info.id ?? '',
|
|
188
|
+
mode: info.mode ?? m.mode ?? 'chat',
|
|
189
|
+
supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
|
|
190
|
+
supports_vision: info.supports_vision ?? m.supports_vision,
|
|
191
|
+
input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
|
|
192
|
+
output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const filtered = normalised.filter(m => m.model_name);
|
|
196
|
+
if (filtered.length)
|
|
197
|
+
return filtered;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// fall through to /models
|
|
201
|
+
}
|
|
202
|
+
const fallback = await this.request('/models');
|
|
203
|
+
const data = Array.isArray(fallback)
|
|
204
|
+
? fallback
|
|
205
|
+
: Array.isArray(fallback?.data)
|
|
206
|
+
? fallback.data
|
|
207
|
+
: [];
|
|
208
|
+
return data
|
|
209
|
+
.map((m) => ({
|
|
210
|
+
model_name: m.model_name ?? m.id ?? '',
|
|
211
|
+
mode: m.mode ?? 'chat',
|
|
212
|
+
supports_function_calling: m.supports_function_calling,
|
|
213
|
+
supports_vision: m.supports_vision,
|
|
214
|
+
}))
|
|
215
|
+
.filter((m) => m.model_name);
|
|
149
216
|
}
|
|
150
217
|
async getTeamInfo(teamId) {
|
|
151
218
|
return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
|