From 21fd0a94f85f400614fd3e7ea08d020c985c8f2b Mon Sep 17 00:00:00 2001 From: "Andrew B." <37745667+AndrianBalanescu@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:25:23 -0500 Subject: [PATCH] feat(alibaba): free-tier routing with live quota sync (#8893) * feat(alibaba): add free-tier routing with console quota and builtin allowlist Classify DashScope free vs paid models via console quota API, a hardcoded operator allowlist fallback, and per-connection drained tracking. Wire wildcard combo expansion, model refresh, combo exhaustion, and audit redaction for Alibaba console credentials. * fix(routing): reset forced connection pin and persist Alibaba free-tier drain Drop session affinity pins when a forced connection is excluded after 429, and record Alibaba free-tier exhaustion on upstream 403 so per-key drained lists stay accurate without blocking sibling keys. * fix(alibaba): prefer live quota sync over static free-tier allowlist Stop unioning the builtin text allowlist when a console quota snapshot exists, treat expired quotaValidityPeriod as not_capable, and add a dated JSON pack plus sync-alibaba-allowlist script for operator refresh without code edits. * docs(alibaba): document free-tier console path + allowlist env overrides Adds the 4 ALIBABA_FREE_TIER_*_FE_PATH / ALIBABA_FREE_TIER_ALLOWLIST_PATH env vars (referenced by alibabaFreeTierQuotaFetcher.ts and alibabaFreeTierAllowlist.ts) to .env.example and docs/reference/ENVIRONMENT.md so the env/docs contract check passes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(open-sse): split alibabaFreeTierQuotaFetcher.ts under file-size cap Extract pure parsing/classification/eligibility-filtering logic into alibabaFreeTierQuotaClassify.ts and shared types/primitives into alibabaFreeTierQuotaTypes.ts, leaving the HTTP/console-fetch flow in the original file. Public API is unchanged (re-exported), behavior is identical. Co-authored-by: AndrianBalanescu * fix: resolve typecheck errors in alibaba-free-tier routing --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: diegosouzapw Co-authored-by: AndrianBalanescu Co-authored-by: AndrianBalanescu --- .env.example | 13 + config/alibaba-free-tier-allowlist.json | 82 +++ docs/reference/ENVIRONMENT.md | 4 + open-sse/services/alibabaFreeTier.ts | 164 +++++ open-sse/services/alibabaFreeTierAllowlist.ts | 202 ++++++ open-sse/services/alibabaFreeTierDiscovery.ts | 359 ++++++++++ .../services/alibabaFreeTierQuotaClassify.ts | 648 ++++++++++++++++++ .../services/alibabaFreeTierQuotaFetcher.ts | 519 ++++++++++++++ .../services/alibabaFreeTierQuotaTypes.ts | 58 ++ open-sse/services/combo/providerWildcard.ts | 84 ++- open-sse/services/combo/targetExhaustion.ts | 12 + open-sse/services/combo/targetResolution.ts | 6 + open-sse/services/dashscopeTextModels.ts | 109 +++ package.json | 3 +- .../ops/alibabafreeaudio-quota.sample.json | 427 ++++++++++++ .../alibabafreemultimodal-quota.sample.json | 201 ++++++ .../ops/alibabafreevision-quota.sample.json | 573 ++++++++++++++++ scripts/ops/sync-alibaba-allowlist.mjs | 101 +++ .../components/modals/QuotaScrapingFields.tsx | 61 ++ .../models/discovery/providerModelsConfig.ts | 42 +- src/app/api/providers/[id]/models/route.ts | 49 +- src/lib/compliance/providerAudit.ts | 2 + src/shared/validation/providerSpecificData.ts | 2 + src/sse/handlers/chat.ts | 17 +- src/sse/services/auth.ts | 90 ++- src/sse/services/sessionAffinityPin.ts | 45 +- .../unit/alibaba-free-tier-allowlist.test.ts | 44 ++ .../unit/alibaba-free-tier-discovery.test.ts | 180 +++++ .../unit/alibaba-free-tier-exhaustion.test.ts | 113 +++ .../alibaba-free-tier-quota-fetcher.test.ts | 468 +++++++++++++ tests/unit/combo-provider-wildcard.test.ts | 98 +++ .../dashscope-text-models-discovery.test.ts | 47 ++ tests/unit/forced-connection-fallback.test.ts | 86 +++ 33 files changed, 4878 insertions(+), 31 deletions(-) create mode 100644 config/alibaba-free-tier-allowlist.json create mode 100644 open-sse/services/alibabaFreeTier.ts create mode 100644 open-sse/services/alibabaFreeTierAllowlist.ts create mode 100644 open-sse/services/alibabaFreeTierDiscovery.ts create mode 100644 open-sse/services/alibabaFreeTierQuotaClassify.ts create mode 100644 open-sse/services/alibabaFreeTierQuotaFetcher.ts create mode 100644 open-sse/services/alibabaFreeTierQuotaTypes.ts create mode 100644 open-sse/services/dashscopeTextModels.ts create mode 100644 scripts/ops/alibabafreeaudio-quota.sample.json create mode 100644 scripts/ops/alibabafreemultimodal-quota.sample.json create mode 100644 scripts/ops/alibabafreevision-quota.sample.json create mode 100644 scripts/ops/sync-alibaba-allowlist.mjs create mode 100644 tests/unit/alibaba-free-tier-allowlist.test.ts create mode 100644 tests/unit/alibaba-free-tier-discovery.test.ts create mode 100644 tests/unit/alibaba-free-tier-exhaustion.test.ts create mode 100644 tests/unit/alibaba-free-tier-quota-fetcher.test.ts create mode 100644 tests/unit/forced-connection-fallback.test.ts diff --git a/.env.example b/.env.example index 83a875e736..9dbc16233d 100644 --- a/.env.example +++ b/.env.example @@ -1978,6 +1978,19 @@ APP_LOG_TO_FILE=true # ALIBABA_CODING_PLAN_HOST= # ALIBABA_CODING_PLAN_QUOTA_URL= +# ── Alibaba Model Studio free-tier quota sync ── +# Console front-end path overrides for the free-tier quota fetcher. Used by: +# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher +# uses the production Bailian console paths. +# ALIBABA_FREE_TIER_VISION_FE_PATH= +# ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH= +# ALIBABA_FREE_TIER_AUDIO_FE_PATH= +# Optional path to a local JSON override for the built-in text free-tier +# allowlist. Used by: open-sse/services/alibabaFreeTierAllowlist.ts. When +# unset, the fetcher falls back to $DATA_DIR/alibaba-free-tier-allowlist.json +# then config/alibaba-free-tier-allowlist.json. +# ALIBABA_FREE_TIER_ALLOWLIST_PATH= + # ── Context window tuning ── # Tokens reserved for completion output when computing prompt budgets. # Used by: open-sse/services/contextManager.ts. Default: 1024. diff --git a/config/alibaba-free-tier-allowlist.json b/config/alibaba-free-tier-allowlist.json new file mode 100644 index 0000000000..9f71a4f9ba --- /dev/null +++ b/config/alibaba-free-tier-allowlist.json @@ -0,0 +1,82 @@ +{ + "asOf": "2026-07-28", + "validUntil": "2026-08-27", + "capable": [ + "deepseek-v3.2", + "deepseek-v4-pro", + "glm-5.2", + "qwen-flash", + "qwen-flash-2025-07-28", + "qwen-flash-character", + "qwen-max", + "qwen-mt-flash", + "qwen-mt-lite", + "qwen-mt-plus", + "qwen-mt-turbo", + "qwen-plus-2025-04-28", + "qwen-plus-2025-07-14", + "qwen-plus-2025-07-28", + "qwen-plus-2025-09-11", + "qwen-plus-character", + "qwen-plus-latest", + "qwen3-14b", + "qwen3-235b-a22b", + "qwen3-235b-a22b-instruct-2507", + "qwen3-235b-a22b-thinking-2507", + "qwen3-30b-a3b", + "qwen3-30b-a3b-instruct-2507", + "qwen3-30b-a3b-thinking-2507", + "qwen3-32b", + "qwen3-8b", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-flash", + "qwen3-coder-flash-2025-07-28", + "qwen3-coder-next", + "qwen3-coder-plus", + "qwen3-coder-plus-2025-07-22", + "qwen3-coder-plus-2025-09-23", + "qwen3-max", + "qwen3-max-2025-09-23", + "qwen3-max-2026-01-23", + "qwen3-max-preview", + "qwen3-next-80b-a3b-instruct", + "qwen3-next-80b-a3b-thinking", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-397b-a17b", + "qwen3.5-flash", + "qwen3.5-flash-2026-02-23", + "qwen3.5-plus", + "qwen3.5-plus-2026-02-15", + "qwen3.5-plus-2026-04-20", + "qwen3.6-27b", + "qwen3.6-35b-a3b", + "qwen3.6-flash", + "qwen3.6-flash-2026-04-16", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-plus-2026-04-02", + "qwen3.7-flash", + "qwen3.7-flash-2026-07-15", + "qwen3.7-max-2026-05-17", + "qwen3.7-max-2026-05-20", + "qwen3.7-max-2026-06-08", + "qwen3.7-max-preview", + "qwen3.7-plus-2026-05-26", + "qwq-plus" + ], + "noFreeTier": [ + "deepseek-v4-flash", + "glm-5.1", + "glm-5.2-fast-preview", + "kimi-k2.7-code", + "qwen-plus", + "qwen-plus-2025-01-25", + "qwen-plus-character-ja", + "qwen-turbo", + "qwen3.5-35b-a3b", + "qwen3.7-max", + "qwen3.7-plus" + ] +} diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3ecbc9e6e9..69186a1d2e 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1116,6 +1116,10 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | | `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | +| `ALIBABA_FREE_TIER_VISION_FE_PATH` | `/costing-balance/free-quota-image-video` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier vision/media quota. | +| `ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH` | `/costing-balance/free-quota-multimodal` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier multimodal quota. | +| `ALIBABA_FREE_TIER_AUDIO_FE_PATH` | `/costing-balance/free-quota-audio` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier audio quota. | +| `ALIBABA_FREE_TIER_ALLOWLIST_PATH` | _(unset)_ | `open-sse/services/alibabaFreeTierAllowlist.ts` | Optional path to a local JSON override for the built-in Alibaba free-tier text-model allowlist. Falls back to `$DATA_DIR/alibaba-free-tier-allowlist.json`, then `config/alibaba-free-tier-allowlist.json`. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `CONTEXT_KEEP_LATEST_IMAGES` | `2` | `open-sse/services/contextManager.ts` | How many of the newest inline images to keep when pruning older ones to fit the context window (#8560). | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | diff --git a/open-sse/services/alibabaFreeTier.ts b/open-sse/services/alibabaFreeTier.ts new file mode 100644 index 0000000000..1592d3d0d1 --- /dev/null +++ b/open-sse/services/alibabaFreeTier.ts @@ -0,0 +1,164 @@ +/** + * @file alibabaFreeTier.ts + * @description Alibaba Model Studio free-tier drain detection, billing mode, and persisted model lockouts. + * + * @changes + * - [2026-07-25] [Composer] - Delegate free-eligible filtering to probe-based discovery module + * - [2026-07-24] [Composer] - Add free-vs-paid billing mode and permanent free-tier model drain handling + */ + +import { isModelLocked, lockModel } from "./accountFallback.ts"; + +export type AlibabaBillingMode = "free" | "paid"; + +type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +/** ~10 years — free-tier drains are permanent until the operator clears connection state. */ +export const ALIBABA_FREE_DRAINED_LOCK_MS = 10 * 365 * 24 * 60 * 60 * 1000; + +const ALIBABA_FREE_QUOTA_EXHAUSTED_PATTERNS = [ + /\bfree quota has been exhausted\b/i, + /\bfree tier of the model has been exhausted\b/i, + /\buse free tier only\b/i, +] as const; + +const ALIBABA_MODEL_STUDIO_PROVIDER_IDS = new Set(["alibaba", "alibaba-cn", "ali"]); + +export { + filterAlibabaFreeEligibleModels, + isAlibabaFreeTierCapableModel, +} from "./alibabaFreeTierDiscovery.ts"; + +export { + filterAlibabaFreeVisionEligibleModels, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeAudioEligibleModels, + isAlibabaFreeTierVisionCapableModel, + isAlibabaFreeTierMultimodalCapableModel, + isAlibabaFreeTierAudioCapableModel, +} from "./alibabaFreeTierQuotaFetcher.ts"; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function isAlibabaModelStudioProvider(provider: string | null | undefined): boolean { + if (!provider) return false; + const normalized = provider.toLowerCase(); + return ALIBABA_MODEL_STUDIO_PROVIDER_IDS.has(normalized); +} + +export function isAlibabaFreeQuotaExhaustedError(errorText: string): boolean { + const text = String(errorText || ""); + if (!text) return false; + return ALIBABA_FREE_QUOTA_EXHAUSTED_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function getAlibabaBillingMode( + providerSpecificData: Record | null | undefined +): AlibabaBillingMode { + const raw = asRecord(providerSpecificData).alibabaBillingMode; + return raw === "free" ? "free" : "paid"; +} + +export function getAlibabaFreeDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + const raw = asRecord(providerSpecificData).alibabaFreeDrainedModels; + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function isAlibabaModelFreeDrained( + provider: string | null | undefined, + providerSpecificData: Record | null | undefined, + model: string | null | undefined +): boolean { + if (!isAlibabaModelStudioProvider(provider) || !model) return false; + return getAlibabaFreeDrainedModels(providerSpecificData).includes(model); +} + +export function mergeAlibabaFreeDrainedModels( + providerSpecificData: Record | null | undefined, + model: string +): Record { + const base = asRecord(providerSpecificData); + const existing = new Set(getAlibabaFreeDrainedModels(base)); + existing.add(model); + return { + ...base, + alibabaFreeDrainedModels: [...existing], + }; +} + +export function shouldUseLiveAlibabaFreeModelDiscovery( + providerSpecificData: Record | null | undefined +): boolean { + return getAlibabaBillingMode(providerSpecificData) === "free"; +} + +export function filterAlibabaFreeTierModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + return modelIds.filter((id) => !drained.has(id)); +} + +export function rehydrateAlibabaFreeDrainedModelLocks( + provider: string, + connectionId: string, + providerSpecificData: Record | null | undefined +): void { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(providerSpecificData) !== "free" + ) { + return; + } + for (const model of getAlibabaFreeDrainedModels(providerSpecificData)) { + if (!isModelLocked(provider, connectionId, model)) { + lockModel( + provider, + connectionId, + model, + "free_quota_exhausted", + ALIBABA_FREE_DRAINED_LOCK_MS + ); + } + } +} + +export async function isAlibabaFreeTierModelRoutable( + provider: string, + connectionId: string, + model: string +): Promise { + if (!isAlibabaModelStudioProvider(provider) || !model) return true; + if (isModelLocked(provider, connectionId, model)) return false; + try { + const { getProviderConnections } = await import("../../src/lib/db/providers.ts"); + const { buildAlibabaFreeTierFilterContext, isAlibabaFreeTierCapableModel } = + await import("./alibabaFreeTierDiscovery.ts"); + const connections = await getProviderConnections({ provider }); + const connection = connections.find((entry) => entry.id === connectionId); + if (!connection) return true; + const providerSpecificData = connection.providerSpecificData as Record; + rehydrateAlibabaFreeDrainedModelLocks(provider, connectionId, providerSpecificData); + if (getAlibabaBillingMode(providerSpecificData) === "free") { + const filterContext = buildAlibabaFreeTierFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ); + if (!isAlibabaFreeTierCapableModel(model, filterContext)) return false; + } + return !isAlibabaModelFreeDrained(provider, providerSpecificData, model); + } catch { + return !isModelLocked(provider, connectionId, model); + } +} diff --git a/open-sse/services/alibabaFreeTierAllowlist.ts b/open-sse/services/alibabaFreeTierAllowlist.ts new file mode 100644 index 0000000000..ef2a28c57f --- /dev/null +++ b/open-sse/services/alibabaFreeTierAllowlist.ts @@ -0,0 +1,202 @@ +/** + * @file alibabaFreeTierAllowlist.ts + * @description Offline fallback allowlist for Alibaba Model Studio free-tier text models. + * + * Prefer live console quota sync (`alibabaFreeTierQuotaFetcher.ts`). This pack is used + * only when no recent console snapshot exists on the connection. + * + * @changes + * - [2026-07-28] [Composer] - Load dated JSON pack from DATA_DIR/config with embedded fallback + * - [2026-07-25] [Composer] - Hardcode text free/paid model lists from operator console quota export + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type AlibabaFreeTierAllowlistPack = { + asOf: string; + validUntil?: string; + capable: string[]; + noFreeTier: string[]; +}; + +export const ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS = [ + "deepseek-v3.2", + "deepseek-v4-pro", + "glm-5.2", + "qwen-flash", + "qwen-flash-2025-07-28", + "qwen-flash-character", + "qwen-max", + "qwen-mt-flash", + "qwen-mt-lite", + "qwen-mt-plus", + "qwen-mt-turbo", + "qwen-plus-2025-04-28", + "qwen-plus-2025-07-14", + "qwen-plus-2025-07-28", + "qwen-plus-2025-09-11", + "qwen-plus-character", + "qwen-plus-latest", + "qwen3-14b", + "qwen3-235b-a22b", + "qwen3-235b-a22b-instruct-2507", + "qwen3-235b-a22b-thinking-2507", + "qwen3-30b-a3b", + "qwen3-30b-a3b-instruct-2507", + "qwen3-30b-a3b-thinking-2507", + "qwen3-32b", + "qwen3-8b", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-flash", + "qwen3-coder-flash-2025-07-28", + "qwen3-coder-next", + "qwen3-coder-plus", + "qwen3-coder-plus-2025-07-22", + "qwen3-coder-plus-2025-09-23", + "qwen3-max", + "qwen3-max-2025-09-23", + "qwen3-max-2026-01-23", + "qwen3-max-preview", + "qwen3-next-80b-a3b-instruct", + "qwen3-next-80b-a3b-thinking", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-397b-a17b", + "qwen3.5-flash", + "qwen3.5-flash-2026-02-23", + "qwen3.5-plus", + "qwen3.5-plus-2026-02-15", + "qwen3.5-plus-2026-04-20", + "qwen3.6-27b", + "qwen3.6-35b-a3b", + "qwen3.6-flash", + "qwen3.6-flash-2026-04-16", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-plus-2026-04-02", + "qwen3.7-flash", + "qwen3.7-flash-2026-07-15", + "qwen3.7-max-2026-05-17", + "qwen3.7-max-2026-05-20", + "qwen3.7-max-2026-06-08", + "qwen3.7-max-preview", + "qwen3.7-plus-2026-05-26", + "qwq-plus", +] as const; + +export const ALIBABA_NO_FREE_TIER_TEXT_MODELS = [ + "deepseek-v4-flash", + "glm-5.1", + "glm-5.2-fast-preview", + "kimi-k2.7-code", + "qwen-plus", + "qwen-plus-2025-01-25", + "qwen-plus-character-ja", + "qwen-turbo", + "qwen3.5-35b-a3b", + "qwen3.7-max", + "qwen3.7-plus", +] as const; + +const EMBEDDED_ALLOWLIST_PACK: AlibabaFreeTierAllowlistPack = { + asOf: "2026-07-25", + capable: [...ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS], + noFreeTier: [...ALIBABA_NO_FREE_TIER_TEXT_MODELS], +}; + +let cachedPack: AlibabaFreeTierAllowlistPack | null | undefined; + +export function resetAlibabaFreeTierAllowlistCache(): void { + cachedPack = undefined; +} + +function resolveAllowlistPaths(): string[] { + const paths: string[] = []; + const envPath = process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH?.trim(); + if (envPath) paths.push(envPath); + + const dataDir = process.env.DATA_DIR?.trim() || path.join(os.homedir(), ".omniroute"); + paths.push(path.join(dataDir, "alibaba-free-tier-allowlist.json")); + paths.push(path.join(process.cwd(), "config", "alibaba-free-tier-allowlist.json")); + return paths; +} + +function parseAllowlistPack(raw: unknown): AlibabaFreeTierAllowlistPack | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + const capable = Array.isArray(record.capable) + ? record.capable.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0 + ) + : []; + const noFreeTier = Array.isArray(record.noFreeTier) + ? record.noFreeTier.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0 + ) + : []; + const asOf = typeof record.asOf === "string" && record.asOf.trim() ? record.asOf.trim() : null; + if (!asOf || capable.length === 0) return null; + + return { + asOf, + validUntil: + typeof record.validUntil === "string" && record.validUntil.trim() + ? record.validUntil.trim() + : undefined, + capable, + noFreeTier, + }; +} + +export function isAlibabaFreeTierAllowlistPackValid( + pack: AlibabaFreeTierAllowlistPack, + nowMs: number = Date.now() +): boolean { + if (!pack.validUntil) return true; + const expiresAt = Date.parse(pack.validUntil); + if (!Number.isFinite(expiresAt)) return true; + return expiresAt >= nowMs; +} + +export function loadAlibabaFreeTierAllowlistPack(): AlibabaFreeTierAllowlistPack | null { + if (cachedPack !== undefined) return cachedPack; + + for (const candidatePath of resolveAllowlistPaths()) { + try { + if (!fs.existsSync(candidatePath)) continue; + const parsed = parseAllowlistPack(JSON.parse(fs.readFileSync(candidatePath, "utf8"))); + if (parsed && isAlibabaFreeTierAllowlistPackValid(parsed)) { + cachedPack = parsed; + return cachedPack; + } + } catch { + // Try next path. + } + } + + cachedPack = null; + return cachedPack; +} + +function resolveActiveAllowlistPack(): AlibabaFreeTierAllowlistPack { + return loadAlibabaFreeTierAllowlistPack() ?? EMBEDDED_ALLOWLIST_PACK; +} + +export function getAlibabaBuiltinFreeTierTextCapableModels(): readonly string[] { + return resolveActiveAllowlistPack().capable; +} + +export function getAlibabaBuiltinNoFreeTierTextModels(): readonly string[] { + return resolveActiveAllowlistPack().noFreeTier; +} + +export function isAlibabaBuiltinFreeTierTextModel(modelId: string): boolean { + return getAlibabaBuiltinFreeTierTextCapableModels().includes(modelId); +} + +export function isAlibabaBuiltinNoFreeTierTextModel(modelId: string): boolean { + return getAlibabaBuiltinNoFreeTierTextModels().includes(modelId); +} diff --git a/open-sse/services/alibabaFreeTierDiscovery.ts b/open-sse/services/alibabaFreeTierDiscovery.ts new file mode 100644 index 0000000000..0c5bb01f6f --- /dev/null +++ b/open-sse/services/alibabaFreeTierDiscovery.ts @@ -0,0 +1,359 @@ +/** + * @file alibabaFreeTierDiscovery.ts + * @description Probe-based Alibaba Model Studio free-tier eligibility discovery. + * + * DashScope /models does not expose free-quota metadata. We classify models with + * minimal chat probes and persist results on the connection: + * - AllocationQuota.FreeTierOnly → model offers free tier (drained on this account) + * - 200 on native promo families (qwen/glm/deepseek/…) → free tier with remaining quota + * - 200 on third-party paid families (kimi/moonshot) → no free tier (paid billing only) + * + * @changes + * - [2026-07-25] [Composer] - Always union built-in text free-tier allowlist into capable/block checks + * - [2026-07-25] [Composer] - Delegate text filter context to canonical shared eligibility builder + * - [2026-07-25] [Composer] - Strict allowlist for alibabafree combos (no optimistic qwen/glm guessing) + * - [2026-07-25] [Composer] - Merge free-tier state across provider connections for wildcard filtering + * - [2026-07-25] [Composer] - Add probe-based free-tier model discovery for Alibaba connections + */ + +import { + getAlibabaBillingMode, + getAlibabaFreeDrainedModels, + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelStudioProvider, + mergeAlibabaFreeDrainedModels, + type AlibabaBillingMode, +} from "./alibabaFreeTier.ts"; +import { + getAlibabaBuiltinFreeTierTextCapableModels, + getAlibabaBuiltinNoFreeTierTextModels, +} from "./alibabaFreeTierAllowlist.ts"; +import { + buildAlibabaFreeTierTextFilterContext, + getAlibabaFreeTierQuotaLastSyncAt, +} from "./alibabaFreeTierQuotaFetcher.ts"; + +export type AlibabaFreeTierProbeVerdict = + "capable_available" | "capable_drained" | "not_capable" | "unknown"; + +export type AlibabaFreeTierProbeResult = { + modelId: string; + verdict: AlibabaFreeTierProbeVerdict; + status: number; + errorCode?: string; +}; + +/** Third-party models listed on DashScope that bill paid-only on international (no free quota toggle). */ +const ALIBABA_THIRD_PARTY_PAID_PREFIXES = [/^kimi-/i, /^moonshot-/i] as const; + +/** Native families that participate in DashScope new-user free-quota promos. */ +const ALIBABA_NATIVE_FREE_TIER_PREFIXES = [ + /^qwen/i, + /^qwq/i, + /^glm-/i, + /^deepseek-/i, + /^minimax-/i, + /^MiniMax-/i, +] as const; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeModelIdList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function getAlibabaFreeTierCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + const raw = asRecord(providerSpecificData).alibabaFreeTierCapableModels; + const drained = getAlibabaFreeDrainedModels(providerSpecificData); + return [...new Set([...normalizeModelIdList(raw), ...drained])]; +} + +export function getAlibabaNoFreeTierModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierModels); +} + +export function isAlibabaThirdPartyPaidModelFamily(modelId: string): boolean { + return ALIBABA_THIRD_PARTY_PAID_PREFIXES.some((pattern) => pattern.test(modelId)); +} + +export function isAlibabaNativeFreeTierModelFamily(modelId: string): boolean { + return ALIBABA_NATIVE_FREE_TIER_PREFIXES.some((pattern) => pattern.test(modelId)); +} + +export function parseAlibabaFreeTierProbeError(bodyText: string): { + code: string; + message: string; +} { + try { + const parsed = JSON.parse(bodyText) as { + error?: { code?: string; type?: string; message?: string }; + }; + const error = parsed?.error; + const code = String(error?.code || error?.type || ""); + const message = String(error?.message || bodyText || ""); + return { code, message }; + } catch { + return { code: "", message: bodyText }; + } +} + +export function classifyAlibabaFreeTierProbe( + modelId: string, + status: number, + bodyText: string +): AlibabaFreeTierProbeResult { + const { code, message } = parseAlibabaFreeTierProbeError(bodyText); + const combined = `${code} ${message}`; + + if (status >= 200 && status < 300) { + if (isAlibabaThirdPartyPaidModelFamily(modelId)) { + return { modelId, verdict: "not_capable", status, errorCode: code || undefined }; + } + if (isAlibabaNativeFreeTierModelFamily(modelId)) { + return { modelId, verdict: "capable_available", status, errorCode: code || undefined }; + } + return { modelId, verdict: "unknown", status, errorCode: code || undefined }; + } + + if ( + status === 403 && + (code === "AllocationQuota.FreeTierOnly" || isAlibabaFreeQuotaExhaustedError(combined)) + ) { + return { modelId, verdict: "capable_drained", status, errorCode: code || undefined }; + } + + if (status === 403 || status === 400) { + return { modelId, verdict: "not_capable", status, errorCode: code || undefined }; + } + + return { modelId, verdict: "unknown", status, errorCode: code || undefined }; +} + +export function mergeAlibabaFreeTierProbeResults( + providerSpecificData: Record | null | undefined, + results: readonly AlibabaFreeTierProbeResult[], + billingMode: AlibabaBillingMode = getAlibabaBillingMode(providerSpecificData) +): Record { + const base = asRecord(providerSpecificData); + const capable = new Set(getAlibabaFreeTierCapableModels(base)); + const noFreeTier = new Set(getAlibabaNoFreeTierModels(base)); + let next = base; + + for (const result of results) { + switch (result.verdict) { + case "capable_available": + capable.add(result.modelId); + noFreeTier.delete(result.modelId); + break; + case "capable_drained": + capable.add(result.modelId); + noFreeTier.delete(result.modelId); + if (billingMode === "free") { + next = mergeAlibabaFreeDrainedModels(next, result.modelId); + } + break; + case "not_capable": + noFreeTier.add(result.modelId); + capable.delete(result.modelId); + break; + default: + break; + } + } + + return { + ...next, + alibabaFreeTierCapableModels: [...capable], + alibabaNoFreeTierModels: [...noFreeTier], + alibabaFreeTierProbeLastRunAt: new Date().toISOString(), + }; +} + +type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +/** + * Build a merged free-tier filter context for one Alibaba connection. + * + * Free-tier eligibility (capable / no-free-tier) is account-agnostic — one synced + * console snapshot applies to every `alibabaBillingMode: free` key. Only drained + * models stay per-connection (quota exhaustion is key-specific). + */ +export function buildAlibabaFreeTierFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaFreeTierTextFilterContext(connections, connectionId); +} + +export function isAlibabaFreeTierCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined, + options: { strictAllowlist?: boolean } = {} +): boolean { + const noFreeTier = new Set([ + ...getAlibabaNoFreeTierModels(providerSpecificData), + ...getAlibabaBuiltinNoFreeTierTextModels(), + ]); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set([ + ...normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierCapableModels), + ...getAlibabaBuiltinFreeTierTextCapableModels(), + ]); + if (capable.has(modelId)) return true; + + // Console quota API is authoritative when present — never guess past it. + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData) || options.strictAllowlist) { + return false; + } + + // Optimistic inclusion for native families until a probe proves otherwise. + if (isAlibabaNativeFreeTierModelFamily(modelId) && !isAlibabaThirdPartyPaidModelFamily(modelId)) { + return true; + } + + return false; +} + +export function filterAlibabaFreeEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined, + options: { strictAllowlist?: boolean } = {} +): string[] { + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + return modelIds.filter((id) => { + if (drained.has(id)) return false; + return isAlibabaFreeTierCapableModel(id, providerSpecificData, options); + }); +} + +type ProbeConnection = { + id: string; + apiKey?: string | null; + providerSpecificData?: Record | null; +}; + +export async function probeAlibabaFreeTierModel( + connection: ProbeConnection, + modelId: string, + chatCompletionsUrl: string +): Promise { + if (!connection.apiKey) { + return { modelId, verdict: "unknown", status: 0 }; + } + + try { + const response = await fetch(chatCompletionsUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${connection.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); + const bodyText = await response.text(); + return classifyAlibabaFreeTierProbe(modelId, response.status, bodyText); + } catch { + return { modelId, verdict: "unknown", status: 0 }; + } +} + +export async function probeAlibabaFreeTierModels( + connection: ProbeConnection, + modelIds: readonly string[], + chatCompletionsUrl: string, + options: { concurrency?: number } = {} +): Promise { + const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 8)); + const results: AlibabaFreeTierProbeResult[] = []; + let index = 0; + + async function worker() { + while (index < modelIds.length) { + const current = modelIds[index]; + index += 1; + results.push(await probeAlibabaFreeTierModel(connection, current, chatCompletionsUrl)); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, modelIds.length) }, () => worker())); + return results; +} + +export async function refreshAlibabaFreeTierModelClassification( + provider: string, + connection: ProbeConnection, + modelIds: readonly string[], + chatCompletionsUrl: string +): Promise | null> { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(connection.providerSpecificData) !== "free" + ) { + return null; + } + + if (getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) { + return null; + } + + const capable = new Set(getAlibabaFreeTierCapableModels(connection.providerSpecificData)); + const noFreeTier = new Set(getAlibabaNoFreeTierModels(connection.providerSpecificData)); + const pending = modelIds.filter((id) => !capable.has(id) && !noFreeTier.has(id)); + if (pending.length === 0) return null; + + const probeResults = await probeAlibabaFreeTierModels(connection, pending, chatCompletionsUrl, { + concurrency: 4, + }); + return mergeAlibabaFreeTierProbeResults(connection.providerSpecificData, probeResults); +} + +export function scheduleAlibabaFreeTierProbeRefresh( + provider: string, + connection: ProbeConnection, + models: ReadonlyArray<{ id?: string | null }>, + chatCompletionsUrl: string +): void { + const modelIds = models + .map((model) => (typeof model?.id === "string" ? model.id.trim() : "")) + .filter((id) => id.length > 0); + if (modelIds.length === 0) return; + + void (async () => { + try { + const merged = await refreshAlibabaFreeTierModelClassification( + provider, + connection, + modelIds, + chatCompletionsUrl + ); + if (!merged) return; + const { updateProviderConnection } = await import("../../src/lib/db/providers.ts"); + await updateProviderConnection(connection.id, { providerSpecificData: merged }); + } catch (error) { + console.warn("[alibaba-free-tier] background probe refresh failed", { + connectionId: connection.id, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); +} diff --git a/open-sse/services/alibabaFreeTierQuotaClassify.ts b/open-sse/services/alibabaFreeTierQuotaClassify.ts new file mode 100644 index 0000000000..efa23ab5da --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaClassify.ts @@ -0,0 +1,648 @@ +/** + * @file alibabaFreeTierQuotaClassify.ts + * @description Parsing + classification + eligibility-filtering logic for Alibaba Model + * Studio free-tier quota entries. Extracted from alibabaFreeTierQuotaFetcher.ts (which + * exceeded the file-size cap) to isolate the pure parsing/classification helpers from + * the HTTP/console-fetch flow. Behavior is unchanged. + */ + +import { getAlibabaBillingMode } from "./alibabaFreeTier.ts"; +import { + getAlibabaBuiltinFreeTierTextCapableModels, + getAlibabaBuiltinNoFreeTierTextModels, +} from "./alibabaFreeTierAllowlist.ts"; +import { toNumberOrNull } from "@/shared/utils/numeric"; +import { + isDashscopeAudioModelId, + isDashscopeMultimodalModelId, + isDashscopeTextModelId, + isDashscopeVisionModelId, +} from "./dashscopeTextModels.ts"; +import { + asRecord, + getAlibabaFreeTierQuotaLastSyncAt, + isAlibabaLiveQuotaSyncAt, + normalizeModelIdList, + toTrimmedString, + type AlibabaFreeTierQuotaClassification, + type AlibabaFreeTierQuotaEntry, +} from "./alibabaFreeTierQuotaTypes.ts"; + +export function isAlibabaQuotaValidityExpired( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): boolean { + if ( + typeof entry.quotaValidityPeriod !== "number" || + !Number.isFinite(entry.quotaValidityPeriod) + ) { + return false; + } + return entry.quotaValidityPeriod < nowMs; +} + +function parseQuotaEntry(value: unknown): AlibabaFreeTierQuotaEntry | null { + const record = asRecord(value); + const model = toTrimmedString(record.model); + if (!model) return null; + + return { + model, + freeTierOnly: record.freeTierOnly === true, + quotaStatus: toTrimmedString(record.quotaStatus) || "UNKNOWN", + quotaTotal: toNumberOrNull(record.quotaTotal) ?? undefined, + quotaInitTotal: toNumberOrNull(record.quotaInitTotal) ?? undefined, + quotaTotalPercentage: toNumberOrNull(record.quotaTotalPercentage) ?? undefined, + quotaValidityPeriod: toNumberOrNull(record.quotaValidityPeriod) ?? undefined, + }; +} + +export function parseAlibabaFreeTierQuotaEntries(payload: unknown): AlibabaFreeTierQuotaEntry[] { + const root = asRecord(payload); + const dataV2 = asRecord(asRecord(root.data).DataV2 ?? root.DataV2); + const inner = asRecord(dataV2.data); + const payloadData = asRecord(inner.data ?? inner); + const quotas = payloadData.freeTierQuotas; + + if (!Array.isArray(quotas)) return []; + return quotas + .map((entry) => parseQuotaEntry(entry)) + .filter((entry): entry is AlibabaFreeTierQuotaEntry => entry !== null); +} + +export function classifyAlibabaFreeTierQuotaEntry( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): "available" | "capable_unknown" | "drained" | "not_capable" { + if (isAlibabaQuotaValidityExpired(entry, nowMs)) { + return "not_capable"; + } + + if (!entry.freeTierOnly) { + return "not_capable"; + } + + if (entry.quotaStatus === "VALID") { + if (typeof entry.quotaTotal === "number") { + return entry.quotaTotal > 0 ? "available" : "drained"; + } + return "capable_unknown"; + } + + if (entry.quotaStatus === "UNKNOWN") { + return "capable_unknown"; + } + + return "not_capable"; +} + +export function classifyAlibabaVisionFreeTierQuotaEntry( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): "available" | "drained" | "not_capable" { + if (isAlibabaQuotaValidityExpired(entry, nowMs)) { + return "not_capable"; + } + + if (!isDashscopeVisionModelId(entry.model)) { + return "not_capable"; + } + + if (entry.quotaStatus === "VALID") { + if (typeof entry.quotaTotal === "number") { + return entry.quotaTotal > 0 ? "available" : "drained"; + } + if (typeof entry.quotaInitTotal === "number") { + return entry.quotaInitTotal > 0 ? "available" : "drained"; + } + return "not_capable"; + } + + return "not_capable"; +} + +export function classifyAlibabaVisionFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeVisionModelId, { + useVisionRules: true, + }); +} + +function classifyAlibabaFreeTierQuotaEntriesByModelFilter( + entries: readonly AlibabaFreeTierQuotaEntry[], + modelFilter: (modelId: string) => boolean, + options: { useVisionRules?: boolean } = {} +): AlibabaFreeTierQuotaClassification { + const capableModels: string[] = []; + const noFreeTierModels: string[] = []; + const drainedModels: string[] = []; + + for (const entry of entries) { + if (!modelFilter(entry.model)) continue; + + const verdict = options.useVisionRules + ? classifyAlibabaVisionFreeTierQuotaEntry(entry) + : classifyAlibabaFreeTierQuotaEntry(entry); + + switch (verdict) { + case "available": + capableModels.push(entry.model); + break; + case "capable_unknown": + capableModels.push(entry.model); + break; + case "drained": + if (options.useVisionRules) { + drainedModels.push(entry.model); + } else { + capableModels.push(entry.model); + drainedModels.push(entry.model); + } + break; + case "not_capable": + noFreeTierModels.push(entry.model); + break; + default: + break; + } + } + + return { + capableModels: [...new Set(capableModels)], + noFreeTierModels: [...new Set(noFreeTierModels)], + drainedModels: [...new Set(drainedModels)], + entries: entries.filter((entry) => modelFilter(entry.model)), + }; +} + +export function classifyAlibabaMultimodalFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeMultimodalModelId); +} + +export function classifyAlibabaAudioFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeAudioModelId); +} + +export function classifyAlibabaFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[], + options: { textOnly?: boolean } = {} +): AlibabaFreeTierQuotaClassification { + const capableModels: string[] = []; + const noFreeTierModels: string[] = []; + const drainedModels: string[] = []; + + for (const entry of entries) { + if (options.textOnly && !isDashscopeTextModelId(entry.model)) continue; + + const verdict = classifyAlibabaFreeTierQuotaEntry(entry); + switch (verdict) { + case "available": + case "capable_unknown": + capableModels.push(entry.model); + break; + case "drained": + capableModels.push(entry.model); + drainedModels.push(entry.model); + break; + case "not_capable": + noFreeTierModels.push(entry.model); + break; + default: + break; + } + } + + return { + capableModels: [...new Set(capableModels)], + noFreeTierModels: [...new Set(noFreeTierModels)], + drainedModels: [...new Set(drainedModels)], + entries: [...entries], + }; +} + +function unionModelIdLists(lists: readonly (readonly string[])[]): string[] { + return [...new Set(lists.flat())]; +} + +/** Eligibility is account-agnostic; only drained/quota exhaustion is per-connection. */ +const ALIBABA_SHARED_FREE_TIER_ELIGIBILITY_KEYS = [ + "alibabaFreeTierCapableModels", + "alibabaNoFreeTierModels", + "alibabaFreeTierVisionCapableModels", + "alibabaNoFreeTierVisionModels", + "alibabaFreeTierMultimodalCapableModels", + "alibabaNoFreeTierMultimodalModels", + "alibabaFreeTierAudioCapableModels", + "alibabaNoFreeTierAudioModels", + "alibabaFreeTierQuotaEntries", + "alibabaFreeTierVisionQuotaEntries", + "alibabaFreeTierMultimodalQuotaEntries", + "alibabaFreeTierAudioQuotaEntries", + "alibabaFreeTierQuotaLastSyncAt", + "alibabaFreeTierDiscoverySource", +] as const; + +export function extractAlibabaSharedFreeTierEligibility( + providerSpecificData: Record +): Record { + const source = asRecord(providerSpecificData); + const shared: Record = {}; + for (const key of ALIBABA_SHARED_FREE_TIER_ELIGIBILITY_KEYS) { + if (source[key] !== undefined) { + shared[key] = source[key]; + } + } + return shared; +} + +export function applyAlibabaSharedFreeTierEligibility( + targetPsd: Record, + shared: Record +): Record { + return { ...targetPsd, ...shared }; +} + +export type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +export type AlibabaFreeTierEligibilityFields = { + capableKey: string; + noFreeTierKey: string; + drainedKey: string; +}; + +export function pickCanonicalAlibabaFreeTierConnection( + connections: readonly AlibabaConnectionLike[], + fields: AlibabaFreeTierEligibilityFields +): AlibabaConnectionLike | undefined { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const synced = freeConnections.filter((connection) => + Boolean(getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) + ); + if (synced.length === 0) return undefined; + + const withEligibility = synced.filter((connection) => { + const psd = asRecord(connection.providerSpecificData); + const capable = normalizeModelIdList(psd[fields.capableKey]); + const blocked = normalizeModelIdList(psd[fields.noFreeTierKey]); + return capable.length > 0 || blocked.length > 0; + }); + + const pool = withEligibility.length > 0 ? withEligibility : synced; + return pool.reduce((best, current) => { + if (!best) return current; + const bestTime = getAlibabaFreeTierQuotaLastSyncAt(best.providerSpecificData) || ""; + const currentTime = getAlibabaFreeTierQuotaLastSyncAt(current.providerSpecificData) || ""; + return currentTime.localeCompare(bestTime) > 0 ? current : best; + }, undefined); +} + +function resolveAlibabaFreeTierEligibilityLists( + connections: readonly AlibabaConnectionLike[], + fields: AlibabaFreeTierEligibilityFields +): { capable: string[]; noFreeTier: string[]; hasQuotaSync: boolean; quotaSyncAt?: string } { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const hasQuotaSync = freeConnections.some((connection) => + Boolean(getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) + ); + const canonical = pickCanonicalAlibabaFreeTierConnection(freeConnections, fields); + + if (canonical) { + const psd = asRecord(canonical.providerSpecificData); + return { + capable: normalizeModelIdList(psd[fields.capableKey]), + noFreeTier: normalizeModelIdList(psd[fields.noFreeTierKey]), + hasQuotaSync, + quotaSyncAt: getAlibabaFreeTierQuotaLastSyncAt(psd) || "provider-canonical", + }; + } + + return { + capable: unionModelIdLists( + freeConnections.map((connection) => + normalizeModelIdList(asRecord(connection.providerSpecificData)[fields.capableKey]) + ) + ), + noFreeTier: unionModelIdLists( + freeConnections.map((connection) => + normalizeModelIdList(asRecord(connection.providerSpecificData)[fields.noFreeTierKey]) + ) + ), + hasQuotaSync, + }; +} + +function buildAlibabaCategoryFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string, + fields: { + capableKey: string; + noFreeTierKey: string; + drainedKey: string; + } +): Record { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const target = freeConnections.find((connection) => connection.id === connectionId); + const targetPsd = asRecord(target?.providerSpecificData); + const eligibility = resolveAlibabaFreeTierEligibilityLists(freeConnections, fields); + + const merged: Record = { + alibabaBillingMode: "free", + [fields.capableKey]: eligibility.capable, + [fields.noFreeTierKey]: eligibility.noFreeTier, + [fields.drainedKey]: normalizeModelIdList(targetPsd[fields.drainedKey]), + }; + + if (eligibility.hasQuotaSync) { + merged.alibabaFreeTierQuotaLastSyncAt = + eligibility.quotaSyncAt || getAlibabaFreeTierQuotaLastSyncAt(targetPsd) || "provider-merged"; + } + + return merged; +} + +export function buildAlibabaFreeVisionFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierVisionCapableModels", + noFreeTierKey: "alibabaNoFreeTierVisionModels", + drainedKey: "alibabaFreeTierVisionDrainedModels", + }); +} + +export function buildAlibabaFreeMultimodalFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierMultimodalCapableModels", + noFreeTierKey: "alibabaNoFreeTierMultimodalModels", + drainedKey: "alibabaFreeTierMultimodalDrainedModels", + }); +} + +export function buildAlibabaFreeAudioFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierAudioCapableModels", + noFreeTierKey: "alibabaNoFreeTierAudioModels", + drainedKey: "alibabaFreeTierAudioDrainedModels", + }); +} + +const ALIBABA_TEXT_ELIGIBILITY_FIELDS: AlibabaFreeTierEligibilityFields = { + capableKey: "alibabaFreeTierCapableModels", + noFreeTierKey: "alibabaNoFreeTierModels", + drainedKey: "alibabaFreeDrainedModels", +}; + +export function buildAlibabaFreeTierTextFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const target = freeConnections.find((connection) => connection.id === connectionId); + const targetPsd = asRecord(target?.providerSpecificData); + const eligibility = resolveAlibabaFreeTierEligibilityLists( + freeConnections, + ALIBABA_TEXT_ELIGIBILITY_FIELDS + ); + + const useBuiltinFallback = + !eligibility.hasQuotaSync || !isAlibabaLiveQuotaSyncAt(eligibility.quotaSyncAt ?? null); + + const merged: Record = { + alibabaBillingMode: "free", + alibabaFreeTierCapableModels: useBuiltinFallback + ? unionModelIdLists([eligibility.capable, getAlibabaBuiltinFreeTierTextCapableModels()]) + : eligibility.capable, + alibabaNoFreeTierModels: useBuiltinFallback + ? unionModelIdLists([eligibility.noFreeTier, getAlibabaBuiltinNoFreeTierTextModels()]) + : eligibility.noFreeTier, + alibabaFreeDrainedModels: normalizeModelIdList(targetPsd.alibabaFreeDrainedModels), + }; + + const syncAt = + eligibility.quotaSyncAt || + getAlibabaFreeTierQuotaLastSyncAt(targetPsd) || + (useBuiltinFallback ? "builtin-allowlist" : null); + if (syncAt) { + merged.alibabaFreeTierQuotaLastSyncAt = syncAt; + } + + return merged; +} + +export function getAlibabaFreeTierVisionCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierVisionCapableModels); +} + +export function getAlibabaFreeTierVisionDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierVisionDrainedModels); +} + +export function getAlibabaNoFreeTierVisionModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierVisionModels); +} + +export function isAlibabaFreeTierVisionCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + const noFreeTier = new Set(getAlibabaNoFreeTierVisionModels(providerSpecificData)); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getAlibabaFreeTierVisionDrainedModels(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set(getAlibabaFreeTierVisionCapableModels(providerSpecificData)); + if (capable.has(modelId)) return true; + + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData)) { + return false; + } + + return isDashscopeVisionModelId(modelId); +} + +export function filterAlibabaFreeVisionEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeVisionModelId, + getAlibabaFreeTierVisionCapableModels, + getAlibabaFreeTierVisionDrainedModels, + getAlibabaNoFreeTierVisionModels + ); +} + +function getAlibabaFreeTierMultimodalCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList( + asRecord(providerSpecificData).alibabaFreeTierMultimodalCapableModels + ); +} + +function getAlibabaFreeTierMultimodalDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList( + asRecord(providerSpecificData).alibabaFreeTierMultimodalDrainedModels + ); +} + +function getAlibabaNoFreeTierMultimodalModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierMultimodalModels); +} + +function getAlibabaFreeTierAudioCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierAudioCapableModels); +} + +function getAlibabaFreeTierAudioDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierAudioDrainedModels); +} + +function getAlibabaNoFreeTierAudioModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierAudioModels); +} + +function filterAlibabaFreeCategoryEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined, + modelTypeCheck: (modelId: string) => boolean, + getCapable: (psd: Record | null | undefined) => string[], + getDrained: (psd: Record | null | undefined) => string[], + getNoFreeTier: (psd: Record | null | undefined) => string[] +): string[] { + const drained = new Set(getDrained(providerSpecificData)); + return modelIds.filter((id) => { + if (!modelTypeCheck(id)) return false; + if (drained.has(id)) return false; + return isAlibabaFreeCategoryCapableModel( + id, + providerSpecificData, + modelTypeCheck, + getCapable, + getDrained, + getNoFreeTier + ); + }); +} + +function isAlibabaFreeCategoryCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined, + modelTypeCheck: (modelId: string) => boolean, + getCapable: (psd: Record | null | undefined) => string[], + getDrained: (psd: Record | null | undefined) => string[], + getNoFreeTier: (psd: Record | null | undefined) => string[] +): boolean { + const noFreeTier = new Set(getNoFreeTier(providerSpecificData)); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getDrained(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set(getCapable(providerSpecificData)); + if (capable.has(modelId)) return true; + + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData)) { + return false; + } + + return modelTypeCheck(modelId); +} + +export function isAlibabaFreeTierMultimodalCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + return isAlibabaFreeCategoryCapableModel( + modelId, + providerSpecificData, + isDashscopeMultimodalModelId, + getAlibabaFreeTierMultimodalCapableModels, + getAlibabaFreeTierMultimodalDrainedModels, + getAlibabaNoFreeTierMultimodalModels + ); +} + +export function isAlibabaFreeTierAudioCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + return isAlibabaFreeCategoryCapableModel( + modelId, + providerSpecificData, + isDashscopeAudioModelId, + getAlibabaFreeTierAudioCapableModels, + getAlibabaFreeTierAudioDrainedModels, + getAlibabaNoFreeTierAudioModels + ); +} + +export function filterAlibabaFreeMultimodalEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeMultimodalModelId, + getAlibabaFreeTierMultimodalCapableModels, + getAlibabaFreeTierMultimodalDrainedModels, + getAlibabaNoFreeTierMultimodalModels + ); +} + +export function filterAlibabaFreeAudioEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeAudioModelId, + getAlibabaFreeTierAudioCapableModels, + getAlibabaFreeTierAudioDrainedModels, + getAlibabaNoFreeTierAudioModels + ); +} diff --git a/open-sse/services/alibabaFreeTierQuotaFetcher.ts b/open-sse/services/alibabaFreeTierQuotaFetcher.ts new file mode 100644 index 0000000000..4cf39a6e43 --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaFetcher.ts @@ -0,0 +1,519 @@ +/** + * @file alibabaFreeTierQuotaFetcher.ts + * @description Fetch Alibaba Model Studio free-tier quota from the Bailian console API. + * + * DashScope inference keys cannot list free-tier eligibility. The console exposes + * `zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuotaAsyn` with per-model + * `freeTierOnly`, `quotaStatus`, and `quotaTotal` fields. + * + * Auth: browser session cookie (`login_aliyunid_ticket` or full Cookie header) stored + * on the connection as `providerSpecificData.alibabaConsoleCookie`. + * + * Parsing/classification/eligibility-filtering logic lives in + * `alibabaFreeTierQuotaClassify.ts` and shared types/primitives in + * `alibabaFreeTierQuotaTypes.ts` (split out to stay under the file-size cap); this file + * re-exports their public API so existing imports of this module keep working + * unchanged, and owns the HTTP/console-fetch flow itself. + * + * @changes + * - [2026-07-25] [Composer] - Merge built-in text free-tier allowlist into filter context + * - [2026-07-25] [Composer] - Propagate shared free-tier eligibility across all Alibaba free connections + * - [2026-07-25] [Composer] - Add multimodal and audio free-quota classification and console fetch paths + * - [2026-07-25] [Composer] - Add vision/media free-quota classification for alibabafreevision + * - [2026-07-25] [Composer] - Add console free-tier quota fetcher for Alibaba Model Studio + * - [2026-07-25] [Composer] - Use shared toNumberOrNull instead of local coercion helper + * - [2026-08-05] - Split classification/eligibility logic into alibabaFreeTierQuotaClassify.ts + * and alibabaFreeTierQuotaTypes.ts to stay under the file-size cap + */ + +import { getAlibabaBillingMode, isAlibabaModelStudioProvider } from "./alibabaFreeTier.ts"; +import { + asRecord, + getAlibabaFreeTierQuotaLastSyncAt, + normalizeModelIdList, + toTrimmedString, + type AlibabaFreeTierQuotaEntry, + type AlibabaFreeTierQuotaSnapshot, +} from "./alibabaFreeTierQuotaTypes.ts"; +import { + applyAlibabaSharedFreeTierEligibility, + classifyAlibabaAudioFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntries, + classifyAlibabaMultimodalFreeTierQuotaEntries, + classifyAlibabaVisionFreeTierQuotaEntries, + extractAlibabaSharedFreeTierEligibility, + parseAlibabaFreeTierQuotaEntries, +} from "./alibabaFreeTierQuotaClassify.ts"; + +// Re-export the shared types + the classification/eligibility public API so existing +// imports of this module (`from "./alibabaFreeTierQuotaFetcher.ts"`) keep working. +export type { + AlibabaFreeTierQuotaEntry, + AlibabaFreeTierQuotaClassification, + AlibabaFreeTierQuotaSnapshot, +} from "./alibabaFreeTierQuotaTypes.ts"; +export { getAlibabaFreeTierQuotaLastSyncAt, isAlibabaLiveQuotaSyncAt } from "./alibabaFreeTierQuotaTypes.ts"; +export { + isAlibabaQuotaValidityExpired, + parseAlibabaFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntry, + classifyAlibabaVisionFreeTierQuotaEntry, + classifyAlibabaVisionFreeTierQuotaEntries, + classifyAlibabaMultimodalFreeTierQuotaEntries, + classifyAlibabaAudioFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntries, + extractAlibabaSharedFreeTierEligibility, + applyAlibabaSharedFreeTierEligibility, + pickCanonicalAlibabaFreeTierConnection, + buildAlibabaFreeVisionFilterContext, + buildAlibabaFreeMultimodalFilterContext, + buildAlibabaFreeAudioFilterContext, + buildAlibabaFreeTierTextFilterContext, + getAlibabaFreeTierVisionCapableModels, + getAlibabaFreeTierVisionDrainedModels, + getAlibabaNoFreeTierVisionModels, + isAlibabaFreeTierVisionCapableModel, + filterAlibabaFreeVisionEligibleModels, + isAlibabaFreeTierMultimodalCapableModel, + isAlibabaFreeTierAudioCapableModel, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeAudioEligibleModels, + type AlibabaConnectionLike, + type AlibabaFreeTierEligibilityFields, +} from "./alibabaFreeTierQuotaClassify.ts"; + +const FREE_TIER_QUOTA_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuotaAsyn"; +const FREE_TIER_QUOTA_START_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota"; +const DEFAULT_TEXT_FE_PATH = "/costing-balance/free-quota"; +const DEFAULT_VISION_FE_PATH = + process.env.ALIBABA_FREE_TIER_VISION_FE_PATH?.trim() || "/costing-balance/free-quota-image-video"; +const DEFAULT_MULTIMODAL_FE_PATH = + process.env.ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH?.trim() || + "/costing-balance/free-quota-multimodal"; +const DEFAULT_AUDIO_FE_PATH = + process.env.ALIBABA_FREE_TIER_AUDIO_FE_PATH?.trim() || "/costing-balance/free-quota-audio"; + +const CONSOLE_GATEWAYS = { + "global-sg": { + host: "https://bailian-singapore-cs.alibabacloud.com", + region: "ap-southeast-1", + action: "IntlBroadScopeAspnGateway", + product: "sfm_bailian", + }, + "china-beijing": { + host: "https://bailian.console.aliyun.com", + region: "cn-beijing", + action: "BroadScopeAspnGateway", + product: "sfm_bailian", + }, +} as const; + +type AlibabaProviderRegion = keyof typeof CONSOLE_GATEWAYS; + +function resolveAlibabaConsoleRegion( + providerSpecificData: Record | null | undefined +): AlibabaProviderRegion { + const region = toTrimmedString(asRecord(providerSpecificData).region); + return region === "china-beijing" ? "china-beijing" : "global-sg"; +} + +export function normalizeAlibabaConsoleCookie(raw: unknown): string | null { + const value = toTrimmedString(raw); + if (!value) return null; + + if (/login_aliyunid_ticket=/i.test(value) || value.includes(";")) { + return value; + } + + return `login_aliyunid_ticket=${value}`; +} + +export function getAlibabaConsoleCookie( + providerSpecificData: Record | null | undefined +): string | null { + const psd = asRecord(providerSpecificData); + return ( + normalizeAlibabaConsoleCookie(psd.alibabaConsoleCookie) || + normalizeAlibabaConsoleCookie(psd.cookie) || + null + ); +} + +export function getAlibabaConsoleSecToken( + providerSpecificData: Record | null | undefined +): string | null { + return toTrimmedString(asRecord(providerSpecificData).alibabaConsoleSecToken); +} + +export function hasAlibabaConsoleFreeTierAuth( + providerSpecificData: Record | null | undefined +): boolean { + return getAlibabaConsoleCookie(providerSpecificData) !== null; +} + +export function mergeAlibabaFreeTierQuotaClassification( + providerSpecificData: Record | null | undefined, + snapshot: AlibabaFreeTierQuotaSnapshot +): Record { + const base = asRecord(providerSpecificData); + + const coalesceList = (snapshotList: readonly string[], existingKey: string): string[] => + snapshotList.length > 0 ? [...snapshotList] : normalizeModelIdList(base[existingKey]); + + return { + ...base, + alibabaFreeTierCapableModels: coalesceList( + snapshot.text.capableModels, + "alibabaFreeTierCapableModels" + ), + alibabaNoFreeTierModels: coalesceList( + snapshot.text.noFreeTierModels, + "alibabaNoFreeTierModels" + ), + alibabaFreeDrainedModels: coalesceList(snapshot.text.drainedModels, "alibabaFreeDrainedModels"), + alibabaFreeTierVisionCapableModels: coalesceList( + snapshot.vision.capableModels, + "alibabaFreeTierVisionCapableModels" + ), + alibabaNoFreeTierVisionModels: coalesceList( + snapshot.vision.noFreeTierModels, + "alibabaNoFreeTierVisionModels" + ), + alibabaFreeTierVisionDrainedModels: coalesceList( + snapshot.vision.drainedModels, + "alibabaFreeTierVisionDrainedModels" + ), + alibabaFreeTierMultimodalCapableModels: coalesceList( + snapshot.multimodal.capableModels, + "alibabaFreeTierMultimodalCapableModels" + ), + alibabaNoFreeTierMultimodalModels: coalesceList( + snapshot.multimodal.noFreeTierModels, + "alibabaNoFreeTierMultimodalModels" + ), + alibabaFreeTierMultimodalDrainedModels: coalesceList( + snapshot.multimodal.drainedModels, + "alibabaFreeTierMultimodalDrainedModels" + ), + alibabaFreeTierAudioCapableModels: coalesceList( + snapshot.audio.capableModels, + "alibabaFreeTierAudioCapableModels" + ), + alibabaNoFreeTierAudioModels: coalesceList( + snapshot.audio.noFreeTierModels, + "alibabaNoFreeTierAudioModels" + ), + alibabaFreeTierAudioDrainedModels: coalesceList( + snapshot.audio.drainedModels, + "alibabaFreeTierAudioDrainedModels" + ), + alibabaFreeTierQuotaEntries: snapshot.entries, + alibabaFreeTierVisionQuotaEntries: snapshot.vision.entries, + alibabaFreeTierMultimodalQuotaEntries: snapshot.multimodal.entries, + alibabaFreeTierAudioQuotaEntries: snapshot.audio.entries, + alibabaFreeTierQuotaLastSyncAt: new Date().toISOString(), + alibabaFreeTierDiscoverySource: "console-quota-api", + }; +} + +export async function propagateAlibabaFreeTierEligibilityToSiblings( + provider: string, + sourceConnectionId: string, + mergedPsd: Record +): Promise { + const shared = extractAlibabaSharedFreeTierEligibility(mergedPsd); + if (!shared.alibabaFreeTierQuotaLastSyncAt) return; + + const { getProviderConnections, updateProviderConnection } = + await import("../../src/lib/db/providers.ts"); + const connections = await getProviderConnections({ provider }); + + for (const connection of connections) { + if (connection.id === sourceConnectionId) continue; + const psd = connection.providerSpecificData as Record | null | undefined; + if (getAlibabaBillingMode(psd) !== "free") continue; + + const updated = applyAlibabaSharedFreeTierEligibility( + asRecord(connection.providerSpecificData), + shared + ); + await updateProviderConnection(connection.id as string, { providerSpecificData: updated }); + } +} + +function buildGatewayUrl(region: AlibabaProviderRegion, api: string): string { + const gateway = CONSOLE_GATEWAYS[region]; + const params = new URLSearchParams({ + action: gateway.action, + product: gateway.product, + api, + _v: "undefined", + }); + return `${gateway.host}/data/api.json?${params.toString()}`; +} + +function buildCornerstoneParam( + region: AlibabaProviderRegion, + fePath: string = DEFAULT_TEXT_FE_PATH +): Record { + const gateway = CONSOLE_GATEWAYS[region]; + const normalizedPath = fePath.startsWith("/") ? fePath : `/${fePath}`; + return { + feTraceId: crypto.randomUUID(), + feURL: `https://modelstudio.console.alibabacloud.com/${gateway.region}?tab=costing-balance#${normalizedPath}`, + protocol: "V2", + console: "ONE_CONSOLE", + productCode: "p_efm", + switchAgent: 416572, + switchUserType: 3, + domain: "modelstudio.console.alibabacloud.com", + consoleSite: "MODELSTUDIO_ALBABACLOUD", + userNickName: "", + userPrincipalName: "", + xsp_lang: "en-US", + }; +} + +function buildRequestBody( + region: AlibabaProviderRegion, + api: string, + taskId?: string | null, + fePath: string = DEFAULT_TEXT_FE_PATH +): URLSearchParams { + const gateway = CONSOLE_GATEWAYS[region]; + const request: Record = {}; + if (taskId) { + request.queryFreeTierQuotaRequest = { taskId }; + } else { + request.queryFreeTierQuotaRequest = {}; + } + request.cornerstoneParam = buildCornerstoneParam(region, fePath); + + const body = new URLSearchParams({ + params: JSON.stringify({ + Api: api, + V: "1.0", + Data: request, + }), + region: gateway.region, + }); + + return body; +} + +async function postConsoleFreeTierQuota( + region: AlibabaProviderRegion, + api: string, + cookie: string, + secToken: string | null, + taskId?: string | null, + fePath: string = DEFAULT_TEXT_FE_PATH +): Promise { + const body = buildRequestBody(region, api, taskId, fePath); + if (secToken) { + body.set("sec_token", secToken); + } + + const response = await fetch(buildGatewayUrl(region, api), { + method: "POST", + headers: { + Accept: "*/*", + "Content-Type": "application/x-www-form-urlencoded", + Cookie: cookie, + Origin: "https://modelstudio.console.alibabacloud.com", + Referer: "https://modelstudio.console.alibabacloud.com/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + }, + body: body.toString(), + signal: AbortSignal.timeout(15_000), + }); + + return response.json(); +} + +function extractTaskId(payload: unknown): string | null { + const root = asRecord(payload); + const dataV2 = asRecord(asRecord(root.data).DataV2 ?? root.DataV2); + const inner = asRecord(dataV2.data); + const payloadData = asRecord(inner.data ?? inner); + return toTrimmedString(payloadData.taskId); +} + +function hasQuotaPayload(payload: unknown): boolean { + return parseAlibabaFreeTierQuotaEntries(payload).length > 0; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchAlibabaFreeTierQuotaEntriesForPath( + providerSpecificData: Record | null | undefined, + fePath: string +): Promise { + const cookie = getAlibabaConsoleCookie(providerSpecificData); + if (!cookie) return null; + + const region = resolveAlibabaConsoleRegion(providerSpecificData); + const secToken = getAlibabaConsoleSecToken(providerSpecificData); + + let payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_START_API, + cookie, + secToken, + null, + fePath + ); + + if (!hasQuotaPayload(payload)) { + payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_API, + cookie, + secToken, + null, + fePath + ); + } + + if (!hasQuotaPayload(payload)) { + const taskId = extractTaskId(payload); + if (!taskId) return null; + + for (let attempt = 0; attempt < 8; attempt += 1) { + if (attempt > 0) { + await delay(400); + } + payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_API, + cookie, + secToken, + taskId, + fePath + ); + if (hasQuotaPayload(payload)) break; + } + } + + const entries = parseAlibabaFreeTierQuotaEntries(payload); + return entries.length > 0 ? entries : null; +} + +export async function fetchAlibabaFreeTierQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_TEXT_FE_PATH); +} + +export async function fetchAlibabaFreeTierVisionQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_VISION_FE_PATH); +} + +export async function fetchAlibabaFreeTierMultimodalQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_MULTIMODAL_FE_PATH); +} + +export async function fetchAlibabaFreeTierAudioQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_AUDIO_FE_PATH); +} + +function mergeUniqueQuotaEntries( + ...entryGroups: Array +): AlibabaFreeTierQuotaEntry[] { + const merged: AlibabaFreeTierQuotaEntry[] = []; + const seen = new Set(); + for (const group of entryGroups) { + for (const entry of group) { + if (seen.has(entry.model)) continue; + seen.add(entry.model); + merged.push(entry); + } + } + return merged; +} + +export async function buildAlibabaFreeTierQuotaSnapshot( + providerSpecificData: Record | null | undefined +): Promise { + const textEntries = await fetchAlibabaFreeTierQuotaEntries(providerSpecificData); + if (!textEntries) return null; + + const visionEntries = + (await fetchAlibabaFreeTierVisionQuotaEntries(providerSpecificData)) || textEntries; + const multimodalEntries = + (await fetchAlibabaFreeTierMultimodalQuotaEntries(providerSpecificData)) || textEntries; + const audioEntries = + (await fetchAlibabaFreeTierAudioQuotaEntries(providerSpecificData)) || textEntries; + + const text = classifyAlibabaFreeTierQuotaEntries(textEntries, { textOnly: true }); + const vision = classifyAlibabaVisionFreeTierQuotaEntries(visionEntries); + const multimodal = classifyAlibabaMultimodalFreeTierQuotaEntries(multimodalEntries); + const audio = classifyAlibabaAudioFreeTierQuotaEntries(audioEntries); + + return { + text, + vision, + multimodal, + audio, + entries: mergeUniqueQuotaEntries(textEntries, visionEntries, multimodalEntries, audioEntries), + }; +} + +export async function refreshAlibabaFreeTierQuotaClassification( + provider: string, + providerSpecificData: Record | null | undefined +): Promise | null> { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(providerSpecificData) !== "free" + ) { + return null; + } + if (!hasAlibabaConsoleFreeTierAuth(providerSpecificData)) { + return null; + } + + const snapshot = await buildAlibabaFreeTierQuotaSnapshot(providerSpecificData); + if (!snapshot) return null; + + return mergeAlibabaFreeTierQuotaClassification(providerSpecificData, snapshot); +} + +type QuotaConnection = { + id: string; + providerSpecificData?: Record | null; +}; + +export function scheduleAlibabaFreeTierQuotaRefresh( + provider: string, + connection: QuotaConnection +): void { + if (!hasAlibabaConsoleFreeTierAuth(connection.providerSpecificData)) return; + + void (async () => { + try { + const merged = await refreshAlibabaFreeTierQuotaClassification( + provider, + connection.providerSpecificData + ); + if (!merged) return; + const { updateProviderConnection } = await import("../../src/lib/db/providers.ts"); + await updateProviderConnection(connection.id, { providerSpecificData: merged }); + await propagateAlibabaFreeTierEligibilityToSiblings(provider, connection.id, merged); + } catch (error) { + console.warn("[alibaba-free-tier] console quota refresh failed", { + connectionId: connection.id, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); +} diff --git a/open-sse/services/alibabaFreeTierQuotaTypes.ts b/open-sse/services/alibabaFreeTierQuotaTypes.ts new file mode 100644 index 0000000000..7d13f04b94 --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaTypes.ts @@ -0,0 +1,58 @@ +/** + * @file alibabaFreeTierQuotaTypes.ts + * @description Shared types + small primitive helpers for the Alibaba free-tier quota + * fetcher/classifier split (extracted from alibabaFreeTierQuotaFetcher.ts to keep that + * file under the file-size cap; behavior is unchanged). + */ + +export type AlibabaFreeTierQuotaEntry = { + model: string; + freeTierOnly: boolean; + quotaStatus: string; + quotaTotal?: number; + quotaInitTotal?: number; + quotaTotalPercentage?: number; + quotaValidityPeriod?: number; +}; + +export type AlibabaFreeTierQuotaClassification = { + capableModels: string[]; + noFreeTierModels: string[]; + drainedModels: string[]; + entries: AlibabaFreeTierQuotaEntry[]; +}; + +export type AlibabaFreeTierQuotaSnapshot = { + text: AlibabaFreeTierQuotaClassification; + vision: AlibabaFreeTierQuotaClassification; + multimodal: AlibabaFreeTierQuotaClassification; + audio: AlibabaFreeTierQuotaClassification; + entries: AlibabaFreeTierQuotaEntry[]; +}; + +export function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function toTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function normalizeModelIdList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function getAlibabaFreeTierQuotaLastSyncAt( + providerSpecificData: Record | null | undefined +): string | null { + return toTrimmedString(asRecord(providerSpecificData).alibabaFreeTierQuotaLastSyncAt); +} + +/** True when the connection has a live console/API quota snapshot (not builtin fallback). */ +export function isAlibabaLiveQuotaSyncAt(syncAt: string | null | undefined): boolean { + if (!syncAt || syncAt === "builtin-allowlist") return false; + return Number.isFinite(Date.parse(syncAt)); +} diff --git a/open-sse/services/combo/providerWildcard.ts b/open-sse/services/combo/providerWildcard.ts index d27c168cd5..55763815c8 100644 --- a/open-sse/services/combo/providerWildcard.ts +++ b/open-sse/services/combo/providerWildcard.ts @@ -32,6 +32,26 @@ import { wildcardMatch } from "../wildcardRouter.ts"; import { getProviderModels } from "../../config/providerModels.ts"; import { getActiveSyncedCatalog } from "../../../src/lib/db/models/activeSyncedCatalog.ts"; +import { filterAlibabaFreeTierModels, isAlibabaModelStudioProvider } from "../alibabaFreeTier.ts"; +import { + filterAlibabaFreeEligibleModels, + buildAlibabaFreeTierFilterContext, +} from "../alibabaFreeTierDiscovery.ts"; +import { + buildAlibabaFreeAudioFilterContext, + buildAlibabaFreeMultimodalFilterContext, + buildAlibabaFreeVisionFilterContext, + filterAlibabaFreeAudioEligibleModels, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeVisionEligibleModels, +} from "../alibabaFreeTierQuotaFetcher.ts"; +import type { AlibabaConnectionLike } from "../alibabaFreeTierQuotaFetcher.ts"; +import { + isAlibabaFreeTierAudioComboName, + isAlibabaFreeTierMultimodalComboName, + isAlibabaFreeTierTextComboName, + isAlibabaFreeTierVisionComboName, +} from "../dashscopeTextModels.ts"; import type { ComboLike } from "./types.ts"; /** Sentinel pattern used for "all models of a provider". */ @@ -130,6 +150,61 @@ async function collectProviderModelIds(providerId: string): Promise { return getProviderModels(providerId).map((model) => model.id); } +async function filterAlibabaFreeDrainedModelIds( + providerId: string, + modelIds: string[], + connectionId: string | null, + comboName: string +): Promise { + if (!isAlibabaModelStudioProvider(providerId) || !connectionId) { + return modelIds; + } + try { + const { getProviderConnections } = await import("../../../src/lib/db/providers.ts"); + const connections = await getProviderConnections({ provider: providerId }); + const connection = connections.find((entry) => entry.id === connectionId); + if (!connection) return modelIds; + + if (isAlibabaFreeTierVisionComboName(comboName)) { + return filterAlibabaFreeVisionEligibleModels( + modelIds, + buildAlibabaFreeVisionFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + if (isAlibabaFreeTierMultimodalComboName(comboName)) { + return filterAlibabaFreeMultimodalEligibleModels( + modelIds, + buildAlibabaFreeMultimodalFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + if (isAlibabaFreeTierAudioComboName(comboName)) { + return filterAlibabaFreeAudioEligibleModels( + modelIds, + buildAlibabaFreeAudioFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + return filterAlibabaFreeEligibleModels( + modelIds, + buildAlibabaFreeTierFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ), + { strictAllowlist: isAlibabaFreeTierTextComboName(comboName) } + ); + } catch { + return modelIds; + } +} + /** * Expand a single provider-wildcard spec into concrete model entry objects * that `normalizeComboStep` can process as normal model steps. @@ -141,9 +216,16 @@ async function expandWildcardSpec( spec: ProviderWildcardSpec, comboName: string ): Promise { - const modelIds = await collectProviderModelIds(spec.providerId); + let modelIds = await collectProviderModelIds(spec.providerId); if (modelIds.length === 0) return null; + modelIds = await filterAlibabaFreeDrainedModelIds( + spec.providerId, + modelIds, + spec.connectionId, + comboName + ); + const pattern = spec.modelPattern; const matchingIds = pattern === PROVIDER_WILDCARD_SENTINEL diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 05d7335da2..02989b68f2 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -20,6 +20,10 @@ import { hasPerModelQuota, isProviderExhaustedReason, } from "../accountFallback.ts"; +import { + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelStudioProvider, +} from "../alibabaFreeTier.ts"; import { RateLimitReason } from "../../config/constants.ts"; import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts"; import { isCloudflareFingerprintRejection } from "../errorClassifier.ts"; @@ -117,6 +121,14 @@ export function applyComboTargetExhaustion( provider && provider !== "unknown" ) { + // Alibaba free-tier drain is model-scoped — the connection and sibling models stay eligible. + if ( + result.status === 403 && + isAlibabaModelStudioProvider(provider) && + isAlibabaFreeQuotaExhaustedError(opts.errorText) + ) { + return false; + } markAuthLevelExhaustion(target, { result, sets, log, tag }); return true; } diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index 0b1189aa7e..32fff2167f 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -161,6 +161,12 @@ async function isTargetSelectableForWeighted( ) { return false; } + if (target.provider && rawModel && target.connectionId) { + const { isAlibabaFreeTierModelRoutable } = await import("../alibabaFreeTier.ts"); + if (!(await isAlibabaFreeTierModelRoutable(target.provider, target.connectionId, rawModel))) { + return false; + } + } return isModelAvailable ? await isModelAvailable(target.modelStr, target) : true; } diff --git a/open-sse/services/dashscopeTextModels.ts b/open-sse/services/dashscopeTextModels.ts new file mode 100644 index 0000000000..08418c5834 --- /dev/null +++ b/open-sse/services/dashscopeTextModels.ts @@ -0,0 +1,109 @@ +/** + * @file dashscopeTextModels.ts + * @description DashScope / Alibaba Model Studio text and vision model ID heuristics. + * + * @changes + * - [2026-07-25] [Composer] - Add alibabafree text combo name detection for strict allowlist routing + * - [2026-07-25] [Composer] - Add multimodal and audio model detection for free-tier combos + * - [2026-07-25] [Composer] - Add vision/media model detection for alibabafreevision + * - [2026-07-25] [Composer] - Extract DashScope text-model filter for open-sse consumers + */ + +const DASHSCOPE_TEXT_MODEL_PREFIXES = [ + "qwen", + "qwq-", + "deepseek-", + "glm-", + "kimi-", + "minimax-", +] as const; + +const DASHSCOPE_VISION_MODEL_PREFIXES = ["wan", "qwen-image", "happyhorse", "z-image"] as const; + +const DASHSCOPE_NON_TEXT_MODEL_TOKEN = + /(?:^|[-_.\/])(?:asr|audio|captioner|embedding|image|livetranslate|omni|ocr|realtime|rerank|s2s|speech|tts|video|vl)(?:$|[-_.\/])/i; + +const DASHSCOPE_VISION_MODEL_TOKEN = + /(?:^|[-_.\/])(?:i2v|t2v|r2v|vace|kf2v|videoedit|animate|image-edit)(?:$|[-_.\/])/i; + +export function isDashscopeTextModelId(value: unknown): boolean { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || DASHSCOPE_NON_TEXT_MODEL_TOKEN.test(modelId)) return false; + return DASHSCOPE_TEXT_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); +} + +export function isDashscopeVisionModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || isDashscopeTextModelId(modelId)) return false; + return ( + DASHSCOPE_VISION_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)) || + DASHSCOPE_VISION_MODEL_TOKEN.test(modelId) + ); +} + +const DASHSCOPE_AUDIO_PREFIXES = [ + "cosyvoice", + "fun-asr", + "qwen-audio", + "qwen-voice", + "voice-enrollment", +] as const; + +const DASHSCOPE_AUDIO_MODEL_TOKEN = + /(?:^|[-_.\/])(?:asr|tts|livetranslate|captioner|speech|voice-design|voice-enrollment)(?:$|[-_.\/])/i; + +const DASHSCOPE_MULTIMODAL_MODEL_TOKEN = /(?:^|[-_.\/])omni(?:$|[-_.\/])/i; + +export function isDashscopeAudioModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId) return false; + return ( + DASHSCOPE_AUDIO_PREFIXES.some((prefix) => modelId.startsWith(prefix)) || + DASHSCOPE_AUDIO_MODEL_TOKEN.test(modelId) + ); +} + +export function isDashscopeMultimodalModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || isDashscopeAudioModelId(modelId) || isDashscopeVisionModelId(modelId)) { + return false; + } + return DASHSCOPE_MULTIMODAL_MODEL_TOKEN.test(modelId); +} + +export function isAlibabaFreeTierTextComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + if ( + isAlibabaFreeTierVisionComboName(normalized) || + isAlibabaFreeTierMultimodalComboName(normalized) || + isAlibabaFreeTierAudioComboName(normalized) + ) { + return false; + } + return normalized === "alibabafree" || normalized.endsWith("alibabafree"); +} + +export function isAlibabaFreeTierVisionComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreevision" || normalized.endsWith("freevision"); +} + +export function isAlibabaFreeTierMultimodalComboName( + comboName: string | null | undefined +): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreemultimodal" || normalized.endsWith("freemultimodal"); +} + +export function isAlibabaFreeTierAudioComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreeaudio" || normalized.endsWith("freeaudio"); +} diff --git a/package.json b/package.json index dd0c73eac9..0fa43b3b9d 100644 --- a/package.json +++ b/package.json @@ -251,7 +251,8 @@ "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"" + "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", + "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", diff --git a/scripts/ops/alibabafreeaudio-quota.sample.json b/scripts/ops/alibabafreeaudio-quota.sample.json new file mode 100644 index 0000000000..a9fe14e7e8 --- /dev/null +++ b/scripts/ops/alibabafreeaudio-quota.sample.json @@ -0,0 +1,427 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-2025-09-08", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-30b-a3b-captioner", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-2026-01-26", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-realtime-2026-01-15", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "cosyvoice-v3-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-voice-enrollment", + "quotaTotal": 1000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-2025-08-25", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-mtl", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-realtime-2025-11-07", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-2025-09-18", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime-2025-09-18", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime-2026-02-10", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-realtime-2025-09-22", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-2026-01-22", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1791820800000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-audio-3.0-tts-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-2026-01-26", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-2025-11-07", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-realtime-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime-2025-10-27", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-realtime-2025-12-16", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "cosyvoice-v3-plus", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-realtime-2026-01-22", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-2026-02-10", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-realtime", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-mtl-2025-08-25", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1791820800000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-audio-3.0-tts-plus", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-realtime-2026-01-15", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-realtime", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-filetrans", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-filetrans-2025-11-17", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-livetranslate-flash-realtime-2026-05-19", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-livetranslate-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1789488000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-flash-2026-06-15", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 40, + "model": "qwen-voice-design", + "quotaTotal": 4, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "voice-enrollment", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + } + } +} diff --git a/scripts/ops/alibabafreemultimodal-quota.sample.json b/scripts/ops/alibabafreemultimodal-quota.sample.json new file mode 100644 index 0000000000..2312dd9383 --- /dev/null +++ b/scripts/ops/alibabafreemultimodal-quota.sample.json @@ -0,0 +1,201 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime-2025-09-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-realtime-2025-05-08", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-realtime-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen2.5-omni-7b", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-2025-03-26", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-realtime-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-2025-09-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-omni-turbo-realtime-latest", + "quotaTotal": 0, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-omni-turbo-latest", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + } + } +} diff --git a/scripts/ops/alibabafreevision-quota.sample.json b/scripts/ops/alibabafreevision-quota.sample.json new file mode 100644 index 0000000000..87d9022843 --- /dev/null +++ b/scripts/ops/alibabafreevision-quota.sample.json @@ -0,0 +1,573 @@ +{ + "code": "200", + "data": { + "DataV2": { + "ret": ["SUCCESS::接口调用成功"], + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-vace-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-videoedit", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-kf2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-i2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus-2025-10-30", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-i2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-image", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-t2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2v-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "z-image-turbo", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-max-2025-12-30", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-r2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-animate-move", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-max-2026-01-16", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-i2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-t2v-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-t2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-r2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-t2i-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1790092800000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-06-22", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-i2v-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2v-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2i-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-video-edit", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-r2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-i2v-2026-04-25", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-t2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-i2v-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2i-flash", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-i2v-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2i-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-i2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1790697600000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v-2026-06-12", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1790697600000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-r2v-2026-06-12", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-2026-03-03", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-max", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-max", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-03-03", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-i2i-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-plus-2026-01-09", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-i2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-image-pro", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-r2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2i-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-animate-mix", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-t2i", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-04-22", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-i2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v-2026-04-25", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus-2025-12-15", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-r2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-i2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-image", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-image-3.0-pro", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + }, + "success": true + } +} diff --git a/scripts/ops/sync-alibaba-allowlist.mjs b/scripts/ops/sync-alibaba-allowlist.mjs new file mode 100644 index 0000000000..cb329c8607 --- /dev/null +++ b/scripts/ops/sync-alibaba-allowlist.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * @file sync-alibaba-allowlist.mjs + * @description Build config/alibaba-free-tier-allowlist.json from Bailian console quota JSON exports. + * + * Usage: + * node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs path/to/quota.json [...] + * node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs --from-samples + * + * Writes: + * - config/alibaba-free-tier-allowlist.json (repo baseline) + * - ~/.omniroute/alibaba-free-tier-allowlist.json when DATA_DIR unset + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + classifyAlibabaFreeTierQuotaEntries, + parseAlibabaFreeTierQuotaEntries, +} from "../../open-sse/services/alibabaFreeTierQuotaFetcher.ts"; +import { isDashscopeTextModelId } from "../../open-sse/services/dashscopeTextModels.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); + +const SAMPLE_FILES = [ + "scripts/ops/alibabafreeaudio-quota.sample.json", + "scripts/ops/alibabafreemultimodal-quota.sample.json", + "scripts/ops/alibabafreevision-quota.sample.json", +].map((relativePath) => path.join(repoRoot, relativePath)); + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function collectInputs(argv) { + if (argv.includes("--from-samples")) { + return SAMPLE_FILES.filter((filePath) => fs.existsSync(filePath)); + } + return argv.filter((arg) => !arg.startsWith("-")); +} + +function classifyTextEntries(allEntries) { + const capable = new Set(); + const noFreeTier = new Set(); + + for (const entry of allEntries) { + if (!isDashscopeTextModelId(entry.model)) continue; + const classified = classifyAlibabaFreeTierQuotaEntries([entry], { textOnly: true }); + for (const modelId of classified.capableModels) capable.add(modelId); + for (const modelId of classified.noFreeTierModels) noFreeTier.add(modelId); + } + + return { + capable: [...capable].sort(), + noFreeTier: [...noFreeTier].sort(), + }; +} + +function main() { + const inputs = collectInputs(process.argv.slice(2)); + if (inputs.length === 0) { + console.error("Usage: sync-alibaba-allowlist.mjs [...] | --from-samples"); + process.exit(1); + } + + const allEntries = []; + for (const inputPath of inputs) { + const payload = readJsonFile(inputPath); + allEntries.push(...parseAlibabaFreeTierQuotaEntries(payload)); + } + + const { capable, noFreeTier } = classifyTextEntries(allEntries); + if (capable.length === 0) { + console.error("No text free-tier models found in input payloads."); + process.exit(1); + } + + const asOf = new Date().toISOString().slice(0, 10); + const validUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const pack = { asOf, validUntil, capable, noFreeTier }; + const serialized = `${JSON.stringify(pack, null, 2)}\n`; + + const configPath = path.join(repoRoot, "config", "alibaba-free-tier-allowlist.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, serialized); + + const dataDir = process.env.DATA_DIR?.trim() || path.join(os.homedir(), ".omniroute"); + const runtimePath = path.join(dataDir, "alibaba-free-tier-allowlist.json"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(runtimePath, serialized); + + console.log(`Wrote ${capable.length} capable + ${noFreeTier.length} blocked models`); + console.log(` config: ${configPath}`); + console.log(` runtime: ${runtimePath}`); + console.log(` validUntil: ${validUntil}`); +} + +main(); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index 4ba800708f..4f4ebe5d8f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -7,12 +7,16 @@ export type QuotaScrapingFieldValues = { opencodeGoWorkspaceId: string; opencodeGoAuthCookie: string; ollamaCloudUsageCookie: string; + alibabaConsoleCookie: string; + alibabaConsoleSecToken: string; }; export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { opencodeGoWorkspaceId: "", opencodeGoAuthCookie: "", ollamaCloudUsageCookie: "", + alibabaConsoleCookie: "", + alibabaConsoleSecToken: "", }; export function assignQuotaScrapingProviderData( @@ -27,6 +31,14 @@ export function assignQuotaScrapingProviderData( } } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); + } else if ( + (provider === "alibaba" || provider === "alibaba-cn") && + values.alibabaConsoleCookie.trim() + ) { + target.alibabaConsoleCookie = values.alibabaConsoleCookie.trim(); + if (values.alibabaConsoleSecToken.trim()) { + target.alibabaConsoleSecToken = values.alibabaConsoleSecToken.trim(); + } } } @@ -109,5 +121,54 @@ export default function QuotaScrapingFields({ ); } + if (provider === "alibaba" || provider === "alibaba-cn") { + return ( +
+ onChange({ alibabaConsoleCookie: e.target.value })} + placeholder="login_aliyunid_ticket=..." + hint={providerText( + t, + "alibabaConsoleCookieHint", + editMode + ? "Leave blank to keep the stored cookie. Paste login_aliyunid_ticket or the full Cookie header from modelstudio.console.alibabacloud.com." + : "Paste login_aliyunid_ticket or the full Cookie header from the Model Studio console Free Quota page." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> + onChange({ alibabaConsoleSecToken: e.target.value })} + placeholder="KmdQ..." + hint={providerText( + t, + "alibabaConsoleSecTokenHint", + "Optional. Copy sec_token from the Free Quota network request if cookie-only sync fails." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + return null; } diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index e94ffecf26..b10d04b9c1 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -20,35 +20,18 @@ import { } from "@omniroute/open-sse/config/providers/registry/kimi/coding/runtime.ts"; import { ALIBABA_MODEL_STUDIO_MODELS } from "@omniroute/open-sse/config/providers/registry/alibaba/index.ts"; import { QWEN_CLOUD_TEXT_MODELS } from "@omniroute/open-sse/config/providers/registry/qwen-cloud/index.ts"; +import { filterAlibabaFreeEligibleModels } from "@omniroute/open-sse/services/alibabaFreeTierDiscovery.ts"; +import { shouldUseLiveAlibabaFreeModelDiscovery } from "@omniroute/open-sse/services/alibabaFreeTier.ts"; +import { isDashscopeTextModelId } from "@omniroute/open-sse/services/dashscopeTextModels.ts"; import { extractZaiToken } from "@omniroute/open-sse/executors/zai-web.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; -const DASHSCOPE_TEXT_MODEL_PREFIXES = [ - "qwen", - "qwq-", - "deepseek-", - "glm-", - "kimi-", - "minimax-", -] as const; - -// DashScope's OpenAI-compatible /models response contains only the standard -// id/object/owned_by fields for Alibaba and Qwen Cloud, so there is no upstream -// modality field to filter on. Keep known text-generation families and reject IDs -// whose tokenized names identify media, speech, embedding, reranking, or vision-only lines. -const DASHSCOPE_NON_TEXT_MODEL_TOKEN = - /(?:^|[-_.\/])(?:asr|audio|captioner|embedding|image|livetranslate|omni|ocr|realtime|rerank|s2s|speech|tts|video|vl)(?:$|[-_.\/])/i; const QWEN_CLOUD_TEXT_MODEL_IDS = new Set(QWEN_CLOUD_TEXT_MODELS.map((model) => model.id)); const ALIBABA_MODEL_STUDIO_MODEL_IDS = new Set( ALIBABA_MODEL_STUDIO_MODELS.map((model) => model.id) ); -export function isDashscopeTextModelId(value: unknown): value is string { - if (typeof value !== "string") return false; - const modelId = value.trim().toLowerCase(); - if (!modelId || DASHSCOPE_NON_TEXT_MODEL_TOKEN.test(modelId)) return false; - return DASHSCOPE_TEXT_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); -} +export { isDashscopeTextModelId }; export function parseDashscopeTextModels(data: any): any[] { const models = Array.isArray(data?.data) @@ -83,6 +66,23 @@ export function parseAlibabaModelStudioModels(data: any): any[] { ); } +export function parseAlibabaModelStudioModelsForConnection( + data: any, + providerSpecificData?: Record | null +): any[] { + if (shouldUseLiveAlibabaFreeModelDiscovery(providerSpecificData)) { + const models = parseDashscopeTextModels(data); + const eligibleIds = new Set( + filterAlibabaFreeEligibleModels( + models.map((model: { id?: string }) => model.id).filter(Boolean) as string[], + providerSpecificData + ) + ); + return models.filter((model: { id?: string }) => model.id && eligibleIds.has(model.id)); + } + return parseAlibabaModelStudioModels(data); +} + export function parseQwenCloudTextModels(data: any): any[] { return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS); } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index d2bc1334c9..8adc370207 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -2322,7 +2322,15 @@ export async function GET( } const data = await response.json(); - const pageModels = config.parseResponse(data); + let pageModels = config.parseResponse(data); + if (provider === "alibaba" || provider === "alibaba-cn") { + const { parseAlibabaModelStudioModelsForConnection } = + await import("./discovery/providerModelsConfig.ts"); + pageModels = parseAlibabaModelStudioModelsForConnection( + data, + connection.providerSpecificData as Record | null | undefined + ); + } allModels = allModels.concat(pageModels); const nextPageToken = data.nextPageToken; @@ -2344,6 +2352,45 @@ export async function GET( ); } + if (provider === "alibaba" || provider === "alibaba-cn") { + const { shouldUseLiveAlibabaFreeModelDiscovery } = + await import("@omniroute/open-sse/services/alibabaFreeTier.ts"); + const { scheduleAlibabaFreeTierProbeRefresh } = + await import("@omniroute/open-sse/services/alibabaFreeTierDiscovery.ts"); + const { scheduleAlibabaFreeTierQuotaRefresh, hasAlibabaConsoleFreeTierAuth } = + await import("@omniroute/open-sse/services/alibabaFreeTierQuotaFetcher.ts"); + const { resolveAlibabaProviderBaseUrl } = + await import("@/shared/constants/alibabaProviderRegions.ts"); + const providerSpecificData = connection.providerSpecificData as Record< + string, + unknown + > | null; + if (shouldUseLiveAlibabaFreeModelDiscovery(providerSpecificData)) { + if (hasAlibabaConsoleFreeTierAuth(providerSpecificData)) { + scheduleAlibabaFreeTierQuotaRefresh(provider, { + id: connectionId, + providerSpecificData, + }); + } else { + const baseUrl = resolveAlibabaProviderBaseUrl( + provider, + providerSpecificData, + paginationBaseUrl.replace(/\/models$/, "") + ); + scheduleAlibabaFreeTierProbeRefresh( + provider, + { + id: connectionId, + apiKey: token, + providerSpecificData, + }, + allModels, + `${baseUrl.replace(/\/$/, "")}/chat/completions` + ); + } + } + } + return buildApiDiscoveryResponse(allModels); } catch (error) { if (error instanceof SafeOutboundFetchError && error.code === "URL_GUARD_BLOCKED") { diff --git a/src/lib/compliance/providerAudit.ts b/src/lib/compliance/providerAudit.ts index 8a84acff4a..853d201986 100644 --- a/src/lib/compliance/providerAudit.ts +++ b/src/lib/compliance/providerAudit.ts @@ -76,6 +76,8 @@ export function summarizeProviderConnectionForAudit(connection: unknown) { if (Object.keys(providerSpecificData).length > 0) { const sanitizedProviderSpecificData = { ...providerSpecificData }; delete sanitizedProviderSpecificData.consoleApiKey; + delete sanitizedProviderSpecificData.alibabaConsoleCookie; + delete sanitizedProviderSpecificData.alibabaConsoleSecToken; sanitized.providerSpecificData = sanitizedProviderSpecificData; } diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index aaa830f1c2..6bbd1c78b1 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -311,6 +311,8 @@ export function validateProviderSpecificData( "ollamaCloudUsageCookie", "ollamaCloudCookie", "usageCookie", + "alibabaConsoleCookie", + "alibabaConsoleSecToken", ] as const) { const value = data[key]; if (value !== undefined && value !== null && typeof value !== "string") { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index f1f2032310..c55f823642 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -107,6 +107,7 @@ import { isSubscriptionQuotaText } from "@omniroute/open-sse/services/quotaTextC import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints"; import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; +import { resolveForcedConnectionForCredentialPool } from "../services/sessionAffinityPin.ts"; import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry"; import { generateRequestId } from "../../shared/utils/requestId"; import { logAuditEvent } from "../../lib/compliance/index"; @@ -1310,9 +1311,19 @@ async function handleSingleModelChat( ...(!forceLiveComboTest && bypassProviderQuotaPolicy ? { bypassQuotaPolicy: true } : {}), - ...(runtimeOptions.forcedConnectionId - ? { forcedConnectionId: runtimeOptions.forcedConnectionId } - : {}), + ...(() => { + const effectiveForcedId = resolveForcedConnectionForCredentialPool({ + forcedConnectionId: runtimeOptions.forcedConnectionId ?? null, + excludedConnectionIds, + connections: [], + allowRateLimitedConnections: + runtimeOptions.allowRateLimitedConnection === true || forceLiveComboTest, + bypassQuotaPolicy: forceLiveComboTest || bypassProviderQuotaPolicy, + isQuotaExhausted: () => false, + isQuotaPolicyBlocked: () => false, + }); + return effectiveForcedId ? { forcedConnectionId: effectiveForcedId } : {}; + })(), } ); preselectedCredentials = null; diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 58ca7306a7..ef6117ba32 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -56,6 +56,15 @@ import { classifyProviderError, PROVIDER_ERROR_TYPES, } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { + ALIBABA_FREE_DRAINED_LOCK_MS, + getAlibabaBillingMode, + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelFreeDrained, + isAlibabaModelStudioProvider, + mergeAlibabaFreeDrainedModels, + rehydrateAlibabaFreeDrainedModelLocks, +} from "@omniroute/open-sse/services/alibabaFreeTier.ts"; import { getCodexModelScope, @@ -74,6 +83,7 @@ import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; import { applySessionAffinityPin, formatSessionKeyForLog, + resolveForcedConnectionForCredentialPool, resolveSessionAffinityTtlMs, selectSessionAffinityConnection, } from "./sessionAffinityPin"; @@ -1038,6 +1048,15 @@ export async function getProviderCredentials( let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []) .map(createLazyConnectionView) .filter((conn) => conn.id.length > 0); + if (isAlibabaModelStudioProvider(provider)) { + for (const conn of connections) { + rehydrateAlibabaFreeDrainedModelLocks( + provider, + conn.id, + conn.providerSpecificData as Record + ); + } + } // allowedConnections: restrict to specific connection IDs (from API key policy, #363) if (allowedConnections && allowedConnections.length > 0) { connections = connections.filter((conn) => allowedConnections.includes(conn.id)); @@ -1060,6 +1079,19 @@ export async function getProviderCredentials( evaluateQuotaLimitPolicy(provider, c as ProviderConnectionView, requestedModel).blocked, }) ?? forcedConnectionId; + forcedConnectionId = resolveForcedConnectionForCredentialPool({ + forcedConnectionId, + excludedConnectionIds, + connections, + allowRateLimitedConnections, + bypassQuotaPolicy, + isQuotaExhausted: (connectionId) => + isQuotaExhaustedForRequest(connectionId, provider, requestedModel), + isQuotaPolicyBlocked: (connection) => + evaluateQuotaLimitPolicy(provider, connection as ProviderConnectionView, requestedModel) + .blocked, + }); + if (forcedConnectionId) { connections = connections.filter((conn) => conn.id === forcedConnectionId); } @@ -1205,7 +1237,15 @@ export async function getProviderCredentials( return false; } // Per-model lockout: if this specific model/family is locked on this connection, skip it - if (requestedModel && isModelLocked(provider, c.id, requestedModel)) { + if ( + requestedModel && + (isModelLocked(provider, c.id, requestedModel) || + isAlibabaModelFreeDrained( + provider, + c.providerSpecificData as Record, + requestedModel + )) + ) { connectionFilterStatus.set(c.id, "modelLocked"); if ( provider === "antigravity" && @@ -1987,6 +2027,9 @@ export async function markAccountUnavailable( // Read passthroughModels from connection config (user-configured per-model quota) const connProviderSpecificData = (conn?.providerSpecificData as Record) || {}; + if (provider && conn) { + rehydrateAlibabaFreeDrainedModelLocks(provider, connectionId, connProviderSpecificData); + } const connectionPassthroughModels = connProviderSpecificData.passthroughModels as boolean | undefined; // #2997: per-connection opt-out of the TRANSIENT connection cooldown. When set, @@ -2096,6 +2139,51 @@ export async function markAccountUnavailable( if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; const providerErrorType = classifyProviderError(status, errorText, provider); + if ( + isAlibabaModelStudioProvider(provider) && + status === 403 && + model && + isAlibabaFreeQuotaExhaustedError(errorText) + ) { + const billingMode = getAlibabaBillingMode(connProviderSpecificData); + if (billingMode === "free") { + const persistedProviderSpecificData = mergeAlibabaFreeDrainedModels( + connProviderSpecificData, + model + ); + await updateProviderConnection(connectionId, { + providerSpecificData: persistedProviderSpecificData, + lastErrorType: "free_quota_exhausted", + lastError: `Model ${model} free quota exhausted`, + lastErrorAt: new Date().toISOString(), + errorCode: status, + }); + rehydrateAlibabaFreeDrainedModelLocks( + provider!, + connectionId, + persistedProviderSpecificData + ); + recordModelLockoutFailure( + provider!, + connectionId, + model!, + "free_quota_exhausted", + status, + 0, + effectiveProviderProfile, + { + exactCooldownMs: ALIBABA_FREE_DRAINED_LOCK_MS, + maxCooldownMs: ALIBABA_FREE_DRAINED_LOCK_MS, + } + ); + log.info( + "AUTH", + `Alibaba free-tier drain for ${provider}:${model} — model permanently removed from routing (billingMode=free)` + ); + return { shouldFallback: true, cooldownMs: 0 }; + } + } + if (provider && resolveProviderId(provider) === "grok-web" && status === 403 && model) { const lockout = recordModelLockoutFailure( provider, diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index 8af7277450..60b5c629ad 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -17,6 +17,9 @@ * connection, so the existing 429-driven `deleteSessionAccountAffinity` * failover still owns rotating away from a pin that stops working. * + * @changes + * - [2026-07-24] [Composer] - Drop forcedConnectionId when excluded or ineligible (429 loop fix) + * * This module stays decoupled from auth.ts internals: the three predicates that * live in (or would cause a cycle back into) auth.ts — * `isTerminalConnectionStatus`, `isCodexScopeUnavailable`, and the quota-policy @@ -163,9 +166,7 @@ export function resolveSessionAffinityTtlMs( ): number { const override = Number(options.sessionAffinityTtlMs); if (Number.isFinite(override) && override > 0) return override; - const configured = Number( - settings.sessionAffinityTtlMs ?? settings.codexSessionAffinityTtlMs - ); + const configured = Number(settings.sessionAffinityTtlMs ?? settings.codexSessionAffinityTtlMs); if (Number.isFinite(configured) && configured > 0) return configured; return 0; } @@ -255,3 +256,41 @@ export function applySessionAffinityPin(params: ApplySessionAffinityPinParams): ); return pinned.connectionId; } + +export interface ResolveForcedConnectionForPoolParams { + forcedConnectionId: string | null; + excludedConnectionIds: ReadonlySet; + connections: AffinityPinConnection[]; + allowRateLimitedConnections: boolean; + bypassQuotaPolicy: boolean; + isQuotaExhausted: (connectionId: string) => boolean; + isQuotaPolicyBlocked: (connection: AffinityPinConnection) => boolean; +} + +/** + * Reset-aware combo routing pins a single `forcedConnectionId` per target. When + * that account 429s (quota exhausted / cooldown), the chat retry loop excludes + * it — but keeping the force would narrow the pool back to the same dead + * account. Drop the pin whenever the forced id is excluded or no longer eligible. + */ +export function resolveForcedConnectionForCredentialPool( + params: ResolveForcedConnectionForPoolParams +): string | null { + const forced = params.forcedConnectionId?.trim() || null; + if (!forced || params.excludedConnectionIds.has(forced)) return null; + + if (params.connections.length === 0) { + return forced; + } + + const forcedConn = params.connections.find((conn) => conn.id === forced); + if (!forcedConn) return null; + + if (!params.allowRateLimitedConnections && isAccountUnavailable(forcedConn.rateLimitedUntil)) { + return null; + } + if (params.isQuotaExhausted(forced)) return null; + if (!params.bypassQuotaPolicy && params.isQuotaPolicyBlocked(forcedConn)) return null; + + return forced; +} diff --git a/tests/unit/alibaba-free-tier-allowlist.test.ts b/tests/unit/alibaba-free-tier-allowlist.test.ts new file mode 100644 index 0000000000..1fa87dc5e1 --- /dev/null +++ b/tests/unit/alibaba-free-tier-allowlist.test.ts @@ -0,0 +1,44 @@ +/** + * @file alibaba-free-tier-allowlist.test.ts + * @description Tests for built-in Alibaba free-tier text model allowlists. + * + * @changes + * - [2026-07-25] [Composer] - Add built-in allowlist regression tests + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS, + ALIBABA_NO_FREE_TIER_TEXT_MODELS, + isAlibabaBuiltinFreeTierTextModel, + isAlibabaBuiltinNoFreeTierTextModel, + isAlibabaFreeTierAllowlistPackValid, + loadAlibabaFreeTierAllowlistPack, + resetAlibabaFreeTierAllowlistCache, +} from "../../open-sse/services/alibabaFreeTierAllowlist.ts"; + +test("built-in allowlist includes operator free models and excludes paid blocklist", () => { + assert.ok(ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS.includes("qwen3.6-plus")); + assert.ok(ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS.includes("glm-5.2")); + assert.ok(ALIBABA_NO_FREE_TIER_TEXT_MODELS.includes("qwen3.7-max")); + assert.ok(ALIBABA_NO_FREE_TIER_TEXT_MODELS.includes("glm-5.2-fast-preview")); + assert.equal(isAlibabaBuiltinFreeTierTextModel("qwen3.6-plus"), true); + assert.equal(isAlibabaBuiltinNoFreeTierTextModel("kimi-k2.7-code"), true); + assert.equal(isAlibabaBuiltinFreeTierTextModel("qwen3.7-max"), false); +}); + +test("allowlist JSON pack overrides embedded lists when valid", () => { + const previousPath = process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH; + const packPath = `${process.cwd()}/config/alibaba-free-tier-allowlist.json`; + process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH = packPath; + resetAlibabaFreeTierAllowlistCache(); + + const pack = loadAlibabaFreeTierAllowlistPack(); + assert.ok(pack); + assert.ok(isAlibabaFreeTierAllowlistPackValid(pack!)); + assert.ok(pack!.capable.includes("qwen3.6-plus")); + + if (previousPath) process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH = previousPath; + else delete process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH; + resetAlibabaFreeTierAllowlistCache(); +}); diff --git a/tests/unit/alibaba-free-tier-discovery.test.ts b/tests/unit/alibaba-free-tier-discovery.test.ts new file mode 100644 index 0000000000..2452552e69 --- /dev/null +++ b/tests/unit/alibaba-free-tier-discovery.test.ts @@ -0,0 +1,180 @@ +/** + * @file alibaba-free-tier-discovery.test.ts + * @description Tests for probe-based Alibaba free-tier model classification. + * + * @changes + * - [2026-07-25] [Composer] - Add Alibaba free-tier probe classification tests + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildAlibabaFreeTierFilterContext, + classifyAlibabaFreeTierProbe, + filterAlibabaFreeEligibleModels, + isAlibabaFreeTierCapableModel, + isAlibabaNativeFreeTierModelFamily, + isAlibabaThirdPartyPaidModelFamily, + mergeAlibabaFreeTierProbeResults, +} from "../../open-sse/services/alibabaFreeTierDiscovery.ts"; + +const FREE_TIER_ONLY_BODY = JSON.stringify({ + error: { + code: "AllocationQuota.FreeTierOnly", + message: + 'The free quota has been exhausted. To continue accessing the model on a paid basis, please disable the "use free tier only" mode in the management console.', + }, +}); + +test("classifyAlibabaFreeTierProbe: FreeTierOnly means capable but drained", () => { + const result = classifyAlibabaFreeTierProbe("qwen3.6-plus", 403, FREE_TIER_ONLY_BODY); + assert.equal(result.verdict, "capable_drained"); +}); + +test("classifyAlibabaFreeTierProbe: native 200 means capable with remaining quota", () => { + const result = classifyAlibabaFreeTierProbe("qwen3-coder-plus", 200, "{}"); + assert.equal(result.verdict, "capable_available"); +}); + +test("classifyAlibabaFreeTierProbe: kimi 200 is paid-only third-party family", () => { + const result = classifyAlibabaFreeTierProbe("kimi-k2.7-code", 200, "{}"); + assert.equal(result.verdict, "not_capable"); + assert.equal(isAlibabaThirdPartyPaidModelFamily("kimi-k2.7-code"), true); + assert.equal(isAlibabaNativeFreeTierModelFamily("kimi-k2.7-code"), false); +}); + +test("mergeAlibabaFreeTierProbeResults persists capable and no-free-tier sets", () => { + const merged = mergeAlibabaFreeTierProbeResults( + { alibabaBillingMode: "free" }, + [ + { modelId: "qwen3.6-plus", verdict: "capable_drained", status: 403 }, + { modelId: "kimi-k2.7-code", verdict: "not_capable", status: 200 }, + { modelId: "qwen3-coder-plus", verdict: "capable_available", status: 200 }, + ], + "free" + ); + + assert.ok(merged.alibabaFreeTierCapableModels?.includes("qwen3.6-plus")); + assert.ok(merged.alibabaFreeTierCapableModels?.includes("qwen3-coder-plus")); + assert.ok(merged.alibabaNoFreeTierModels?.includes("kimi-k2.7-code")); + assert.ok(merged.alibabaFreeDrainedModels?.includes("qwen3.6-plus")); +}); + +test("filterAlibabaFreeEligibleModels excludes drained and paid-only families", () => { + const filtered = filterAlibabaFreeEligibleModels( + ["qwen3.6-plus", "qwen3-coder-plus", "kimi-k2.7-code", "glm-5.2"], + { + alibabaFreeDrainedModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["kimi-k2.7-code"], + } + ); + assert.deepEqual(filtered, ["qwen3-coder-plus", "glm-5.2"]); +}); + +test("isAlibabaFreeTierCapableModel defaults native families to capable before probe", () => { + assert.equal(isAlibabaFreeTierCapableModel("qwen3-coder-next", {}), true); + assert.equal(isAlibabaFreeTierCapableModel("kimi-k2.7-code", {}), false); + assert.equal( + isAlibabaFreeTierCapableModel("kimi-k2.7-code", { + alibabaNoFreeTierModels: ["kimi-k2.7-code"], + }), + false + ); +}); + +test("isAlibabaFreeTierCapableModel does not guess when console quota sync exists", () => { + const psd = { + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["qwen3.7-plus"], + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T00:00:00.000Z", + }; + assert.equal(isAlibabaFreeTierCapableModel("qwen3.6-plus", psd), true); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-plus", psd), false); + assert.equal(isAlibabaFreeTierCapableModel("qwen3-coder-next", psd), true); + assert.equal(isAlibabaFreeTierCapableModel("totally-unknown-model", psd), false); +}); + +test("buildAlibabaFreeTierFilterContext blocks paid models on unsynced sibling keys", () => { + const merged = buildAlibabaFreeTierFilterContext( + [ + { + id: "main", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T00:00:00.000Z", + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["glm-5.2-fast-preview"], + }, + }, + { + id: "main-2", + providerSpecificData: { + alibabaBillingMode: "free", + }, + }, + ], + "main-2" + ); + + assert.equal(isAlibabaFreeTierCapableModel("glm-5.2-fast-preview", merged), false); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.6-plus", merged), true); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-max", merged), false); + assert.equal(isAlibabaFreeTierCapableModel("totally-unknown-model", merged), false); +}); + +test("buildAlibabaFreeTierFilterContext prefers sibling with populated free-model lists", () => { + const merged = buildAlibabaFreeTierFilterContext( + [ + { + id: "main", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T12:00:00.000Z", + alibabaFreeTierCapableModels: [], + alibabaNoFreeTierModels: [], + }, + }, + { + id: "main-2", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T06:00:00.000Z", + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["glm-5.2-fast-preview", "qwen3.7-max"], + }, + }, + ], + "main-2" + ); + + assert.equal(isAlibabaFreeTierCapableModel("glm-5.2-fast-preview", merged), false); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.6-plus", merged), true); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-max", merged), false); + assert.equal(isAlibabaFreeTierCapableModel("qwen3-coder-next", merged), true); +}); + +test("strictAllowlist blocks optimistic qwen/glm guessing for alibabafree combos", () => { + const psd = { + alibabaBillingMode: "free", + alibabaFreeDrainedModels: [], + alibabaNoFreeTierModels: [], + alibabaFreeTierCapableModels: [], + }; + + assert.equal(isAlibabaFreeTierCapableModel("qwen-not-in-builtin-list", psd), true); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-max", psd), false); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-max", psd, { strictAllowlist: true }), false); + assert.equal( + isAlibabaFreeTierCapableModel( + "qwen3.6-plus", + { ...psd, alibabaFreeTierCapableModels: ["qwen3.6-plus"] }, + { strictAllowlist: true } + ), + true + ); + assert.deepEqual( + filterAlibabaFreeEligibleModels(["qwen3.7-max", "qwen3.6-plus", "glm-5.2-fast-preview"], psd, { + strictAllowlist: true, + }), + ["qwen3.6-plus"] + ); +}); diff --git a/tests/unit/alibaba-free-tier-exhaustion.test.ts b/tests/unit/alibaba-free-tier-exhaustion.test.ts new file mode 100644 index 0000000000..609abe3dd8 --- /dev/null +++ b/tests/unit/alibaba-free-tier-exhaustion.test.ts @@ -0,0 +1,113 @@ +/** + * @file alibaba-free-tier-exhaustion.test.ts + * @description Regression tests for Alibaba Model Studio free-tier drain handling. + * + * @changes + * - [2026-07-24] [Composer] - Add free-tier exhaustion detection and combo exhaustion tests + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ALIBABA_FREE_DRAINED_LOCK_MS, + filterAlibabaFreeTierModels, + getAlibabaBillingMode, + getAlibabaFreeDrainedModels, + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelFreeDrained, + isAlibabaModelStudioProvider, + mergeAlibabaFreeDrainedModels, +} from "../../open-sse/services/alibabaFreeTier.ts"; +import { + applyComboTargetExhaustion, + type ComboExhaustionSets, +} from "../../open-sse/services/combo/targetExhaustion.ts"; +import { + clearAllModelLockouts, + isModelLocked, + lockModel, +} from "../../open-sse/services/accountFallback.ts"; + +const ALIBABA_FREE_QUOTA_ERROR = + "The free quota has been exhausted. To continue accessing the model on a paid basis, please complete your payment information (or disable the use free tier only mode)."; + +test("detects Alibaba free-quota exhaustion phrasing", () => { + assert.equal(isAlibabaFreeQuotaExhaustedError(ALIBABA_FREE_QUOTA_ERROR), true); + assert.equal(isAlibabaFreeQuotaExhaustedError("free tier of the model has been exhausted"), true); + assert.equal(isAlibabaFreeQuotaExhaustedError("Forbidden"), false); +}); + +test("defaults Alibaba billing mode to paid and supports free override", () => { + assert.equal(getAlibabaBillingMode({}), "paid"); + assert.equal(getAlibabaBillingMode({ alibabaBillingMode: "free" }), "free"); +}); + +test("mergeAlibabaFreeDrainedModels persists unique model ids", () => { + const first = mergeAlibabaFreeDrainedModels({}, "qwen3.7-max-preview"); + const second = mergeAlibabaFreeDrainedModels(first, "qwen3.6-plus"); + const third = mergeAlibabaFreeDrainedModels(second, "qwen3.7-max-preview"); + + assert.deepEqual(getAlibabaFreeDrainedModels(third), ["qwen3.7-max-preview", "qwen3.6-plus"]); + assert.equal(isAlibabaModelFreeDrained("alibaba", third, "qwen3.7-max-preview"), true); + assert.equal(isAlibabaModelFreeDrained("openai", third, "qwen3.7-max-preview"), false); +}); + +test("isAlibabaModelStudioProvider recognizes alibaba family ids", () => { + assert.equal(isAlibabaModelStudioProvider("alibaba"), true); + assert.equal(isAlibabaModelStudioProvider("alibaba-cn"), true); + assert.equal(isAlibabaModelStudioProvider("ali"), true); + assert.equal(isAlibabaModelStudioProvider("bailian-coding-plan"), false); +}); + +test("alibaba free-quota 403 does not exhaust the whole connection in combo routing", () => { + const s: ComboExhaustionSets = { + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + }; + const log = { info() {}, warn() {}, error() {}, debug() {} }; + const target = { + kind: "model", + executionKey: "ek", + modelStr: "alibaba/qwen3.7-max-preview", + provider: "alibaba", + providerId: null, + connectionId: "conn-1", + } as Parameters[0]; + + const exhausted = applyComboTargetExhaustion(target, { + result: { status: 403 }, + fallbackResult: {}, + errorText: ALIBABA_FREE_QUOTA_ERROR, + rawModel: "qwen3.7-max-preview", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + sets: s, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + }); + + assert.equal(exhausted, false); + assert.equal(s.exhaustedConnections.size, 0); + assert.equal(s.exhaustedProviders.size, 0); +}); + +test("filterAlibabaFreeTierModels removes only persisted drained models", () => { + const filtered = filterAlibabaFreeTierModels(["qwen3.7-max-preview", "qwen3.6-plus"], { + alibabaFreeDrainedModels: ["qwen3.7-max-preview"], + }); + assert.deepEqual(filtered, ["qwen3.6-plus"]); +}); + +test("permanent free-tier lockout uses a multi-year cooldown window", () => { + clearAllModelLockouts(); + lockModel( + "alibaba", + "conn-1", + "qwen3.7-max-preview", + "free_quota_exhausted", + ALIBABA_FREE_DRAINED_LOCK_MS + ); + assert.equal(isModelLocked("alibaba", "conn-1", "qwen3.7-max-preview"), true); + clearAllModelLockouts(); +}); diff --git a/tests/unit/alibaba-free-tier-quota-fetcher.test.ts b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts new file mode 100644 index 0000000000..734ee7dd38 --- /dev/null +++ b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts @@ -0,0 +1,468 @@ +/** + * @file alibaba-free-tier-quota-fetcher.test.ts + * @description Tests for Alibaba console free-tier quota API parsing and classification. + * + * @changes + * - [2026-07-25] [Composer] - Add multimodal and audio quota classification tests + * - [2026-07-25] [Composer] - Add console free-tier quota fetcher tests + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classifyAlibabaAudioFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntry, + classifyAlibabaMultimodalFreeTierQuotaEntries, + classifyAlibabaVisionFreeTierQuotaEntries, + isAlibabaQuotaValidityExpired, + mergeAlibabaFreeTierQuotaClassification, + normalizeAlibabaConsoleCookie, + parseAlibabaFreeTierQuotaEntries, + applyAlibabaSharedFreeTierEligibility, + buildAlibabaFreeTierTextFilterContext, + extractAlibabaSharedFreeTierEligibility, + isAlibabaFreeTierAudioCapableModel, + isAlibabaFreeTierMultimodalCapableModel, + isAlibabaFreeTierVisionCapableModel, +} from "../../open-sse/services/alibabaFreeTierQuotaFetcher.ts"; +import { isAlibabaFreeTierCapableModel } from "../../open-sse/services/alibabaFreeTierDiscovery.ts"; +import { + isDashscopeAudioModelId, + isDashscopeMultimodalModelId, + isDashscopeVisionModelId, +} from "../../open-sse/services/dashscopeTextModels.ts"; + +const SAMPLE_CONSOLE_RESPONSE = { + code: "200", + data: { + DataV2: { + data: { + data: { + freeTierQuotas: [ + { + quotaInitTotal: 1000000, + quotaValidityPeriod: 1786896000000, + freeTierOnly: true, + quotaTotalPercentage: 99.98, + model: "qwen3.6-plus", + quotaTotal: 999795, + quotaStatus: "VALID", + }, + { + freeTierOnly: false, + model: "qwen3.7-plus", + quotaStatus: "UNKNOWN", + }, + { + freeTierOnly: false, + model: "kimi-k2.7-code", + quotaStatus: "UNKNOWN", + }, + { + freeTierOnly: true, + model: "glm-5.2", + quotaStatus: "UNKNOWN", + }, + { + quotaInitTotal: 0, + freeTierOnly: false, + quotaTotalPercentage: 0, + model: "glm-5.2-fast-preview", + quotaTotal: 0, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 1000000, + freeTierOnly: true, + quotaTotalPercentage: 100, + model: "qwen3-vl-plus", + quotaTotal: 1000000, + quotaStatus: "VALID", + }, + ], + }, + success: true, + }, + }, + }, +}; + +const SAMPLE_VISION_CONSOLE_RESPONSE = { + code: "200", + data: { + DataV2: { + data: { + data: { + freeTierQuotas: [ + { + quotaInitTotal: 50, + freeTierOnly: false, + quotaTotalPercentage: 100, + model: "wan2.7-t2v", + quotaTotal: 50, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 100, + freeTierOnly: false, + quotaTotalPercentage: 100, + model: "qwen-image-2.0-pro", + quotaTotal: 100, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 0, + freeTierOnly: false, + quotaTotalPercentage: 0, + model: "qwen-image-3.0-pro", + quotaTotal: 0, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 1000000, + freeTierOnly: true, + quotaTotalPercentage: 100, + model: "qwen3.6-plus", + quotaTotal: 999795, + quotaStatus: "VALID", + }, + ], + }, + success: true, + }, + }, + }, +}; + +const SAMPLE_MULTIMODAL_CONSOLE_RESPONSE = { + code: "200", + data: { + DataV2: { + data: { + data: { + freeTierQuotas: [ + { + quotaInitTotal: 1000000, + freeTierOnly: true, + model: "qwen3.5-omni-plus", + quotaTotal: 1000000, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 0, + freeTierOnly: false, + model: "qwen-omni-turbo-latest", + quotaTotal: 0, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 36000, + freeTierOnly: true, + model: "qwen3-asr-flash", + quotaTotal: 36000, + quotaStatus: "VALID", + }, + ], + }, + success: true, + }, + }, + }, +}; + +const SAMPLE_AUDIO_CONSOLE_RESPONSE = { + code: "200", + data: { + DataV2: { + data: { + data: { + freeTierQuotas: [ + { + quotaInitTotal: 36000, + freeTierOnly: true, + model: "qwen3-asr-flash", + quotaTotal: 36000, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 10000, + freeTierOnly: true, + model: "qwen3-tts-flash", + quotaTotal: 10000, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 0, + freeTierOnly: false, + model: "voice-enrollment", + quotaTotal: 0, + quotaStatus: "VALID", + }, + { + quotaInitTotal: 1000000, + freeTierOnly: true, + model: "qwen3.5-omni-plus", + quotaTotal: 1000000, + quotaStatus: "VALID", + }, + ], + }, + success: true, + }, + }, + }, +}; + +test("parseAlibabaFreeTierQuotaEntries reads console payload", () => { + const entries = parseAlibabaFreeTierQuotaEntries(SAMPLE_CONSOLE_RESPONSE); + assert.equal(entries.length, 6); + assert.equal(entries[0].model, "qwen3.6-plus"); + assert.equal(entries[0].freeTierOnly, true); +}); + +test("classifyAlibabaFreeTierQuotaEntry maps console flags", () => { + assert.equal( + classifyAlibabaFreeTierQuotaEntry({ + model: "qwen3.6-plus", + freeTierOnly: true, + quotaStatus: "VALID", + quotaTotal: 100, + }), + "available" + ); + assert.equal( + classifyAlibabaFreeTierQuotaEntry({ + model: "glm-5.2", + freeTierOnly: true, + quotaStatus: "UNKNOWN", + }), + "capable_unknown" + ); + assert.equal( + classifyAlibabaFreeTierQuotaEntry({ + model: "qwen3.7-plus", + freeTierOnly: false, + quotaStatus: "UNKNOWN", + }), + "not_capable" + ); + assert.equal( + classifyAlibabaFreeTierQuotaEntry({ + model: "glm-5.2-fast-preview", + freeTierOnly: false, + quotaStatus: "VALID", + quotaTotal: 0, + quotaInitTotal: 0, + }), + "not_capable" + ); +}); + +test("classifyAlibabaFreeTierQuotaEntry treats expired quotaValidityPeriod as not_capable", () => { + assert.equal( + classifyAlibabaFreeTierQuotaEntry( + { + model: "qwen3.6-plus", + freeTierOnly: true, + quotaStatus: "VALID", + quotaTotal: 100, + quotaValidityPeriod: Date.now() - 60_000, + }, + Date.now() + ), + "not_capable" + ); + assert.equal( + isAlibabaQuotaValidityExpired({ + model: "qwen3.6-plus", + freeTierOnly: true, + quotaStatus: "VALID", + quotaValidityPeriod: Date.now() + 60_000, + }), + false + ); +}); + +test("classifyAlibabaFreeTierQuotaEntries filters text-only models for alibabafree", () => { + const classification = classifyAlibabaFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_CONSOLE_RESPONSE), + { textOnly: true } + ); + + assert.ok(classification.capableModels.includes("qwen3.6-plus")); + assert.ok(classification.capableModels.includes("glm-5.2")); + assert.ok(classification.noFreeTierModels.includes("qwen3.7-plus")); + assert.ok(classification.noFreeTierModels.includes("kimi-k2.7-code")); + assert.ok(classification.noFreeTierModels.includes("glm-5.2-fast-preview")); + assert.equal(classification.capableModels.includes("qwen3-vl-plus"), false); +}); + +test("classifyAlibabaVisionFreeTierQuotaEntries includes promo vision quota models", () => { + const classification = classifyAlibabaVisionFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_VISION_CONSOLE_RESPONSE) + ); + + assert.ok(classification.capableModels.includes("wan2.7-t2v")); + assert.ok(classification.capableModels.includes("qwen-image-2.0-pro")); + assert.ok(classification.drainedModels.includes("qwen-image-3.0-pro")); + assert.equal(classification.capableModels.includes("qwen-image-3.0-pro"), false); + assert.equal(classification.capableModels.includes("qwen3.6-plus"), false); +}); + +test("isDashscopeVisionModelId recognizes wan and qwen-image families", () => { + assert.equal(isDashscopeVisionModelId("wan2.7-t2v"), true); + assert.equal(isDashscopeVisionModelId("qwen-image-plus"), true); + assert.equal(isDashscopeVisionModelId("qwen3.6-plus"), false); +}); + +test("classifyAlibabaMultimodalFreeTierQuotaEntries requires freeTierOnly omni models", () => { + const classification = classifyAlibabaMultimodalFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_MULTIMODAL_CONSOLE_RESPONSE) + ); + + assert.ok(classification.capableModels.includes("qwen3.5-omni-plus")); + assert.ok(classification.noFreeTierModels.includes("qwen-omni-turbo-latest")); + assert.equal(classification.capableModels.includes("qwen3-asr-flash"), false); +}); + +test("classifyAlibabaAudioFreeTierQuotaEntries includes asr and tts free-tier models", () => { + const classification = classifyAlibabaAudioFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_AUDIO_CONSOLE_RESPONSE) + ); + + assert.ok(classification.capableModels.includes("qwen3-asr-flash")); + assert.ok(classification.capableModels.includes("qwen3-tts-flash")); + assert.ok(classification.noFreeTierModels.includes("voice-enrollment")); + assert.equal(classification.capableModels.includes("qwen3.5-omni-plus"), false); +}); + +test("isDashscopeMultimodalModelId and isDashscopeAudioModelId classify model families", () => { + assert.equal(isDashscopeMultimodalModelId("qwen3.5-omni-plus"), true); + assert.equal(isDashscopeMultimodalModelId("qwen3-asr-flash"), false); + assert.equal(isDashscopeAudioModelId("qwen3-tts-flash"), true); + assert.equal(isDashscopeAudioModelId("cosyvoice-v3-flash"), true); + assert.equal(isDashscopeAudioModelId("qwen3.5-omni-plus"), false); +}); + +test("mergeAlibabaFreeTierQuotaClassification makes discovery authoritative", () => { + const text = classifyAlibabaFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_CONSOLE_RESPONSE), + { textOnly: true } + ); + const vision = classifyAlibabaVisionFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_VISION_CONSOLE_RESPONSE) + ); + const multimodal = classifyAlibabaMultimodalFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_MULTIMODAL_CONSOLE_RESPONSE) + ); + const audio = classifyAlibabaAudioFreeTierQuotaEntries( + parseAlibabaFreeTierQuotaEntries(SAMPLE_AUDIO_CONSOLE_RESPONSE) + ); + const merged = mergeAlibabaFreeTierQuotaClassification( + { alibabaBillingMode: "free" }, + { + text, + vision, + multimodal, + audio, + entries: [...text.entries, ...vision.entries, ...multimodal.entries, ...audio.entries], + } + ); + + assert.equal(merged.alibabaFreeTierDiscoverySource, "console-quota-api"); + assert.equal(typeof merged.alibabaFreeTierQuotaLastSyncAt, "string"); + assert.ok(merged.alibabaFreeTierVisionCapableModels?.includes("wan2.7-t2v")); + assert.equal(merged.alibabaFreeTierVisionCapableModels?.includes("qwen-image-3.0-pro"), false); + assert.ok(merged.alibabaFreeTierMultimodalCapableModels?.includes("qwen3.5-omni-plus")); + assert.ok(merged.alibabaFreeTierAudioCapableModels?.includes("qwen3-tts-flash")); + + assert.equal(isAlibabaFreeTierCapableModel("qwen3.6-plus", merged), true); + assert.equal(isAlibabaFreeTierCapableModel("qwen3.7-plus", merged), false); + assert.equal(isAlibabaFreeTierVisionCapableModel("wan2.7-t2v", merged), true); + assert.equal(isAlibabaFreeTierVisionCapableModel("qwen-image-3.0-pro", merged), false); + assert.equal(isAlibabaFreeTierMultimodalCapableModel("qwen3.5-omni-plus", merged), true); + assert.equal(isAlibabaFreeTierAudioCapableModel("qwen3-tts-flash", merged), true); +}); + +test("mergeAlibabaFreeTierQuotaClassification preserves text lists on partial category snapshot", () => { + const merged = mergeAlibabaFreeTierQuotaClassification( + { + alibabaBillingMode: "free", + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["qwen3.7-max"], + }, + { + text: { capableModels: [], noFreeTierModels: [], drainedModels: [], entries: [] }, + vision: { + capableModels: ["wan2.7-t2v"], + noFreeTierModels: [], + drainedModels: [], + entries: [], + }, + multimodal: { capableModels: [], noFreeTierModels: [], drainedModels: [], entries: [] }, + audio: { capableModels: [], noFreeTierModels: [], drainedModels: [], entries: [] }, + entries: [], + } + ); + + assert.deepEqual(merged.alibabaFreeTierCapableModels, ["qwen3.6-plus"]); + assert.deepEqual(merged.alibabaNoFreeTierModels, ["qwen3.7-max"]); + assert.deepEqual(merged.alibabaFreeTierVisionCapableModels, ["wan2.7-t2v"]); +}); + +test("buildAlibabaFreeTierTextFilterContext uses canonical sibling free-model lists", () => { + const context = buildAlibabaFreeTierTextFilterContext( + [ + { + id: "main", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T12:00:00.000Z", + alibabaFreeTierCapableModels: [], + alibabaNoFreeTierModels: [], + }, + }, + { + id: "main-2", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T06:00:00.000Z", + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["qwen3.7-max", "glm-5.2-fast-preview"], + }, + }, + ], + "main" + ); + + assert.ok((context.alibabaFreeTierCapableModels as string[]).includes("qwen3.6-plus")); + assert.equal((context.alibabaFreeTierCapableModels as string[]).includes("glm-5.2"), false); + assert.ok((context.alibabaNoFreeTierModels as string[]).includes("qwen3.7-max")); + assert.ok((context.alibabaNoFreeTierModels as string[]).includes("glm-5.2-fast-preview")); +}); + +test("shared free-tier eligibility copies lists but preserves per-connection drained models", () => { + const shared = extractAlibabaSharedFreeTierEligibility({ + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["qwen3.7-max"], + alibabaFreeTierQuotaLastSyncAt: "2026-07-25T00:00:00.000Z", + }); + const applied = applyAlibabaSharedFreeTierEligibility( + { + alibabaBillingMode: "free", + alibabaFreeDrainedModels: ["qwen3.6-plus"], + }, + shared + ); + + assert.deepEqual(applied.alibabaFreeTierCapableModels, ["qwen3.6-plus"]); + assert.deepEqual(applied.alibabaFreeDrainedModels, ["qwen3.6-plus"]); +}); + +test("normalizeAlibabaConsoleCookie accepts ticket-only paste", () => { + assert.equal(normalizeAlibabaConsoleCookie("abc123ticket"), "login_aliyunid_ticket=abc123ticket"); + assert.equal( + normalizeAlibabaConsoleCookie("login_aliyunid_ticket=abc123ticket; cna=xyz"), + "login_aliyunid_ticket=abc123ticket; cna=xyz" + ); +}); diff --git a/tests/unit/combo-provider-wildcard.test.ts b/tests/unit/combo-provider-wildcard.test.ts index 95008b6b07..e6c7e3808b 100644 --- a/tests/unit/combo-provider-wildcard.test.ts +++ b/tests/unit/combo-provider-wildcard.test.ts @@ -32,10 +32,13 @@ const { const { replaceSyncedAvailableModelsForConnection, getSyncedAvailableModels } = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); const core = await import("../../src/lib/db/core.ts"); core.getDbInstance(); // initialise DB + run migrations +const { normalizeComboRecord } = await import("../../src/lib/combos/steps.ts"); + // ── Helpers ────────────────────────────────────────────────────────────────── function makeCombo(models: unknown[], name = "test-combo") { @@ -406,3 +409,98 @@ test("#8926: inactive synced catalog does not override static wildcard fallback" "an inactive connection must not make its synced catalog authoritative" ); }); +test("expandProviderWildcardsInCombo: skips Alibaba free-tier drained models for pinned connections", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "alibaba", + authType: "apikey", + name: "alibaba-free-wildcard", + apiKey: "test-dashscope-key", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeDrainedModels: ["qwen3.7-max-preview"], + }, + }); + await seedSyncedModels("alibaba", connection.id, [ + "qwen3.7-max-preview", + "qwen3.6-plus", + "glm-5.2", + ]); + + const combo = makeCombo([ + { + kind: "provider-wildcard", + providerId: "alibaba", + modelPattern: "*", + connectionId: connection.id, + }, + ]); + const result = await expandProviderWildcardsInCombo(combo); + const models = result.models.map((entry) => (entry as { model: string }).model); + + assert.equal(models.includes("alibaba/qwen3.7-max-preview"), false); + assert.ok(models.includes("alibaba/qwen3.6-plus")); + assert.ok(models.includes("alibaba/glm-5.2")); + assert.equal(models.includes("alibaba/kimi-k2.7-code"), false); +}); + +test("expandProviderWildcardsInCombo: alibabafree combo uses strict console allowlist only", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "alibaba", + authType: "apikey", + name: "alibaba-free-strict", + apiKey: "test-dashscope-key-strict-allowlist", + providerSpecificData: { + alibabaBillingMode: "free", + alibabaFreeTierCapableModels: ["qwen3.6-plus"], + alibabaNoFreeTierModels: ["glm-5.2-fast-preview", "qwen3.7-max"], + }, + }); + await seedSyncedModels("alibaba", connection.id, [ + "qwen3.6-plus", + "qwen3.7-max", + "glm-5.2-fast-preview", + "glm-5.2", + ]); + + const combo = makeCombo( + [ + { + kind: "provider-wildcard", + providerId: "alibaba", + modelPattern: "*", + connectionId: connection.id, + }, + ], + "alibabafree" + ); + const result = await expandProviderWildcardsInCombo(combo); + const models = result.models.map((entry) => (entry as { model: string }).model); + + assert.ok(models.includes("alibaba/qwen3.6-plus")); + assert.ok(models.includes("alibaba/glm-5.2")); + assert.equal(models.includes("alibaba/qwen3.7-max"), false); + assert.equal(models.includes("alibaba/glm-5.2-fast-preview"), false); +}); + +test("normalizeComboRecord: preserves provider-wildcard steps for DB round-trip", () => { + const normalized = normalizeComboRecord({ + name: "alibabafree", + models: [ + { + kind: "provider-wildcard", + providerId: "alibaba", + modelPattern: "*", + connectionId: "conn-main", + label: "main", + }, + "alibaba/qwen*", + ], + }); + + assert.equal(normalized.models.length, 2); + assert.equal(normalized.models[0].kind, "provider-wildcard"); + assert.equal((normalized.models[0] as { providerId: string }).providerId, "alibaba"); + assert.equal((normalized.models[0] as { connectionId?: string }).connectionId, "conn-main"); + assert.equal(normalized.models[1].kind, "provider-wildcard"); + assert.equal((normalized.models[1] as { modelPattern: string }).modelPattern, "qwen*"); +}); diff --git a/tests/unit/dashscope-text-models-discovery.test.ts b/tests/unit/dashscope-text-models-discovery.test.ts index f0fe794034..5e62ce7613 100644 --- a/tests/unit/dashscope-text-models-discovery.test.ts +++ b/tests/unit/dashscope-text-models-discovery.test.ts @@ -176,3 +176,50 @@ test("legacy Alibaba China connections also sync only Beijing text models", asyn expectedModelIds: ALIBABA_MODEL_STUDIO_MODEL_IDS, }); }); + +test("Alibaba free billing mode syncs all live text models, not only the curated catalog", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "alibaba", + authType: "apikey", + name: "alibaba-free-live", + apiKey: "test-dashscope-key", + providerSpecificData: { region: "global-sg", alibabaBillingMode: "free" }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ object: "list", data: MIXED_DASHSCOPE_MODELS }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.ok( + body.models.length > ALIBABA_MODEL_STUDIO_MODEL_IDS.length, + "free billing mode should expose more than the curated catalog" + ); + assert.ok( + body.models.some((model: { id: string }) => model.id === "qwen3-coder-next"), + "expected upstream-only free-tier model to be included" + ); + assert.equal( + body.models.some((model: { id: string }) => model.id === "qwen-image-2.0-pro"), + false, + "non-text models must still be filtered out" + ); + assert.equal( + body.models.some((model: { id: string }) => model.id === "kimi-k2.7-code"), + false, + "third-party paid-only models must be excluded from free billing discovery" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/forced-connection-fallback.test.ts b/tests/unit/forced-connection-fallback.test.ts new file mode 100644 index 0000000000..c556be0ba9 --- /dev/null +++ b/tests/unit/forced-connection-fallback.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveForcedConnectionForCredentialPool } from "../../src/sse/services/sessionAffinityPin.ts"; + +const conn = (id: string, rateLimitedUntil: string | null = null) => ({ + id, + rateLimitedUntil, +}); + +test("resolveForcedConnectionForCredentialPool drops forced id when excluded after 429 fallback", () => { + const excluded = new Set(["dead-account"]); + assert.equal( + resolveForcedConnectionForCredentialPool({ + forcedConnectionId: "dead-account", + excludedConnectionIds: excluded, + connections: [conn("dead-account"), conn("healthy-account")], + allowRateLimitedConnections: false, + bypassQuotaPolicy: false, + isQuotaExhausted: () => false, + isQuotaPolicyBlocked: () => false, + }), + null + ); +}); + +test("resolveForcedConnectionForCredentialPool keeps forced id when eligible", () => { + assert.equal( + resolveForcedConnectionForCredentialPool({ + forcedConnectionId: "healthy-account", + excludedConnectionIds: new Set(), + connections: [conn("healthy-account"), conn("other-account")], + allowRateLimitedConnections: false, + bypassQuotaPolicy: false, + isQuotaExhausted: () => false, + isQuotaPolicyBlocked: () => false, + }), + "healthy-account" + ); +}); + +test("resolveForcedConnectionForCredentialPool drops forced id on cooldown", () => { + const future = new Date(Date.now() + 60_000).toISOString(); + assert.equal( + resolveForcedConnectionForCredentialPool({ + forcedConnectionId: "cooling-account", + excludedConnectionIds: new Set(), + connections: [conn("cooling-account", future)], + allowRateLimitedConnections: false, + bypassQuotaPolicy: false, + isQuotaExhausted: () => false, + isQuotaPolicyBlocked: () => false, + }), + null + ); +}); + +test("resolveForcedConnectionForCredentialPool drops forced id when quota exhausted", () => { + assert.equal( + resolveForcedConnectionForCredentialPool({ + forcedConnectionId: "exhausted-account", + excludedConnectionIds: new Set(), + connections: [conn("exhausted-account")], + allowRateLimitedConnections: false, + bypassQuotaPolicy: false, + isQuotaExhausted: (id) => id === "exhausted-account", + isQuotaPolicyBlocked: () => false, + }), + null + ); +}); + +test("resolveForcedConnectionForCredentialPool with empty connections only checks exclusion", () => { + assert.equal( + resolveForcedConnectionForCredentialPool({ + forcedConnectionId: "pinned-account", + excludedConnectionIds: new Set(), + connections: [], + allowRateLimitedConnections: false, + bypassQuotaPolicy: false, + isQuotaExhausted: () => true, + isQuotaPolicyBlocked: () => true, + }), + "pinned-account" + ); +});