diff --git a/CHANGELOG.md b/CHANGELOG.md index b84a32d6ba..8f4dd451a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 39fbd592d2..6ffd2b05e0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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." } diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 4f20473080..32bf03e8d2 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -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 } { + 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 = { + 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; diff --git a/open-sse/services/usage/glm.ts b/open-sse/services/usage/glm.ts index 26d011a97f..0e14a36aba 100644 --- a/open-sse/services/usage/glm.ts +++ b/open-sse/services/usage/glm.ts @@ -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; @@ -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, + _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) { 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 = {}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index ca4a7cfb9c..d46fbd51d2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -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 && ( -
- - -

{t("apiRegionHint")}

+
+
+ + +

{t("apiRegionHint")}

+
+ setFormData({ ...formData, ...patch })} + t={t} + />
)}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 0c96f5b4b3..cb79879226 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -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 && ( -
- - -

{t("apiRegionHint")}

+
+
+ + +

{t("apiRegionHint")}

+
+ setFormData({ ...formData, ...patch })} + t={t} + />
)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/GlmTeamQuotaFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/GlmTeamQuotaFields.tsx new file mode 100644 index 0000000000..e70b131163 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/GlmTeamQuotaFields.tsx @@ -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) => void; + t: ProviderMessageTranslator; +}; + +export default function GlmTeamQuotaFields({ values, onChange, t }: GlmTeamQuotaFieldsProps) { + return ( +
+ 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} + /> + 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} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts index 9eedd15c60..677b646df6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts @@ -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; 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; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/glmTeamQuotaProviderData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/glmTeamQuotaProviderData.ts new file mode 100644 index 0000000000..d147569f18 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/glmTeamQuotaProviderData.ts @@ -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 +) { + 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; + } +} diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 4a0afc6bd5..0087dd0e64 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -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 && diff --git a/tests/unit/glm-team-quota.test.ts b/tests/unit/glm-team-quota.test.ts new file mode 100644 index 0000000000..cdbede97dd --- /dev/null +++ b/tests/unit/glm-team-quota.test.ts @@ -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 = { + 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 = { + 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 = { + 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 }).headers["bigmodel-organization"], + "org-team" + ); + assert.equal( + (init as { headers: Record }).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; + } + }); +}); diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index 8715fe296f..85536f305d 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -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); +});