mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
319
tests/unit/glm-team-quota.test.ts
Normal file
319
tests/unit/glm-team-quota.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user