feat(glm): add team plan quota settings for glm-cn connections (#6351)

add GLM team plan quota settings (#6351). Tests 29/29; reconciled FormData with m365Tier; modal caps bumped for own growth. Integrated into release/v3.8.46.
This commit is contained in:
hao3039032
2026-07-07 08:35:04 +08:00
committed by GitHub
parent e45e6c3e34
commit f6b4926138
12 changed files with 703 additions and 62 deletions

View File

@@ -31,6 +31,7 @@
### 🐛 Bug Fixes
- **feat(glm):** add **team-plan quota settings** for `glm-cn` connections — a dedicated `GlmTeamQuotaFields` form section (team quota id / limits) threaded through the Add/Edit connection modals, persisted via `providerSpecificData`, with the GLM usage service reading the team quota. Regression guards: `tests/unit/glm-team-quota.test.ts`, `provider-specific-data-schema.test.ts`. (thanks @hao3039032)
- **feat(providers):** add **TinyFish** web-fetch/search support — a `tinyfish-fetch` executor + `/v1/web/fetch` route + MCP web-fetch tool, registered as a specialty-media provider with request-validation and a search-provider catalog entry. Regression guards: `tests/unit/executor-tinyfish-fetch.test.ts`, `web-fetch-handler.test.ts`, `mcp-web-fetch-tool.test.ts`, `provider-validation-tinyfish.test.ts`. (thanks @dtybnrj)
- **fix(cli):** `omniroute launch-codex` now spawns `codex.cmd` through a shell on Windows (the npm `.cmd` shim is unresolvable by bare `spawn` → ENOENT), mirroring the qodercli Windows fix (#6263). Regression guard: `tests/unit/launch-codex-windows-spawn-6312.test.ts`. (thanks @swingtempo)
- **fix(codex):** isolate the **Spark** quota from the shared Codex quota and stabilize the quota UI ordering / hydration so per-scope limits render consistently. Regression guards: `tests/unit/codex-quota-selection-hydration.test.ts`, `provider-limits-ui.test.ts` + 3 more. (thanks @xz-dev)

View File

@@ -201,8 +201,8 @@
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 915,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1231,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 952,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1278,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
@@ -377,5 +377,6 @@
"_rebaseline_2026_07_05_6213_kiro_thinking_filesize": "PR #6213 own growth (kiro adaptive-thinking -> reasoning_content, +384): open-sse/translator/request/openai-to-kiro.ts 853->890 (+37 = additionalModelRequestFields builder for adaptive thinking: output_config.effort + thinking:{type:adaptive} + max_tokens, only when the request asked for thinking) and tests/unit/translator-openai-to-kiro.test.ts 1093->1234 (+141 = adaptive-thinking request/frame regression cases). The fast-path PR->release does NOT gate check:file-size on the merge, so this cohesive feature growth accumulated on the release tip (see the 2026-07-02 #5798 note for the same pattern). Superseded by the release captain's rebaseline-at-release.",
"_rebaseline_2026_07_05_6235_doubao_dola": "PR #6235 own growth: tests/unit/web-cookie-providers-new.test.ts 850->890 (+40 = doubao-web -> Dola global provider switch regression cases: new host/cookie-domain/token-source assertions for www.dola.com). Cohesive test growth alongside the provider switch; contributor backryun. Fast-path PR->release skips check:file-size, so this bump lands with the PR.",
"_rebaseline_2026_07_06_v3845_release_close": "Release v3.8.45 cycle-close rebaseline (captain, sess ce897453): 13 files grown by the cycle's merged fix/feature PRs (#6216 streaming fixes + request-logger UI grew RequestLoggerV2/chat/chatHelpers/auth/stream/response-sanitizer.test; #6251/#6253 dashboard UX grew combos page/modals/wizard/ComboDefaultsTab/ProxyRegistryManager/providerPageHelpers). Growth is legitimate merged-feature code, absorbed at release per Phase 0 drift policy; all remain frozen (cannot grow further).",
"_rebaseline_2026_07_06_6118_zed_oauthmodal": "PR #6118 own growth: OAuthModal.tsx 989->993 (+4 = Zed hosted native-app sign-in modal branch). Cohesive UI growth for the zed-hosted OAuth provider; not extractable. The prior 6118 comment set the note but left the frozen value at 989."
"_rebaseline_2026_07_06_6118_zed_oauthmodal": "PR #6118 own growth: OAuthModal.tsx 989->993 (+4 = Zed hosted native-app sign-in modal branch). Cohesive UI growth for the zed-hosted OAuth provider; not extractable. The prior 6118 comment set the note but left the frozen value at 989.",
"_rebaseline_2026_07_06_6351_glm_team_quota": "PR #6351 own growth (GLM team-plan quota fields threaded through the connection modals; new GlmTeamQuotaFields.tsx extracted): AddApiKeyModal.tsx ->951 (+9), EditConnectionModal.tsx ->1277 (+18). Absorbs the pre-existing session base-red on these frozen modals; release captain rebaseline-at-release supersedes."
}

View File

@@ -209,6 +209,79 @@ export function getGlmQuotaUrl(providerSpecificData: unknown): string {
return GLM_QUOTA_URLS[getGlmApiRegion(providerSpecificData)];
}
function getProviderSpecificString(data: JsonRecord, keys: readonly string[]): string | null {
for (const key of keys) {
const value = asString(data[key]);
if (value) return value;
}
return null;
}
export const GLM_TEAM_QUOTA_ORGANIZATION_KEYS = [
"glmOrganizationId",
"bigmodelOrganization",
"glmOrganization",
] as const;
export const GLM_TEAM_QUOTA_PROJECT_KEYS = [
"glmProjectId",
"bigmodelProject",
"glmProject",
] as const;
export const GLM_TEAM_QUOTA_ALIAS_KEYS = [
"bigmodelOrganization",
"glmOrganization",
"bigmodelProject",
"glmProject",
] as const;
export type GlmTeamQuotaConfig =
| { state: "none" }
| { state: "configured"; organizationId: string; projectId: string }
| { state: "incomplete"; missing: "glmOrganizationId" | "glmProjectId" };
export function getGlmTeamQuotaConfig(providerSpecificData: unknown): GlmTeamQuotaConfig {
const data = asRecord(providerSpecificData);
const organizationId = getProviderSpecificString(data, GLM_TEAM_QUOTA_ORGANIZATION_KEYS);
const projectId = getProviderSpecificString(data, GLM_TEAM_QUOTA_PROJECT_KEYS);
if (!organizationId && !projectId) return { state: "none" };
if (organizationId && projectId) {
return { state: "configured", organizationId, projectId };
}
return {
state: "incomplete",
missing: organizationId ? "glmProjectId" : "glmOrganizationId",
};
}
export function buildGlmQuotaFetch(
apiKey: string,
providerSpecificData?: unknown
): { url: string; headers: Record<string, string> } {
const teamConfig = getGlmTeamQuotaConfig(providerSpecificData);
const baseUrl = getGlmQuotaUrl(providerSpecificData);
const url =
teamConfig.state === "configured"
? baseUrl.includes("?")
? `${baseUrl}&type=2`
: `${baseUrl}?type=2`
: baseUrl;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
};
if (teamConfig.state === "configured") {
headers["bigmodel-organization"] = teamConfig.organizationId;
headers["bigmodel-project"] = teamConfig.projectId;
}
return { url, headers };
}
function stripKnownGlmEndpointSuffix(baseUrl: string): { base: string; suffix: string } {
const parts = splitUrlQueryAndHash(baseUrl);
let base = parts.base;

View File

@@ -11,7 +11,7 @@
import { toNumber, toRecord, toTitleCase, toPercentage } from "./scalars.ts";
import { type UsageQuota } from "./quota.ts";
import { getGlmQuotaUrl } from "../../config/glmProvider.ts";
import { buildGlmQuotaFetch, getGlmTeamQuotaConfig } from "../../config/glmProvider.ts";
type JsonRecord = Record<string, unknown>;
@@ -84,19 +84,45 @@ export function glmMonthlyRemainingPercentage(total: number, remaining: number):
return Math.max(0, Math.min(100, Math.round(remaining)));
}
function glmTeamQuotaIncompleteMessage(missing: "glmOrganizationId" | "glmProjectId"): string {
const fieldLabel = missing === "glmOrganizationId" ? "Organization ID" : "Project ID";
return `GLM team plan quota requires both Organization ID and Project ID. Add the missing ${fieldLabel} on this connection.`;
}
function glmTeamQuotaHintMessage(): string {
return "This API key appears to be a GLM Coding team plan. Add Organization ID and Project ID on this connection to view usage.";
}
function sanitizeGlmQuotaErrorMessage(msg: unknown): string {
if (typeof msg !== "string" || !msg.trim()) {
return "Unable to fetch GLM quota.";
}
return msg.trim();
}
function shouldSuggestGlmTeamQuota(
teamConfig: ReturnType<typeof getGlmTeamQuotaConfig>,
_providerSpecificData: unknown,
_json: JsonRecord,
upstreamMsg: string
): boolean {
if (teamConfig.state !== "none") return false;
return /coding\s*plan|不存在.*plan|没有.*coding|团队|编码套餐/i.test(upstreamMsg);
}
export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string, unknown>) {
if (!apiKey) {
return { message: "API key not available. Add a coding plan API key to view usage." };
}
const quotaUrl = getGlmQuotaUrl(providerSpecificData);
const teamConfig = getGlmTeamQuotaConfig(providerSpecificData);
if (teamConfig.state === "incomplete") {
return { message: glmTeamQuotaIncompleteMessage(teamConfig.missing) };
}
const res = await fetch(quotaUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
const { url: quotaUrl, headers } = buildGlmQuotaFetch(apiKey, providerSpecificData);
const res = await fetch(quotaUrl, { headers });
if (!res.ok) {
if (res.status === 401) throw new Error("Invalid API key");
@@ -104,10 +130,21 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<
}
const json = await res.json();
if (toNumber(json.code, 200) === 401 || json.success === false) {
if (!json || typeof json !== "object") {
throw new Error("Invalid JSON response from GLM quota API");
}
if (toNumber(json.code, 200) === 401) {
throw new Error("Invalid API key");
}
if (json.success === false) {
const upstreamMsg = sanitizeGlmQuotaErrorMessage(json.msg ?? json.message);
if (shouldSuggestGlmTeamQuota(teamConfig, providerSpecificData, toRecord(json), upstreamMsg)) {
return { message: glmTeamQuotaHintMessage() };
}
return { message: upstreamMsg };
}
const data = toRecord(json.data);
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
const quotas: Record<string, UsageQuota> = {};

View File

@@ -34,6 +34,7 @@ import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
export interface AddApiKeyModalProps {
isOpen: boolean;
provider?: string;
@@ -128,6 +129,7 @@ export default function AddApiKeyModal({
customUserAgent: "",
accountId: "",
consoleApiKey: "",
...EMPTY_GLM_TEAM_QUOTA_FIELDS,
...EMPTY_QUOTA_SCRAPING_FIELDS,
ccCompatibleContext1m: false,
ccCompatibleRedactThinking: false,
@@ -900,19 +902,26 @@ export default function AddApiKeyModal({
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
<div className="flex flex-col gap-3">
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
<GlmTeamQuotaFields
values={formData}
onChange={(patch) => setFormData({ ...formData, ...patch })}
t={t}
/>
</div>
)}
<div className="flex gap-2">

View File

@@ -57,6 +57,7 @@ import {
type M365TierValue,
} from "./m365Tier";
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
export interface EditConnectionModalConnection {
id?: string;
@@ -124,6 +125,7 @@ export default function EditConnectionModal({
codexServiceTier: "default" as CodexServiceTier,
codexOpenaiStoreEnabled: false,
consoleApiKey: "",
...EMPTY_GLM_TEAM_QUOTA_FIELDS,
...EMPTY_QUOTA_SCRAPING_FIELDS,
ccCompatibleContext1m: false,
ccCompatibleRedactThinking: false,
@@ -173,8 +175,7 @@ export default function EditConnectionModal({
typeof connection?.providerSpecificData?.baseUrl === "string" &&
connection.providerSpecificData.baseUrl.trim().length > 0
);
const usesBaseUrl =
isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride);
const usesBaseUrl = isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride);
const defaultBaseUrl = getProviderBaseUrlDefault(provider);
const isVertex = provider === "vertex" || provider === "vertex-partner";
const isBedrock = provider === "bedrock";
@@ -245,6 +246,14 @@ export default function EditConnectionModal({
stringField(connection.providerSpecificData?.opencodeGoWorkspaceId) ||
stringField(connection.providerSpecificData?.openCodeGoWorkspaceId) ||
stringField(connection.providerSpecificData?.workspaceId);
const existingGlmOrganizationId =
stringField(connection.providerSpecificData?.glmOrganizationId) ||
stringField(connection.providerSpecificData?.bigmodelOrganization) ||
stringField(connection.providerSpecificData?.glmOrganization);
const existingGlmProjectId =
stringField(connection.providerSpecificData?.glmProjectId) ||
stringField(connection.providerSpecificData?.bigmodelProject) ||
stringField(connection.providerSpecificData?.glmProject);
const codexRequestDefaults = getCodexRequestDefaults(connection.providerSpecificData);
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
connection.providerSpecificData
@@ -296,6 +305,8 @@ export default function EditConnectionModal({
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
consoleApiKey: existingConsoleApiKey,
glmOrganizationId: existingGlmOrganizationId,
glmProjectId: existingGlmProjectId,
opencodeGoWorkspaceId: existingOpenCodeGoWorkspaceId,
opencodeGoAuthCookie: "",
ollamaCloudUsageCookie: "",
@@ -1036,19 +1047,26 @@ export default function EditConnectionModal({
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
<div className="flex flex-col gap-3">
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
<GlmTeamQuotaFields
values={formData}
onChange={(patch) => setFormData({ ...formData, ...patch })}
t={t}
/>
</div>
)}

View File

@@ -0,0 +1,52 @@
"use client";
import { Input } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
import type { GlmTeamQuotaFieldValues } from "./glmTeamQuotaProviderData";
export type { GlmTeamQuotaFieldValues };
export {
EMPTY_GLM_TEAM_QUOTA_FIELDS,
assignGlmTeamQuotaProviderData,
} from "./glmTeamQuotaProviderData";
type GlmTeamQuotaFieldsProps = {
values: GlmTeamQuotaFieldValues;
onChange: (patch: Partial<GlmTeamQuotaFieldValues>) => void;
t: ProviderMessageTranslator;
};
export default function GlmTeamQuotaFields({ values, onChange, t }: GlmTeamQuotaFieldsProps) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
<Input
label={providerText(t, "glmOrganizationIdLabel", "GLM team organization ID")}
name="glmOrganizationId"
value={values.glmOrganizationId}
onChange={(e) => onChange({ glmOrganizationId: e.target.value })}
placeholder="org-xxxxxx"
hint={providerText(
t,
"glmOrganizationIdHint",
"Optional for team plan quota. Copy bigmodel-organization from the team usage page network request."
)}
autoComplete="off"
spellCheck={false}
/>
<Input
label={providerText(t, "glmProjectIdLabel", "GLM team project ID")}
name="glmProjectId"
value={values.glmProjectId}
onChange={(e) => onChange({ glmProjectId: e.target.value })}
placeholder="proj_xxxxxx"
hint={providerText(
t,
"glmProjectIdHint",
"Optional for team plan quota. Copy bigmodel-project from the team usage page network request."
)}
autoComplete="off"
spellCheck={false}
/>
</div>
);
}

View File

@@ -8,25 +8,30 @@ import {
assignQuotaScrapingProviderData,
type QuotaScrapingFieldValues,
} from "./QuotaScrapingFields";
import {
assignGlmTeamQuotaProviderData,
type GlmTeamQuotaFieldValues,
} from "./glmTeamQuotaProviderData";
type FormData = QuotaScrapingFieldValues & {
accountId: string;
apiRegion: string;
ccCompatibleContext1m: boolean;
ccCompatibleRedactThinking: boolean;
ccCompatibleSummarizeThinking: boolean;
consoleApiKey: string;
customUserAgent: string;
cx: string;
excludedModels: string;
importFreeModelsOnly: boolean;
m365Tier?: M365TierValue;
passthroughModels: boolean;
region: string;
routingTags: string;
tag?: string;
validationModelId?: string;
};
type FormData = QuotaScrapingFieldValues &
GlmTeamQuotaFieldValues & {
accountId: string;
apiRegion: string;
ccCompatibleContext1m: boolean;
ccCompatibleRedactThinking: boolean;
ccCompatibleSummarizeThinking: boolean;
consoleApiKey: string;
customUserAgent: string;
cx: string;
excludedModels: string;
importFreeModelsOnly: boolean;
m365Tier?: M365TierValue;
passthroughModels: boolean;
region: string;
routingTags: string;
tag?: string;
validationModelId?: string;
};
type ProviderSpecificData = Record<string, unknown>;
export function buildAddProviderSpecificData(options: {
@@ -73,8 +78,10 @@ export function buildAddProviderSpecificData(options: {
if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim();
if (usesBaseUrl) data.baseUrl = validatedBaseUrl;
else if (showsRegion) data.region = formData.region.trim() || defaultRegion;
else if (isGlm) data.apiRegion = formData.apiRegion;
else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim();
else if (isGlm) {
data.apiRegion = formData.apiRegion;
assignGlmTeamQuotaProviderData(isGlm, formData, data);
} else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim();
if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData);
return Object.keys(data).length > 0 ? data : undefined;
}
@@ -114,8 +121,10 @@ export function assignEditApiKeyProviderSpecificData(options: {
if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined;
if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl;
else if (o.showsRegion) o.target.region = o.formData.region.trim() || o.defaultRegion;
else if (o.isGlm) o.target.apiRegion = o.formData.apiRegion;
else if (o.isCloudflare && o.formData.accountId.trim()) {
else if (o.isGlm) {
o.target.apiRegion = o.formData.apiRegion;
assignGlmTeamQuotaProviderData(o.isGlm, o.formData, o.target);
} else if (o.isCloudflare && o.formData.accountId.trim()) {
o.target.accountId = o.formData.accountId.trim();
}
if (o.isAntigravityFamily) o.target.projectId = o.trimmedCloudCodeProjectId || null;

View File

@@ -0,0 +1,38 @@
import { GLM_TEAM_QUOTA_ALIAS_KEYS } from "@omniroute/open-sse/config/glmProvider.ts";
export type GlmTeamQuotaFieldValues = {
glmOrganizationId: string;
glmProjectId: string;
};
export const EMPTY_GLM_TEAM_QUOTA_FIELDS: GlmTeamQuotaFieldValues = {
glmOrganizationId: "",
glmProjectId: "",
};
export function assignGlmTeamQuotaProviderData(
isGlm: boolean,
values: GlmTeamQuotaFieldValues,
target: Record<string, unknown>
) {
if (!isGlm) return;
for (const key of GLM_TEAM_QUOTA_ALIAS_KEYS) {
delete target[key];
}
const organizationId = (values.glmOrganizationId || "").trim();
const projectId = (values.glmProjectId || "").trim();
if (organizationId) {
target.glmOrganizationId = organizationId;
} else {
delete target.glmOrganizationId;
}
if (projectId) {
target.glmProjectId = projectId;
} else {
delete target.glmProjectId;
}
}

View File

@@ -223,6 +223,50 @@ export function validateProviderSpecificData(
}
}
for (const key of [
"glmOrganizationId",
"bigmodelOrganization",
"glmOrganization",
"glmProjectId",
"bigmodelProject",
"glmProject",
] as const) {
const value = data[key];
if (value !== undefined && value !== null && typeof value !== "string") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `providerSpecificData.${key} must be a string`,
path: [key],
});
}
if (typeof value === "string" && value.length > 200) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `providerSpecificData.${key} must be at most 200 characters`,
path: [key],
});
}
}
const glmOrganizationId =
(typeof data.glmOrganizationId === "string" && data.glmOrganizationId.trim()) ||
(typeof data.bigmodelOrganization === "string" && data.bigmodelOrganization.trim()) ||
(typeof data.glmOrganization === "string" && data.glmOrganization.trim()) ||
"";
const glmProjectId =
(typeof data.glmProjectId === "string" && data.glmProjectId.trim()) ||
(typeof data.bigmodelProject === "string" && data.bigmodelProject.trim()) ||
(typeof data.glmProject === "string" && data.glmProject.trim()) ||
"";
if (Boolean(glmOrganizationId) !== Boolean(glmProjectId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"providerSpecificData.glmOrganizationId and glmProjectId must both be set for GLM team plan quota",
path: glmOrganizationId ? ["glmProjectId"] : ["glmOrganizationId"],
});
}
const groupTag = data.tag;
if (
groupTag !== undefined &&

View File

@@ -0,0 +1,319 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildGlmQuotaFetch, getGlmTeamQuotaConfig } from "../../open-sse/config/glmProvider.ts";
import { getGlmUsage } from "../../open-sse/services/usage/glm.ts";
import { assignGlmTeamQuotaProviderData } from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/glmTeamQuotaProviderData.ts";
const TEAM_QUOTA_RESPONSE = {
code: 200,
msg: "操作成功",
data: {
limits: [
{
type: "TIME_LIMIT",
unit: 5,
number: 1,
usage: 1000,
currentValue: 43,
remaining: 957,
percentage: 4,
nextResetTime: 1784269055973,
usageDetails: [
{ modelCode: "search-prime", usage: 34 },
{ modelCode: "web-reader", usage: 9 },
{ modelCode: "zread", usage: 0 },
],
},
{
type: "TOKENS_LIMIT",
unit: 3,
number: 5,
percentage: 0,
},
{
type: "TOKENS_LIMIT",
unit: 6,
number: 1,
percentage: 87,
nextResetTime: 1783491455996,
},
],
level: "pro",
},
success: true,
};
describe("getGlmTeamQuotaConfig", () => {
it("returns none when org/project are missing", () => {
assert.deepEqual(getGlmTeamQuotaConfig({}), { state: "none" });
});
it("returns configured when both org and project are present", () => {
assert.deepEqual(
getGlmTeamQuotaConfig({
glmOrganizationId: "org-abc",
glmProjectId: "proj_xyz",
}),
{
state: "configured",
organizationId: "org-abc",
projectId: "proj_xyz",
}
);
});
it("accepts bigmodel* aliases", () => {
assert.deepEqual(
getGlmTeamQuotaConfig({
bigmodelOrganization: "org-alias",
bigmodelProject: "proj-alias",
}),
{
state: "configured",
organizationId: "org-alias",
projectId: "proj-alias",
}
);
});
it("returns incomplete when only one field is set", () => {
assert.deepEqual(getGlmTeamQuotaConfig({ glmOrganizationId: "org-only" }), {
state: "incomplete",
missing: "glmProjectId",
});
});
});
describe("buildGlmQuotaFetch", () => {
it("uses personal quota URL without team headers by default", () => {
const { url, headers } = buildGlmQuotaFetch("glm-key", { apiRegion: "china" });
assert.equal(url, "https://open.bigmodel.cn/api/monitor/usage/quota/limit");
assert.equal(headers.Authorization, "Bearer glm-key");
assert.equal(headers["bigmodel-organization"], undefined);
assert.equal(headers["bigmodel-project"], undefined);
assert.equal(url.includes("type=2"), false);
});
it("uses team quota URL and headers when org/project are configured", () => {
const { url, headers } = buildGlmQuotaFetch("glm-key", {
apiRegion: "china",
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
});
assert.equal(url, "https://open.bigmodel.cn/api/monitor/usage/quota/limit?type=2");
assert.equal(headers.Authorization, "Bearer glm-key");
assert.equal(headers["bigmodel-organization"], "org-team");
assert.equal(headers["bigmodel-project"], "proj_team");
});
it("uses international team quota URL when apiRegion is international", () => {
const { url } = buildGlmQuotaFetch("glm-key", {
apiRegion: "international",
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
});
assert.equal(url, "https://api.z.ai/api/monitor/usage/quota/limit?type=2");
});
});
describe("assignGlmTeamQuotaProviderData", () => {
it("writes canonical keys and strips legacy alias fields", () => {
const target: Record<string, unknown> = {
bigmodelOrganization: "org-alias",
bigmodelProject: "proj-alias",
glmOrganization: "org-legacy",
glmProject: "proj-legacy",
};
assignGlmTeamQuotaProviderData(
true,
{ glmOrganizationId: "org-canonical", glmProjectId: "proj-canonical" },
target
);
assert.equal(target.glmOrganizationId, "org-canonical");
assert.equal(target.glmProjectId, "proj-canonical");
assert.equal(target.bigmodelOrganization, undefined);
assert.equal(target.bigmodelProject, undefined);
assert.equal(target.glmOrganization, undefined);
assert.equal(target.glmProject, undefined);
});
it("treats missing form values as blank strings", () => {
const target: Record<string, unknown> = {
glmOrganizationId: "org-old",
glmProjectId: "proj-old",
};
assignGlmTeamQuotaProviderData(
true,
{
glmOrganizationId: undefined as unknown as string,
glmProjectId: null as unknown as string,
},
target
);
assert.equal(getGlmTeamQuotaConfig(target).state, "none");
assert.equal(target.glmOrganizationId, undefined);
assert.equal(target.glmProjectId, undefined);
});
it("clears all team quota keys when both form fields are blank", () => {
const target: Record<string, unknown> = {
glmOrganizationId: "org-old",
glmProjectId: "proj-old",
bigmodelOrganization: "org-alias",
bigmodelProject: "proj-alias",
};
assignGlmTeamQuotaProviderData(true, { glmOrganizationId: "", glmProjectId: "" }, target);
assert.equal(getGlmTeamQuotaConfig(target).state, "none");
assert.equal(target.glmOrganizationId, undefined);
assert.equal(target.glmProjectId, undefined);
assert.equal(target.bigmodelOrganization, undefined);
assert.equal(target.bigmodelProject, undefined);
});
});
describe("getGlmUsage team quota parsing", () => {
it("parses numeric percentage fields from team quota response", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
assert.match(String(url), /open\.bigmodel\.cn\/api\/monitor\/usage\/quota\/limit\?type=2/);
assert.equal(
(init as { headers: Record<string, string> }).headers["bigmodel-organization"],
"org-team"
);
assert.equal(
(init as { headers: Record<string, string> }).headers["bigmodel-project"],
"proj_team"
);
return new Response(JSON.stringify(TEAM_QUOTA_RESPONSE), { status: 200 });
};
try {
const usage = await getGlmUsage("glm-cn-key", {
apiRegion: "china",
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
});
assert.equal(usage.plan, "Pro");
assert.equal(usage.quotas.session.remaining, 100);
assert.equal(usage.quotas.weekly.remaining, 13);
assert.equal(usage.quotas.mcp_monthly.remaining, 957);
assert.equal(usage.quotas.mcp_monthly.remainingPercentage, 96);
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns a hint message for team keys missing org/project", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
code: 500,
msg: "当前用户不存在coding plan",
success: false,
}),
{ status: 200 }
);
try {
const usage = await getGlmUsage("glm-cn-key", { apiRegion: "china" });
assert.match(String(usage.message), /team plan/i);
assert.match(String(usage.message), /Organization ID and Project ID/i);
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns incomplete message when only organization is configured", async () => {
const usage = await getGlmUsage("glm-cn-key", {
apiRegion: "china",
glmOrganizationId: "org-only",
});
assert.match(String(usage.message), /Project ID/i);
});
it("returns upstream message when configured team quota request fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
code: 403,
msg: "project mismatch",
success: false,
}),
{ status: 200 }
);
try {
const usage = await getGlmUsage("glm-cn-key", {
apiRegion: "china",
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
});
assert.equal(usage.message, "project mismatch");
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns upstream message for personal-plan failures instead of throwing", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
code: 429,
msg: "rate limited",
success: false,
}),
{ status: 200 }
);
try {
const usage = await getGlmUsage("glm-key", { apiRegion: "international" });
assert.equal(usage.message, "rate limited");
} finally {
globalThis.fetch = originalFetch;
}
});
it("throws when quota API returns non-object JSON", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("null", { status: 200 });
try {
await assert.rejects(
() => getGlmUsage("glm-cn-key", { apiRegion: "china" }),
/Invalid JSON response from GLM quota API/
);
} finally {
globalThis.fetch = originalFetch;
}
});
it("suggests team quota fields for chinese team-plan error text", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
code: 500,
msg: "当前用户不存在coding plan",
success: false,
}),
{ status: 200 }
);
try {
const usage = await getGlmUsage("glm-cn-key", { apiRegion: "china" });
assert.match(String(usage.message), /team plan/i);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -219,3 +219,43 @@ test("provider schemas reject malformed quota scraping provider-specific values"
assert.equal(created.success, false);
assert.equal(updated.success, false);
});
test("provider schemas accept GLM team quota provider-specific strings", () => {
const created = createProviderSchema.safeParse({
provider: "glm-cn",
apiKey: "id.secret",
name: "GLM CN Team",
providerSpecificData: {
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
},
});
const updated = updateProviderConnectionSchema.safeParse({
providerSpecificData: {
glmOrganizationId: "org-team",
glmProjectId: "proj_team",
},
});
assert.equal(created.success, true);
assert.equal(updated.success, true);
});
test("provider schemas reject incomplete GLM team quota provider-specific values", () => {
const created = createProviderSchema.safeParse({
provider: "glm-cn",
apiKey: "id.secret",
name: "GLM CN Team",
providerSpecificData: {
glmOrganizationId: "org-only",
},
});
const updated = updateProviderConnectionSchema.safeParse({
providerSpecificData: {
glmProjectId: 123,
},
});
assert.equal(created.success, false);
assert.equal(updated.success, false);
});