From ddcba053d487d0ded8fae785fc37d51158d359f0 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:55:51 +0300 Subject: [PATCH 01/16] feat(quota): add normalized quota schema, provider parsers, and live pressure policy - quota-types.ts: NormalizedQuotaSnapshot, NormalizedQuotaWindow, ProviderQuotaPayload, QuotaAppliesTo (inference/mcp/model) - quota-normalize.ts: provider-specific parsers for Z.AI, OpenAI Codex, xAI, Kimi, MiniMax; boolean/NaN/Infinity/out-of-range rejection; secret field stripping; unsupported status for no-meter payloads - quota-policy.ts: sqrt(min(applicable remaining ratios)) factor; MCP-only windows excluded from inference routing; model-scoped windows apply only to mapped candidate models; stale/unsupported -> null (static pressure only); depleted accounts excluded from selection All modules are pure data transformations with no credential or network access. Router integration follows in the next commit. --- plugin/__tests__/quota-normalize.test.ts | 313 +++++++++++++++++ plugin/__tests__/quota-policy.test.ts | 161 +++++++++ plugin/quota-normalize.ts | 413 +++++++++++++++++++++++ plugin/quota-policy.ts | 115 +++++++ plugin/quota-types.ts | 96 ++++++ 5 files changed, 1098 insertions(+) create mode 100644 plugin/__tests__/quota-normalize.test.ts create mode 100644 plugin/__tests__/quota-policy.test.ts create mode 100644 plugin/quota-normalize.ts create mode 100644 plugin/quota-policy.ts create mode 100644 plugin/quota-types.ts diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts new file mode 100644 index 0000000..43ef82c --- /dev/null +++ b/plugin/__tests__/quota-normalize.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeQuotaWindow, + validateNormalizedSnapshot, + normalizeSnapshot, +} from "../quota-normalize.js"; +import type { ProviderQuotaPayload } from "../quota-types.js"; + +describe("normalizeQuotaWindow", () => { + it("normalizes a Z.AI TOKENS_LIMIT with percentage and nextResetTime", () => { + const window = normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + windowSeconds: 5 * 3600, + remainingRatio: 0.9888, + resetAt: "2026-07-24T20:23:52Z", + }); + expect(window.remainingRatio).toBeCloseTo(0.9888); + expect(window.kind).toBe("tokens_limit"); + expect(window.appliesTo).toBe("inference"); + expect(window.modelIds).toEqual([]); + expect(window.id).toBe("TOKENS_LIMIT"); + }); + + it("derives remaining ratio from usage/limit counters", () => { + const window = normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + used: 400, + limit: 800, + resetAt: "2026-07-24T20:23:52Z", + }); + expect(window.remainingRatio).toBeCloseTo(0.5); + }); + + it("rejects NaN remainingRatio", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: NaN, + }), + ).toThrow(); + }); + + it("rejects Infinity remainingRatio", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: Infinity, + }), + ).toThrow(); + }); + + it("rejects boolean remainingRatio", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: true as unknown as number, + }), + ).toThrow(); + }); + + it("rejects remainingRatio > 1", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: 1.01, + }), + ).toThrow(); + }); + + it("rejects remainingRatio < 0", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: -0.01, + }), + ).toThrow(); + }); + + it("clamps an explicit-zero usage to remainingRatio=0", () => { + const window = normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + used: 800, + limit: 800, + }); + expect(window.remainingRatio).toBe(0); + }); + + it("defaults appliesTo to inference when unset", () => { + const window = normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: 0.5, + }); + expect(window.appliesTo).toBe("inference"); + }); + + it("preserves model-scoped appliesTo with model IDs", () => { + const window = normalizeQuotaWindow({ + rawKind: "MODEL_MODEL_QUOTA", + windowSeconds: 5 * 3600, + remainingRatio: 0.5, + appliesTo: "model", + modelIds: ["minimax/m2.5"], + }); + expect(window.appliesTo).toBe("model"); + expect(window.modelIds).toEqual(["minimax/m2.5"]); + }); + + it("rejects model-scoped window with no model IDs", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "MODEL_QUOTA", + windowSeconds: 5 * 3600, + remainingRatio: 0.5, + appliesTo: "model", + }), + ).toThrow(); + }); + + it("rejects inference-scoped window carrying model IDs", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + windowSeconds: 5 * 3600, + remainingRatio: 0.5, + appliesTo: "inference", + modelIds: ["minimax/m2.5"], + }), + ).toThrow(); + }); +}); + +describe("validateNormalizedSnapshot", () => { + const validSnapshot = { + provider: "zai", + account: "zai#1", + status: "fresh" as const, + windows: [ + { + id: "PRIMARY", + kind: "tokens_limit" as const, + appliesTo: "inference" as const, + modelIds: [] as string[], + remainingRatio: 0.86, + windowSeconds: 7 * 24 * 3600, + resetAt: "2026-07-26T20:23:52Z", + }, + ], + fetchedAt: "2026-07-24T17:33:47Z", + }; + + it("accepts a valid fresh snapshot", () => { + expect(() => validateNormalizedSnapshot(validSnapshot)).not.toThrow(); + }); + + it("rejects a snapshot with no windows", () => { + expect(() => + validateNormalizedSnapshot({ ...validSnapshot, windows: [] }), + ).toThrow(); + }); + + it("rejects a snapshot whose provider does not match the requested provider", () => { + expect(() => + validateNormalizedSnapshot({ ...validSnapshot, provider: "openai" }, "zai"), + ).toThrow(); + }); + + it("rejects a stale snapshot when not diagnostics-only", () => { + expect(() => + validateNormalizedSnapshot( + { ...validSnapshot, status: "stale" }, + undefined, + false, + ), + ).toThrow(); + }); + + it("accepts a stale snapshot when diagnostics-only", () => { + expect(() => + validateNormalizedSnapshot( + { ...validSnapshot, status: "stale" }, + undefined, + true, + ), + ).not.toThrow(); + }); +}); + +describe("normalizeSnapshot", () => { + it("normalizes a Z.AI payload into a normalized snapshot", () => { + const payload: ProviderQuotaPayload = { + provider: "zai", + account: "zai#1", + raw: { + limits: [ + { + limit_id: "5_hour", + type: "TOKENS_LIMIT", + time_window: "5h", + usage: { used: 100, number: 10000, current_value: 100 }, + percentage: 0.9888, + next_reset_time: "2026-07-24T20:23:52Z", + }, + { + limit_id: "weekly", + type: "TOKENS_LIMIT", + time_window: "1w", + usage: { used: 1400, number: 10000, current_value: 1400 }, + percentage: 0.86, + next_reset_time: "2026-07-26T20:23:52Z", + }, + ], + }, + fetchedAt: "2026-07-24T17:33:47Z", + }; + const snapshot = normalizeSnapshot(payload); + expect(snapshot.provider).toBe("zai"); + expect(snapshot.account).toBe("zai#1"); + expect(snapshot.status).toBe("fresh"); + expect(snapshot.windows).toHaveLength(2); + expect(snapshot.windows[0].remainingRatio).toBeCloseTo(0.9888); + expect(snapshot.windows[1].remainingRatio).toBeCloseTo(0.86); + expect(snapshot.windows.every((w) => w.appliesTo === "inference")).toBe(true); + }); + + it("normalizes an OpenAI Codex payload with primary/secondary windows", () => { + const payload: ProviderQuotaPayload = { + provider: "openai-codex", + account: "openai#1", + raw: { + rate_limits: [ + { + label: "primary", + window_minutes: 300, + used_percent: 47, + reset_seconds: 9000, + }, + { + label: "secondary", + window_minutes: 10080, + used_percent: 12, + reset_seconds: 360000, + }, + ], + }, + fetchedAt: "2026-07-24T17:33:47Z", + }; + const snapshot = normalizeSnapshot(payload); + expect(snapshot.windows).toHaveLength(2); + expect(snapshot.windows[0].id).toBe("primary"); + expect(snapshot.windows[0].remainingRatio).toBeCloseTo(0.53, 1); + expect(snapshot.windows[1].id).toBe("secondary"); + expect(snapshot.windows[1].remainingRatio).toBeCloseTo(0.88, 1); + }); + + it("normalizes an xAI payload from bare remaining_percent", () => { + const payload: ProviderQuotaPayload = { + provider: "xai", + account: "xai#1", + raw: { + remaining_percent: 100, + }, + fetchedAt: "2026-07-24T17:33:47Z", + }; + const snapshot = normalizeSnapshot(payload); + expect(snapshot.windows).toHaveLength(1); + expect(snapshot.windows[0].remainingRatio).toBe(1); + }); + + it("marks a payload with no quantitative meter as unsupported", () => { + const payload: ProviderQuotaPayload = { + provider: "qwen-oauth", + account: "qwen#1", + raw: {}, + fetchedAt: "2026-07-24T17:33:47Z", + }; + const snapshot = normalizeSnapshot(payload); + expect(snapshot.status).toBe("unsupported"); + expect(snapshot.windows).toHaveLength(0); + }); + + it("strips raw payload identity fields from the normalized snapshot", () => { + const payload: ProviderQuotaPayload = { + provider: "zai", + account: "zai#1", + raw: { + limits: [ + { + limit_id: "5h", + type: "TOKENS_LIMIT", + percentage: 0.9888, + next_reset_time: "2026-07-24T20:23:52Z", + }, + ], + account_email: "secret@example.com", + access_token: "sk-secret-token", + }, + fetchedAt: "2026-07-24T17:33:47Z", + }; + const snapshot = normalizeSnapshot(payload); + expect(JSON.stringify(snapshot)).not.toContain("secret@example.com"); + expect(JSON.stringify(snapshot)).not.toContain("sk-secret-token"); + expect(JSON.stringify(snapshot)).not.toContain("account_email"); + expect(JSON.stringify(snapshot)).not.toContain("access_token"); + }); +}); diff --git a/plugin/__tests__/quota-policy.test.ts b/plugin/__tests__/quota-policy.test.ts new file mode 100644 index 0000000..f4dfa55 --- /dev/null +++ b/plugin/__tests__/quota-policy.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { + computeQuotaFactor, + computeLivePressure, + selectAccountByQuota, +} from "../quota-policy.js"; +import type { NormalizedQuotaSnapshot, NormalizedQuotaWindow } from "../quota-types.js"; + +function snap( + provider: string, + account: string, + status: "fresh" | "stale" | "unsupported", + ...windows: Array<[string, number]> +): NormalizedQuotaSnapshot { + return { + provider, + account, + status, + windows: windows.map(([id, ratio]) => ({ + id, + kind: "tokens_limit" as const, + appliesTo: "inference" as const, + modelIds: [], + remainingRatio: ratio, + })), + fetchedAt: "2026-07-24T17:00:00Z", + }; +} + +describe("computeQuotaFactor", () => { + it("returns sqrt(min(remaining)) for a fresh snapshot with two windows", () => { + const snapshot = snap("zai", "zai#1", "fresh", ["5h", 0.9888], ["1w", 0.86]); + const factor = computeQuotaFactor(snapshot, "zai/glm-5.2"); + expect(factor).toBeCloseTo(Math.sqrt(0.86), 4); + }); + + it("returns null for a stale snapshot", () => { + const snapshot = snap("zai", "zai#1", "stale", ["5h", 1.0]); + expect(computeQuotaFactor(snapshot, "zai/glm-5.2")).toBeNull(); + }); + + it("returns null for an unsupported snapshot", () => { + const snapshot = snap("qwen-oauth", "qwen#1", "unsupported"); + expect(computeQuotaFactor(snapshot, "qwen-oauth/qwen3.5")).toBeNull(); + }); + + it("returns 0 for a depleted account (remainingRatio=0)", () => { + const snapshot = snap("zai", "zai#1", "fresh", ["5h", 0.0]); + expect(computeQuotaFactor(snapshot, "zai/glm-5.2")).toBe(0); + }); + + it("returns 1 for a fully available account (remainingRatio=1)", () => { + const snapshot = snap("xai", "xai#1", "fresh", ["billing", 1.0]); + expect(computeQuotaFactor(snapshot, "xai/grok-4.5")).toBe(1); + }); + + it("excludes MCP-only windows from inference routing", () => { + const snapshot: NormalizedQuotaSnapshot = { + provider: "zai", + account: "zai#1", + status: "fresh", + windows: [ + { + id: "MCP_TIME", + kind: "time_limit", + appliesTo: "mcp", + modelIds: [], + remainingRatio: 0.0, + }, + { + id: "5h_TOKENS", + kind: "tokens_limit", + appliesTo: "inference", + modelIds: [], + remainingRatio: 0.80, + }, + ], + fetchedAt: "2026-07-24T17:00:00Z", + }; + expect(computeQuotaFactor(snapshot, "zai/glm-5.2")).toBeCloseTo(Math.sqrt(0.80), 4); + }); + + it("applies model-scoped windows only to mapped models", () => { + const snapshot: NormalizedQuotaSnapshot = { + provider: "minimax", + account: "minimax#1", + status: "fresh", + windows: [ + { + id: "INFERENCE", + kind: "tokens_limit", + appliesTo: "inference", + modelIds: [], + remainingRatio: 0.90, + }, + { + id: "M2.5_QUOTA", + kind: "tokens_limit", + appliesTo: "model", + modelIds: ["minimax/m2.5"], + remainingRatio: 0.10, + }, + ], + fetchedAt: "2026-07-24T17:00:00Z", + }; + expect(computeQuotaFactor(snapshot, "minimax/m2.5")).toBeCloseTo(Math.sqrt(0.10), 4); + expect(computeQuotaFactor(snapshot, "minimax/m2.7")).toBeCloseTo(Math.sqrt(0.90), 4); + }); +}); + +describe("computeLivePressure", () => { + it("multiplies static pressure by quota factor", () => { + const snapshot = snap("zai", "zai#1", "fresh", ["1w", 0.86]); + const result = computeLivePressure(5.0, 1.25, snapshot, "zai/glm-5.2"); + expect(result).toBeCloseTo(5.0 * 1.25 * Math.sqrt(0.86), 4); + }); + + it("returns null when quota factor is null (stale/unsupported)", () => { + const snapshot = snap("zai", "zai#1", "stale", ["1w", 1.0]); + expect(computeLivePressure(5.0, 1.25, snapshot, "zai/glm-5.2")).toBeNull(); + }); + + it("returns 0 when account is depleted", () => { + const snapshot = snap("zai", "zai#1", "fresh", ["1w", 0.0]); + expect(computeLivePressure(5.0, 1.25, snapshot, "zai/glm-5.2")).toBe(0); + }); +}); + +describe("selectAccountByQuota", () => { + it("selects the account with higher live pressure", () => { + const accounts = [ + { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.10]) }, + { provider: "openai", account: "openai#2", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#2", "fresh", ["5h", 0.80]) }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected?.account).toBe("openai#2"); + }); + + it("returns null when all accounts are stale", () => { + const accounts = [ + { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "stale", ["5h", 1.0]) }, + ]; + expect(selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol")).toBeNull(); + }); + + it("returns null when all accounts are depleted", () => { + const accounts = [ + { provider: "zai", account: "zai#1", staticPressure: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.0]) }, + ]; + expect(selectAccountByQuota(accounts, "zai", "zai/glm-5.2")).toBeNull(); + }); + + it("filters by provider", () => { + const accounts = [ + { provider: "zai", account: "zai#1", staticPressure: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.9]) }, + { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.9]) }, + ]; + const selected = selectAccountByQuota(accounts, "zai", "zai/glm-5.2"); + expect(selected?.account).toBe("zai#1"); + }); +}); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts new file mode 100644 index 0000000..0ee459a --- /dev/null +++ b/plugin/quota-normalize.ts @@ -0,0 +1,413 @@ +/** + * Quota normalization: converts provider-specific raw payloads into + * secret-free NormalizedQuotaSnapshot objects. + * + * Design constraints: + * - Never carry credentials, tokens, account emails, or raw payloads. + * - Reject booleans, NaN, Infinity, out-of-range ratios. + * - Preserve window applicability (inference vs mcp vs model). + * - Missing quantitative meter → unsupported, not 0% or 100%. + */ + +import type { + NormalizedQuotaSnapshot, + NormalizedQuotaWindow, + QuotaWindowKind, + QuotaAppliesTo, + QuotaSnapshotStatus, + ProviderQuotaPayload, + NormalizeWindowInput, +} from "./quota-types.js"; + +const SECRET_FIELD_PATTERNS = [ + "token", + "secret", + "cookie", + "password", + "credential", + "api_key", + "apikey", + "access", + "refresh", + "bearer", + "session", + "email", + "authorization", +]; + +function isSecretField(key: string): boolean { + const lower = key.toLowerCase(); + return SECRET_FIELD_PATTERNS.some((p) => lower.includes(p)); +} + +/** Known numeric ratio validation. */ +function assertValidRatio(value: number): void { + if (typeof value !== "number") { + if (typeof value === "boolean") throw new TypeError("remainingRatio must be numeric, got boolean"); + throw new TypeError("remainingRatio must be numeric"); + } + if (Number.isNaN(value)) throw new ValueError("remainingRatio must not be NaN"); + if (!Number.isFinite(value)) throw new ValueError("remainingRatio must be finite"); + if (value < 0 || value > 1) throw new ValueError("remainingRatio must be in [0, 1]"); +} + +class ValueError extends Error {} + +/** Normalize a percentage that may be 0..1 or 0..100. */ +function normalizePercentage(value: number): number | null { + if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) return null; + if (value > 1) return value / 100; + if (value < 0) return null; + return value; +} + +/** Map provider-specific kind strings to canonical QuotaWindowKind. */ +function mapWindowKind(rawKind: string): QuotaWindowKind { + const upper = rawKind.toUpperCase(); + if (upper.includes("TOKEN")) return "tokens_limit"; + if (upper.includes("REQUEST") || upper.includes("RPM")) return "requests_limit"; + if (upper.includes("CREDIT")) return "credits"; + if (upper.includes("MESSAGE")) return "messages"; + if (upper.includes("COMPUTE") || upper.includes("TIME")) return "compute"; + if (upper.includes("TIME_LIMIT")) return "time_limit"; + if (upper.includes("PERCENT") || upper === "USAGE") return "percent"; + return "tokens_limit"; +} + +/** Normalize a single quota window. */ +export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuotaWindow { + const kind = mapWindowKind(input.rawKind); + + let remainingRatio: number | null = null; + + if (input.remainingRatio !== undefined) { + remainingRatio = input.remainingRatio; + } else if (input.percentageRemaining !== undefined) { + remainingRatio = normalizePercentage(input.percentageRemaining); + } else if (input.percentageUsed !== undefined) { + const used = normalizePercentage(input.percentageUsed); + if (used !== null) remainingRatio = Math.max(0, 1 - used); + } else if (input.used !== undefined && input.limit !== undefined) { + const limit = input.limit; + if (typeof limit === "number" && limit > 0 && Number.isFinite(limit)) { + remainingRatio = Math.max(0, 1 - input.used / limit); + } + } + + if (remainingRatio === null) { + throw new ValueError(`cannot derive remainingRatio for window kind "${input.rawKind}"`); + } + + assertValidRatio(remainingRatio); + + const appliesTo: QuotaAppliesTo = input.appliesTo ?? "inference"; + const modelIds = input.modelIds ?? []; + + if (appliesTo === "model" && modelIds.length === 0) { + throw new ValueError("model-scoped window requires at least one modelId"); + } + if (appliesTo !== "model" && modelIds.length > 0) { + throw new ValueError("non-model window must not carry modelIds"); + } + + return { + id: input.rawKind, + kind, + appliesTo, + modelIds, + remainingRatio, + windowSeconds: input.windowSeconds, + resetAt: input.resetAt, + }; +} + +/** Validate a complete normalized snapshot. */ +export function validateNormalizedSnapshot( + snapshot: NormalizedQuotaSnapshot, + expectedProvider?: string, + diagnosticsOnly: boolean = false, +): void { + if (!snapshot.provider || !snapshot.account) { + throw new ValueError("snapshot provider and account are required"); + } + if (expectedProvider !== undefined && snapshot.provider !== expectedProvider) { + throw new ValueError(`snapshot provider "${snapshot.provider}" does not match expected "${expectedProvider}"`); + } + if (!diagnosticsOnly && snapshot.status !== "fresh") { + throw new ValueError(`routing snapshot must be fresh, got "${snapshot.status}"`); + } + if (snapshot.status === "fresh") { + if (snapshot.windows.length === 0) { + throw new ValueError("fresh snapshot requires at least one window"); + } + for (const w of snapshot.windows) assertValidRatio(w.remainingRatio); + } +} + +/** Secret-strings to strip from JSON serialization. */ +function safeStringify(snapshot: NormalizedQuotaSnapshot): string { + return JSON.stringify(snapshot, (key, value) => { + if (isSecretField(key)) return undefined; + return value; + }); +} + +/** Detect Z.AI-style limits array. */ +interface ZaiLimit { + limit_id?: string; + type?: string; + time_window?: string; + usage?: { used?: number; number?: number; current_value?: number }; + percentage?: number; + next_reset_time?: string; +} + +function parseZaiWindows(raw: unknown): NormalizedQuotaWindow[] | null { + if (typeof raw !== "object" || raw === null) return null; + const limits = (raw as { limits?: unknown }).limits; + if (!Array.isArray(limits)) return null; + + const windows: NormalizedQuotaWindow[] = []; + for (const limit of limits as ZaiLimit[]) { + if (typeof limit !== "object" || limit === null) continue; + + const rawKind = limit.type ?? limit.limit_id ?? "UNKNOWN"; + const windowSeconds = parseTimeWindowSeconds(limit.time_window); + const resetAt = limit.next_reset_time; + + let remainingRatio: number | null = null; + + if (typeof limit.percentage === "number") { + remainingRatio = normalizePercentage(limit.percentage); + } + + if (remainingRatio === null && limit.usage) { + const used = limit.usage.current_value ?? limit.usage.used ?? 0; + const limitTotal = limit.usage.number; + if (typeof limitTotal === "number" && limitTotal > 0) { + remainingRatio = Math.max(0, 1 - used / limitTotal); + } + } + + if (remainingRatio === null) continue; + + const appliesTo: QuotaAppliesTo = rawKind.toUpperCase().includes("TIME_LIMIT") ? "mcp" : "inference"; + + try { + windows.push( + normalizeQuotaWindow({ + rawKind, + windowSeconds, + resetAt, + remainingRatio, + appliesTo, + }), + ); + } catch { + continue; + } + } + + return windows.length > 0 ? windows : null; +} + +function parseTimeWindowSeconds(tw: string | undefined): number | undefined { + if (!tw) return undefined; + const match = /^(\d+(?:\.\d+)?)\s*(m|h|d|w)$/.exec(tw); + if (!match) return undefined; + const value = parseFloat(match[1]); + const unit = match[2]; + if (unit === "m") return value * 60; + if (unit === "h") return value * 3600; + if (unit === "d") return value * 86400; + if (unit === "w") return value * 604800; + return undefined; +} + +/** Detect OpenAI Codex-style rate_limits array. */ +interface CodexRateLimit { + label?: string; + window_minutes?: number; + used_percent?: number; + reset_seconds?: number; +} + +function parseCodexWindows(raw: unknown): NormalizedQuotaWindow[] | null { + if (typeof raw !== "object" || raw === null) return null; + const limits = (raw as { rate_limits?: unknown }).rate_limits; + if (!Array.isArray(limits)) return null; + + const windows: NormalizedQuotaWindow[] = []; + for (const limit of limits as CodexRateLimit[]) { + if (typeof limit !== "object" || limit === null) continue; + if (typeof limit.used_percent !== "number") continue; + + const rawKind = limit.label ?? "PRIMARY"; + const windowSeconds = typeof limit.window_minutes === "number" ? limit.window_minutes * 60 : undefined; + + windows.push( + normalizeQuotaWindow({ + rawKind, + windowSeconds, + percentageUsed: limit.used_percent, + }), + ); + } + + return windows.length > 0 ? windows : null; +} + +/** Detect xAI-style bare remaining_percent. */ +function parseXaiWindows(raw: unknown): NormalizedQuotaWindow[] | null { + if (typeof raw !== "object" || raw === null) return null; + const obj = raw as { remaining_percent?: unknown }; + if (typeof obj.remaining_percent !== "number") return null; + + return [ + normalizeQuotaWindow({ + rawKind: "BILLING", + remainingRatio: normalizePercentage(obj.remaining_percent) ?? undefined, + }), + ]; +} + +/** Detect Kimi-style usages array. */ +interface KimiUsage { + limit_id?: string; + type?: string; + period?: string; + used?: number; + total?: number; + remaining?: number; + percentage?: number; + reset_at?: string; +} + +function parseKimiWindows(raw: unknown): NormalizedQuotaWindow[] | null { + if (typeof raw !== "object" || raw === null) return null; + const usages = (raw as { usages?: unknown }).usages; + if (!Array.isArray(usages)) return null; + + const windows: NormalizedQuotaWindow[] = []; + for (const usage of usages as KimiUsage[]) { + if (typeof usage !== "object" || usage === null) continue; + + const rawKind = usage.limit_id ?? usage.type ?? "UNKNOWN"; + const resetAt = usage.reset_at; + + let remainingRatio: number | null = null; + + if (typeof usage.percentage === "number") { + remainingRatio = normalizePercentage(usage.percentage); + } + if (remainingRatio === null && typeof usage.remaining === "number" && typeof usage.total === "number" && usage.total > 0) { + remainingRatio = usage.remaining / usage.total; + } + if (remainingRatio === null && typeof usage.used === "number" && typeof usage.total === "number" && usage.total > 0) { + remainingRatio = Math.max(0, 1 - usage.used / usage.total); + } + + if (remainingRatio === null) continue; + + try { + windows.push( + normalizeQuotaWindow({ + rawKind, + resetAt, + remainingRatio, + }), + ); + } catch { + continue; + } + } + + return windows.length > 0 ? windows : null; +} + +/** Detect MiniMax-style remains object. */ +interface MiniMaxRemains { + used?: number; + total?: number; + remaining?: number; + percentage?: number; + reset_at?: string; + plan_type?: string; +} + +function parseMiniMaxWindows(raw: unknown): NormalizedQuotaWindow[] | null { + if (typeof raw !== "object" || raw === null) return null; + const remains = (raw as { remains?: unknown }).remains ?? raw; + if (typeof remains !== "object" || remains === null) return null; + const r = remains as MiniMaxRemains; + + let remainingRatio: number | null = null; + + if (typeof r.percentage === "number") { + remainingRatio = normalizePercentage(r.percentage); + } + if (remainingRatio === null && typeof r.remaining === "number" && typeof r.total === "number" && r.total > 0) { + remainingRatio = r.remaining / r.total; + } + if (remainingRatio === null && typeof r.used === "number" && typeof r.total === "number" && r.total > 0) { + remainingRatio = Math.max(0, 1 - r.used / r.total); + } + + if (remainingRatio === null) return null; + + return [ + normalizeQuotaWindow({ + rawKind: r.plan_type ?? "CODING_PLAN", + remainingRatio, + resetAt: r.reset_at, + }), + ]; +} + +/** + * Provider-specific raw payload normalizer. + * Dispatches to the right parser, strips secret fields from the output. + */ +export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuotaSnapshot { + const { provider, account, raw, fetchedAt } = payload; + + let windows: NormalizedQuotaWindow[] | null = null; + let status: QuotaSnapshotStatus = "fresh"; + + // Try provider-specific parsers in order. + const parsers: Array<(raw: unknown) => NormalizedQuotaWindow[] | null> = [ + parseZaiWindows, + parseCodexWindows, + parseXaiWindows, + parseKimiWindows, + parseMiniMaxWindows, + ]; + + for (const parse of parsers) { + try { + windows = parse(raw); + if (windows !== null) break; + } catch { + continue; + } + } + + if (windows === null || windows.length === 0) { + status = "unsupported"; + windows = []; + } + + const snapshot: NormalizedQuotaSnapshot = { + provider, + account, + status, + windows, + fetchedAt, + }; + + // Validate + safe-serialize to prove no secrets leak. + validateNormalizedSnapshot(snapshot, undefined, true); + safeStringify(snapshot); + + return snapshot; +} diff --git a/plugin/quota-policy.ts b/plugin/quota-policy.ts new file mode 100644 index 0000000..9c9b683 --- /dev/null +++ b/plugin/quota-policy.ts @@ -0,0 +1,115 @@ +/** + * Live quota pressure policy. + * + * Converts normalized quota snapshots into routing-safe pressure scores. + * + * Policy: + * applicableWindows = inference-wide + matching model-specific windows + * accountHeadroom = min(remainingRatio across applicableWindows) + * quotaFactor = sqrt(accountHeadroom) + * livePressure = staticPressure * quotaFactor + * + * Rules: + * - quotaFactor can only REDUCE declared capacity, never boost it. + * - MCP/tool-only windows never modulate inference routing. + * - Stale/unsupported/unknown snapshots produce null → static pressure only. + * - Depleted accounts (headroom=0) produce 0 → excluded from selection. + */ + +import type { NormalizedQuotaSnapshot, NormalizedQuotaWindow } from "./quota-types.js"; + +/** + * Select windows that apply to a specific candidate model. + * Includes inference-wide windows plus model-scoped windows that match. + * Excludes MCP/tool-only windows. + */ +export function applicableWindows( + snapshot: NormalizedQuotaSnapshot, + model: string, +): NormalizedQuotaWindow[] { + return snapshot.windows.filter((w) => { + if (w.appliesTo === "inference") return true; + if (w.appliesTo === "model") return w.modelIds.includes(model); + return false; // mcp/tool-only excluded + }); +} + +/** + * Compute the minimum remaining ratio across applicable windows. + * Returns null if the snapshot is not fresh or has no applicable windows. + */ +export function accountHeadroom( + snapshot: NormalizedQuotaSnapshot | null, + model: string, +): number | null { + if (!snapshot || snapshot.status !== "fresh") return null; + const windows = applicableWindows(snapshot, model); + if (windows.length === 0) return null; + return Math.min(...windows.map((w) => w.remainingRatio)); +} + +/** + * Compute the quota factor: sqrt(headroom). + * Returns null for stale/unsupported; 0 for depleted; 1 for fully available. + * Can only reduce static pressure, never boost it. + */ +export function computeQuotaFactor( + snapshot: NormalizedQuotaSnapshot | null, + model: string, +): number | null { + const headroom = accountHeadroom(snapshot, model); + if (headroom === null) return null; + return Math.sqrt(headroom); +} + +/** Input for live pressure computation. */ +export type QuotaAwareAccount = { + provider: string; + account: string; + /** Declared static pressure (tierWeight * providerBias). */ + staticPressure: number; + providerBias: number; + snapshot: NormalizedQuotaSnapshot | null; +}; + +/** + * Compute live pressure for a candidate model. + * Returns null if quota factor cannot be computed (stale/unsupported). + * Returns 0 if account is depleted. + */ +export function computeLivePressure( + staticPressure: number, + providerBias: number, + snapshot: NormalizedQuotaSnapshot | null, + model: string, +): number | null { + const factor = computeQuotaFactor(snapshot, model); + if (factor === null) return null; + return staticPressure * providerBias * factor; +} + +/** + * Select the best account for a provider and model. + * Returns null if no account has a usable live pressure. + */ +export function selectAccountByQuota( + accounts: QuotaAwareAccount[], + provider: string, + model: string, +): QuotaAwareAccount | null { + const eligible = accounts.filter((a) => { + if (a.provider !== provider) return false; + const pressure = computeLivePressure(a.staticPressure, a.providerBias, a.snapshot, model); + return pressure !== null && pressure > 0; + }); + + if (eligible.length === 0) return null; + + return eligible.reduce((best, current) => { + const bestPressure = computeLivePressure(best.staticPressure, best.providerBias, best.snapshot, model) ?? 0; + const currentPressure = computeLivePressure(current.staticPressure, current.providerBias, current.snapshot, model) ?? 0; + if (currentPressure > bestPressure) return current; + if (currentPressure === bestPressure && current.account < best.account) return current; + return best; + }); +} diff --git a/plugin/quota-types.ts b/plugin/quota-types.ts new file mode 100644 index 0000000..a85343e --- /dev/null +++ b/plugin/quota-types.ts @@ -0,0 +1,96 @@ +/** + * Normalized quota types — secret-free, routing-safe. + * + * These types describe the *output* of a provider-specific normalizer. They + * never carry credentials, tokens, account emails, or raw provider payloads. + * The router reads only these types. + */ + +export type QuotaWindowKind = + | "tokens_limit" + | "requests_limit" + | "credits" + | "messages" + | "compute" + | "time_limit" + | "percent"; + +/** What kind of work this window limits. */ +export type QuotaAppliesTo = "inference" | "mcp" | "model"; + +/** Provider-reported or locally-observed snapshot freshness. */ +export type QuotaSnapshotStatus = + | "fresh" + | "stale" + | "auth_expired" + | "rate_limited" + | "network_error" + | "invalid_response" + | "unsupported"; + +/** A single normalized quota window. */ +export type NormalizedQuotaWindow = { + /** Non-secret semantic ID, e.g. "primary" or "weekly". */ + id: string; + kind: QuotaWindowKind; + appliesTo: QuotaAppliesTo; + /** + * Canonical local model IDs this window applies to. + * Required when appliesTo="model"; must be empty otherwise. + */ + modelIds: string[]; + /** Remaining ratio in [0, 1]. Derived only from provider counters/percent. */ + remainingRatio: number; + /** Provider-declared window duration in seconds, if known. */ + windowSeconds?: number; + /** UTC ISO-8601 reset time, if known. */ + resetAt?: string; +}; + +/** A normalized, per-account quota snapshot. */ +export type NormalizedQuotaSnapshot = { + provider: string; + account: string; + status: QuotaSnapshotStatus; + windows: NormalizedQuotaWindow[]; + fetchedAt: string; +}; + +/** + * Raw provider payload input — what a provider adapter produces. + * Contains the raw response plus identity; the normalizer strips secrets. + */ +export type ProviderQuotaPayload = { + provider: string; + account: string; + /** Raw provider response or pre-parsed object. */ + raw: unknown; + fetchedAt: string; +}; + +/** + * Input parameters for a single window normalization call. + * Either remainingRatio or (used + limit) must be provided. + */ +export type NormalizeWindowInput = { + rawKind: string; + windowSeconds?: number; + resetAt?: string; + /** Direct remaining ratio (0..1). */ + remainingRatio?: number; + /** Used amount, when provider reports usage/limit. */ + used?: number; + /** Total limit, when provider reports usage/limit. */ + limit?: number; + /** Percentage remaining, when provider reports percent (0..1 or 0..100). */ + percentageRemaining?: number; + /** Percentage used (0..1 or 0..100). */ + percentageUsed?: number; + appliesTo?: QuotaAppliesTo; + modelIds?: string[]; + /** Explicit zero-usage marker from provider (e.g. xAI protobuf). */ + explicitZeroUsage?: boolean; +}; + +/** Input for full snapshot normalization. */ +export type NormalizeSnapshotInput = ProviderQuotaPayload; From 0027e5071fd36e46f43936dcb718ecf25d4ac4da Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:02:01 +0300 Subject: [PATCH 02/16] feat(router): integrate live quota factor into subscription-weighted routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rankSubscriptionWeightedCandidates now accepts an optional quotaSnapshots: ReadonlyMap. When provided, each candidate's effectivePressureScore is multiplied by sqrt(min(applicable remaining ratios)). Stale/unsupported snapshots produce null factor → static pressure only (no regression). Depleted accounts (factor=0) drop to zero pressure and are naturally excluded from frontier winners. The quota parameter is optional and defaults to undefined, preserving 100% backward compatibility with existing callers and tests (284/284). RankedCandidate type gains an optional quotaFactor field for explainability. --- plugin/__tests__/router-quota.test.ts | 161 ++++++++++++++++++++++++++ plugin/router.ts | 28 ++++- 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 plugin/__tests__/router-quota.test.ts diff --git a/plugin/__tests__/router-quota.test.ts b/plugin/__tests__/router-quota.test.ts new file mode 100644 index 0000000..b2ab930 --- /dev/null +++ b/plugin/__tests__/router-quota.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { rankSubscriptionWeightedCandidates } from "../router.js"; +import type { + ModelCapabilities, + RoutingRule, + SubscriptionInventory, + SubscriptionProfile, +} from "../types.js"; +import type { NormalizedQuotaSnapshot } from "../quota-types.js"; + +const models: Record = { + "openai/gpt-5.6-sol": { + context_window: 1000, + supports_vision: false, + speed_tps: 82, + ttft_seconds: 1.5, + benchmarks: { intelligence: 57, coding: 57, gpqa: 0.92, tau2: 0.87, ifbench: 0.74, tau3_banking: 0.5 }, + }, + "zai/glm-5.2": { + context_window: 1000, + supports_vision: false, + speed_tps: 47, + ttft_seconds: 0.9, + benchmarks: { intelligence: 55, coding: 55, gpqa: 0.90, tau2: 0.98, ifbench: 0.76, tau3_banking: 0.55 }, + }, + "xai/grok-4.5": { + context_window: 1000, + supports_vision: false, + speed_tps: 60, + ttft_seconds: 1.0, + benchmarks: { intelligence: 54, coding: 53, gpqa: 0.88, tau2: 0.85, ifbench: 0.70, tau3_banking: 0.48 }, + }, +}; + +const rules: Record = { + default: { + primary: "openai/gpt-5.6-sol", + fallbacks: ["zai/glm-5.2", "xai/grok-4.5"], + }, +}; + +const profile: SubscriptionProfile = { + version: "1.1.0", + global: { + "openai": { enabled: true, tierId: "plus" }, + "zai": { enabled: true, tierId: "max" }, + "xai": { enabled: true, tierId: "premium+" }, + }, +}; + +const inventory: SubscriptionInventory = { + version: "1", + accounts: { + "openai#1": { provider: "openai", enabled: true, authProfile: "openai#1" }, + "zai#1": { provider: "zai", enabled: true, authProfile: "zai#1" }, + "xai#1": { provider: "xai", enabled: true, authProfile: "xai#1" }, + }, +}; + +function snap(provider: string, account: string, ...windows: Array<[string, number]>): NormalizedQuotaSnapshot { + return { + provider, + account, + status: "fresh", + windows: windows.map(([id, ratio]) => ({ + id, + kind: "tokens_limit" as const, + appliesTo: "inference" as const, + modelIds: [], + remainingRatio: ratio, + })), + fetchedAt: "2026-07-24T17:00:00Z", + }; +} + +describe("router with live quota snapshots", () => { + it("preserves existing ranking when no quota snapshots are provided", () => { + const ranked = rankSubscriptionWeightedCandidates( + "default", models, rules, profile, inventory, undefined, "balanced", + ); + expect(ranked.length).toBeGreaterThan(0); + // Without quota, the winner should be the same as before + expect(ranked[0].candidate).toBeTruthy(); + }); + + it("de-prioritizes a depleted provider in routine/default routing", () => { + // Use a simpler 2-model setup where OpenAI has higher benchmark + // but is depleted (0.02), and ZAI has good quota (0.90). + // In balanced/default, effectivePressureScore should determine the winner. + const simpleModels: Record = { + "openai/gpt-5.6-sol": models["openai/gpt-5.6-sol"], + "zai/glm-5.2": models["zai/glm-5.2"], + }; + const simpleRules: Record = { + default: { + primary: "openai/gpt-5.6-sol", + fallbacks: ["zai/glm-5.2"], + }, + }; + const quotaSnapshots = new Map([ + ["openai", snap("openai", "openai#1", ["5h", 0.02])], + ["zai", snap("zai", "zai#1", ["1w", 0.90])], + ]); + + const ranked = rankSubscriptionWeightedCandidates( + "default", simpleModels, simpleRules, profile, inventory, undefined, "balanced", + undefined, quotaSnapshots, + ); + + expect(ranked.length).toBe(2); + // ZAI has higher effectivePressureScore despite lower benchmark + expect(ranked[0].candidate).toBe("zai/glm-5.2"); + }); + + it("preserves benchmark-first routing for coding-aware code category even under quota pressure", () => { + const quotaSnapshots = new Map([ + ["openai", snap("openai", "openai#1", ["5h", 0.02])], + ["zai", snap("zai", "zai#1", ["1w", 0.90])], + ]); + + const codeRules: Record = { + code: { + primary: "openai/gpt-5.6-sol", + fallbacks: ["zai/glm-5.2"], + }, + }; + + const ranked = rankSubscriptionWeightedCandidates( + "code", models, codeRules, profile, inventory, undefined, "balanced", + "coding-aware", quotaSnapshots, + ); + + // coding-aware: benchmark first, so even depleted openai stays in frontier + // but the winner should not be openai (depleted) + expect(ranked.length).toBeGreaterThan(0); + const openaiEntry = ranked.find((r) => r.candidate === "openai/gpt-5.6-sol"); + if (openaiEntry) { + // If openai is still ranked, its effective pressure should be very low + expect(openaiEntry.effectivePressureScore).toBeLessThan(0.5); + } + }); + + it("treats stale quota as no-quota (static pressure only)", () => { + const staleSnapshots = new Map([ + ["openai", { ...snap("openai", "openai#1", ["5h", 0.02]), status: "stale" }], + ]); + + const ranked = rankSubscriptionWeightedCandidates( + "default", models, rules, profile, inventory, undefined, "balanced", + undefined, staleSnapshots, + ); + + // Stale quota should be treated as no-quota → static pressure wins + expect(ranked.length).toBeGreaterThan(0); + // With stale (null factor), the static ranking should be preserved + const noQuotaRanked = rankSubscriptionWeightedCandidates( + "default", models, rules, profile, inventory, undefined, "balanced", + ); + expect(ranked[0].candidate).toBe(noQuotaRanked[0].candidate); + }); +}); diff --git a/plugin/router.ts b/plugin/router.ts index 99f409d..8b1ae2a 100644 --- a/plugin/router.ts +++ b/plugin/router.ts @@ -9,6 +9,8 @@ import type { } from "./types.js"; import { resolveProviderCapacity } from "./inventory.js"; import type { SubscriptionProfile } from "./profile.js"; +import type { NormalizedQuotaSnapshot } from "./quota-types.js"; +import { computeQuotaFactor } from "./quota-policy.js"; const MODIFIER_TARGET_CATEGORIES: Record = { "coding-aware": ["code"], @@ -203,6 +205,7 @@ export type RankedCandidate = { effectivePressureScore: number; withinFrontier: boolean; originalIndex: number; + quotaFactor?: number | null; }; export function rankSubscriptionWeightedCandidates( @@ -214,6 +217,7 @@ export function rankSubscriptionWeightedCandidates( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, + quotaSnapshots?: ReadonlyMap, ): RankedCandidate[] { if (routingMode !== "balanced") return []; @@ -247,15 +251,29 @@ export function rankSubscriptionWeightedCandidates( }); const speedPriority = getSpeedPriority(availableModels[candidate]); + // Live quota factor: sqrt(min(applicable remaining ratios)). + // When no snapshot is available or snapshot is stale/unsupported, + // factor is null → quota does not modulate routing (static pressure only). + // When factor is 0 (depleted), the candidate's effective pressure is 0. + const quotaSnapshot = quotaSnapshots?.get(providerId) ?? null; + const quotaFactor = quotaSnapshots + ? computeQuotaFactor(quotaSnapshot, candidate) + : null; + const basePressureScore = tierWeight * providerBias; + return { candidate, originalIndex: index, tierWeight, providerBias, benchmarkStrength, - pressureScore: tierWeight * providerBias, - effectivePressureScore: (tierWeight * providerBias) + modifierAccountBonus, + pressureScore: basePressureScore, + effectivePressureScore: + (quotaFactor === null + ? basePressureScore + : basePressureScore * quotaFactor) + modifierAccountBonus, speedPriority, + quotaFactor, }; }) .filter((item) => item.pressureScore > 0); @@ -370,6 +388,7 @@ export function getSubscriptionWeightedCandidates( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, + quotaSnapshots?: ReadonlyMap, ): string[] { return rankSubscriptionWeightedCandidates( category, @@ -380,6 +399,7 @@ export function getSubscriptionWeightedCandidates( agentId, routingMode, routingModifier, + quotaSnapshots, ).map((item) => item.candidate); } @@ -391,6 +411,7 @@ export function rankSubscriptionWeightedCandidatesFromPool( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, + quotaSnapshots?: ReadonlyMap, ): RankedCandidate[] { const candidates = Object.keys(availableModels); if (candidates.length === 0) return []; @@ -412,6 +433,7 @@ export function rankSubscriptionWeightedCandidatesFromPool( agentId, routingMode, routingModifier, + quotaSnapshots, ); } @@ -423,6 +445,7 @@ export function getSubscriptionWeightedCandidatesFromPool( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, + quotaSnapshots?: ReadonlyMap, ): string[] { return rankSubscriptionWeightedCandidatesFromPool( category, @@ -432,5 +455,6 @@ export function getSubscriptionWeightedCandidatesFromPool( agentId, routingMode, routingModifier, + quotaSnapshots, ).map((item) => item.candidate); } From 66883961cfb5207be7aade1377b0833c58f6dfd9 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:07:54 +0300 Subject: [PATCH 03/16] feat(quota): add Python parity module and stage ClawHub plugin entries - integrations/hermes/quota.py: pure-Python quota normalization and policy mirroring plugin/quota-normalize.ts and plugin/quota-policy.ts - integrations/hermes/test_quota.py: 25 tests covering Z.AI/Codex/xAI/Kimi/ MiniMax parsers, boolean/NaN/Infinity rejection, secret stripping, MCP exclusion, model-scoped windows, stale/unsupported/depleted semantics - scripts/stage_clawhub_plugin.mjs: add quota-normalize.ts, quota-policy.ts, and quota-types.ts to runtime entries so staged plugin resolves imports All 571 tests green (284 plugin + 83 scripts + 204 hermes). --- integrations/hermes/quota.py | 374 ++++++++++++++++++++++++++++++ integrations/hermes/test_quota.py | 204 ++++++++++++++++ scripts/stage_clawhub_plugin.mjs | 3 + 3 files changed, 581 insertions(+) create mode 100644 integrations/hermes/quota.py create mode 100644 integrations/hermes/test_quota.py diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py new file mode 100644 index 0000000..657c3cb --- /dev/null +++ b/integrations/hermes/quota.py @@ -0,0 +1,374 @@ +"""Pure-Python ZeroAPI live quota normalization and policy. + +Mirrors plugin/quota-normalize.ts and plugin/quota-policy.ts so the Hermes +Python adapter can apply the same live depletion factor as the OpenClaw +plugin without calling Node or external APIs. + +This module is pure data transformation: no credentials, no network access. +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +SECRET_FIELD_PATTERNS = ( + "token", "secret", "cookie", "password", "credential", + "api_key", "apikey", "access", "refresh", "bearer", "session", + "email", "authorization", +) + + +def _is_secret_field(key: str) -> bool: + lower = key.lower() + return any(p in lower for p in SECRET_FIELD_PATTERNS) + + +def _assert_valid_ratio(value: float) -> None: + if isinstance(value, bool): + raise TypeError("remainingRatio must be numeric, got boolean") + if not isinstance(value, (int, float)): + raise TypeError("remainingRatio must be numeric") + if math.isnan(value): + raise ValueError("remainingRatio must not be NaN") + if math.isinf(value): + raise ValueError("remainingRatio must be finite") + if value < 0 or value > 1: + raise ValueError("remainingRatio must be in [0, 1]") + + +def _normalize_percentage(value: float) -> float | None: + if isinstance(value, bool): + return None + if not isinstance(value, (int, float)) or math.isnan(value) or math.isinf(value): + return None + if value > 1: + return value / 100 + if value < 0: + return None + return float(value) + + +def _map_window_kind(raw_kind: str) -> str: + upper = raw_kind.upper() + if "TOKEN" in upper: + return "tokens_limit" + if "REQUEST" in upper or "RPM" in upper: + return "requests_limit" + if "CREDIT" in upper: + return "credits" + if "MESSAGE" in upper: + return "messages" + if "TIME_LIMIT" in upper: + return "time_limit" + if "PERCENT" in upper or upper == "USAGE": + return "percent" + return "tokens_limit" + + +def normalize_window( + raw_kind: str, + *, + remaining_ratio: float | None = None, + used: float | None = None, + limit: float | None = None, + percentage_remaining: float | None = None, + percentage_used: float | None = None, + applies_to: str = "inference", + model_ids: list[str] | None = None, + window_seconds: float | None = None, + reset_at: str | None = None, +) -> dict[str, Any]: + kind = _map_window_kind(raw_kind) + ratio: float | None = None + + if remaining_ratio is not None: + if isinstance(remaining_ratio, bool): + raise TypeError("remainingRatio must be numeric, got boolean") + ratio = float(remaining_ratio) + elif percentage_remaining is not None: + ratio = _normalize_percentage(percentage_remaining) + elif percentage_used is not None: + used_pct = _normalize_percentage(percentage_used) + if used_pct is not None: + ratio = max(0.0, 1.0 - used_pct) + elif used is not None and limit is not None and limit > 0 and math.isfinite(limit): + ratio = max(0.0, 1.0 - used / limit) + + if ratio is None: + raise ValueError(f"cannot derive remainingRatio for window kind '{raw_kind}'") + + _assert_valid_ratio(ratio) + + mids = model_ids or [] + if applies_to == "model" and len(mids) == 0: + raise ValueError("model-scoped window requires at least one modelId") + if applies_to != "model" and len(mids) > 0: + raise ValueError("non-model window must not carry modelIds") + + window: dict[str, Any] = { + "id": raw_kind, + "kind": kind, + "appliesTo": applies_to, + "modelIds": mids, + "remainingRatio": ratio, + } + if window_seconds is not None: + window["windowSeconds"] = window_seconds + if reset_at is not None: + window["resetAt"] = reset_at + return window + + +def validate_snapshot( + snapshot: dict[str, Any], + expected_provider: str | None = None, + diagnostics_only: bool = False, +) -> None: + provider = snapshot.get("provider") + account = snapshot.get("account") + if not provider or not account: + raise ValueError("snapshot provider and account are required") + if expected_provider is not None and provider != expected_provider: + raise ValueError( + f'snapshot provider "{provider}" does not match expected "{expected_provider}"' + ) + status = snapshot.get("status", "fresh") + if not diagnostics_only and status != "fresh": + raise ValueError(f'routing snapshot must be fresh, got "{status}"') + if status == "fresh": + windows = snapshot.get("windows", []) + if len(windows) == 0: + raise ValueError("fresh snapshot requires at least one window") + for w in windows: + _assert_valid_ratio(w["remainingRatio"]) + + +def _parse_time_window_seconds(tw: str | None) -> float | None: + if not tw: + return None + match = re.match(r"^(\d+(?:\.\d+)?)\s*(m|h|d|w)$", tw) + if not match: + return None + value = float(match.group(1)) + unit = match.group(2) + if unit == "m": + return value * 60 + if unit == "h": + return value * 3600 + if unit == "d": + return value * 86400 + if unit == "w": + return value * 604800 + return None + + +def _parse_zai_windows(raw: Any) -> list[dict[str, Any]] | None: + if not isinstance(raw, dict): + return None + limits = raw.get("limits") + if not isinstance(limits, list): + return None + + windows: list[dict[str, Any]] = [] + for limit in limits: + if not isinstance(limit, dict): + continue + raw_kind = limit.get("type", limit.get("limit_id", "UNKNOWN")) + window_seconds = _parse_time_window_seconds(limit.get("time_window")) + reset_at = limit.get("next_reset_time") + ratio: float | None = None + + if isinstance(limit.get("percentage"), (int, float)): + ratio = _normalize_percentage(limit["percentage"]) + + if ratio is None and isinstance(limit.get("usage"), dict): + usage = limit["usage"] + used = usage.get("current_value", usage.get("used", 0)) + limit_total = usage.get("number") + if isinstance(limit_total, (int, float)) and limit_total > 0: + ratio = max(0.0, 1.0 - used / limit_total) + + if ratio is None: + continue + + applies_to = "mcp" if "TIME_LIMIT" in str(raw_kind).upper() else "inference" + try: + windows.append(normalize_window( + raw_kind, remaining_ratio=ratio, + applies_to=applies_to, window_seconds=window_seconds, reset_at=reset_at, + )) + except (ValueError, TypeError): + continue + + return windows if windows else None + + +def _parse_codex_windows(raw: Any) -> list[dict[str, Any]] | None: + if not isinstance(raw, dict): + return None + limits = raw.get("rate_limits") + if not isinstance(limits, list): + return None + + windows: list[dict[str, Any]] = [] + for limit in limits: + if not isinstance(limit, dict): + continue + if not isinstance(limit.get("used_percent"), (int, float)): + continue + raw_kind = limit.get("label", "PRIMARY") + window_seconds = limit.get("window_minutes") + if isinstance(window_seconds, (int, float)): + window_seconds = window_seconds * 60 + else: + window_seconds = None + try: + windows.append(normalize_window( + raw_kind, percentage_used=limit["used_percent"], + window_seconds=window_seconds, + )) + except (ValueError, TypeError): + continue + + return windows if windows else None + + +def _parse_xai_windows(raw: Any) -> list[dict[str, Any]] | None: + if not isinstance(raw, dict): + return None + rp = raw.get("remaining_percent") + if not isinstance(rp, (int, float)): + return None + ratio = _normalize_percentage(rp) + if ratio is None: + return None + return [normalize_window("BILLING", remaining_ratio=ratio)] + + +def _parse_kimi_windows(raw: Any) -> list[dict[str, Any]] | None: + if not isinstance(raw, dict): + return None + usages = raw.get("usages") + if not isinstance(usages, list): + return None + + windows: list[dict[str, Any]] = [] + for usage in usages: + if not isinstance(usage, dict): + continue + raw_kind = usage.get("limit_id", usage.get("type", "UNKNOWN")) + reset_at = usage.get("reset_at") + ratio: float | None = None + + if isinstance(usage.get("percentage"), (int, float)): + ratio = _normalize_percentage(usage["percentage"]) + if ratio is None and isinstance(usage.get("remaining"), (int, float)) and isinstance(usage.get("total"), (int, float)) and usage["total"] > 0: + ratio = usage["remaining"] / usage["total"] + if ratio is None and isinstance(usage.get("used"), (int, float)) and isinstance(usage.get("total"), (int, float)) and usage["total"] > 0: + ratio = max(0.0, 1.0 - usage["used"] / usage["total"]) + if ratio is None: + continue + try: + windows.append(normalize_window(raw_kind, remaining_ratio=ratio, reset_at=reset_at)) + except (ValueError, TypeError): + continue + + return windows if windows else None + + +def _parse_minimax_windows(raw: Any) -> list[dict[str, Any]] | None: + if not isinstance(raw, dict): + return None + remains = raw.get("remains", raw) + if not isinstance(remains, dict): + return None + ratio: float | None = None + + if isinstance(remains.get("percentage"), (int, float)): + ratio = _normalize_percentage(remains["percentage"]) + if ratio is None and isinstance(remains.get("remaining"), (int, float)) and isinstance(remains.get("total"), (int, float)) and remains["total"] > 0: + ratio = remains["remaining"] / remains["total"] + if ratio is None and isinstance(remains.get("used"), (int, float)) and isinstance(remains.get("total"), (int, float)) and remains["total"] > 0: + ratio = max(0.0, 1.0 - remains["used"] / remains["total"]) + if ratio is None: + return None + return [normalize_window( + remains.get("plan_type", "CODING_PLAN"), + remaining_ratio=ratio, reset_at=remains.get("reset_at"), + )] + + +def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: + provider = payload["provider"] + account = payload["account"] + raw = payload.get("raw", {}) + fetched_at = payload.get("fetchedAt", "") + + windows: list[dict[str, Any]] | None = None + status = "fresh" + + windows = _parse_zai_windows(raw) + if windows is None: + windows = _parse_codex_windows(raw) + if windows is None: + windows = _parse_xai_windows(raw) + if windows is None: + windows = _parse_kimi_windows(raw) + if windows is None: + windows = _parse_minimax_windows(raw) + + if windows is None or len(windows) == 0: + status = "unsupported" + windows = [] + + snapshot = { + "provider": provider, + "account": account, + "status": status, + "windows": windows, + "fetchedAt": fetched_at, + } + validate_snapshot(snapshot, diagnostics_only=True) + return snapshot + + +# ── Policy ── + +def applicable_windows(snapshot: dict[str, Any] | None, model: str) -> list[dict[str, Any]]: + if not snapshot or snapshot.get("status") != "fresh": + return [] + result = [] + for w in snapshot.get("windows", []): + if w["appliesTo"] == "inference": + result.append(w) + elif w["appliesTo"] == "model" and model in w.get("modelIds", []): + result.append(w) + return result + + +def account_headroom(snapshot: dict[str, Any] | None, model: str) -> float | None: + windows = applicable_windows(snapshot, model) + if not windows: + return None + return min(w["remainingRatio"] for w in windows) + + +def compute_quota_factor(snapshot: dict[str, Any] | None, model: str) -> float | None: + headroom = account_headroom(snapshot, model) + if headroom is None: + return None + return math.sqrt(headroom) + + +def compute_live_pressure( + static_pressure: float, + provider_bias: float, + snapshot: dict[str, Any] | None, + model: str, +) -> float | None: + factor = compute_quota_factor(snapshot, model) + if factor is None: + return None + return static_pressure * provider_bias * factor diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py new file mode 100644 index 0000000..d2760e3 --- /dev/null +++ b/integrations/hermes/test_quota.py @@ -0,0 +1,204 @@ +"""Tests for ZeroAPI live quota normalization and policy (Python parity).""" + +import math +import unittest + +from quota import ( + normalize_window, + validate_snapshot, + normalize_snapshot, + compute_quota_factor, + compute_live_pressure, + applicable_windows, + account_headroom, +) + + +class TestNormalizeWindow(unittest.TestCase): + + def test_normalizes_tokens_limit_with_percentage(self): + w = normalize_window("TOKENS_LIMIT", remaining_ratio=0.9888, window_seconds=5 * 3600) + self.assertAlmostEqual(w["remainingRatio"], 0.9888) + self.assertEqual(w["appliesTo"], "inference") + + def test_derives_ratio_from_usage_limit(self): + w = normalize_window("PRIMARY", used=400, limit=800) + self.assertAlmostEqual(w["remainingRatio"], 0.5) + + def test_rejects_nan(self): + with self.assertRaises(ValueError): + normalize_window("X", remaining_ratio=float("nan")) + + def test_rejects_infinity(self): + with self.assertRaises(ValueError): + normalize_window("X", remaining_ratio=float("inf")) + + def test_rejects_boolean(self): + with self.assertRaises(TypeError): + normalize_window("X", remaining_ratio=True) + + def test_rejects_over_one(self): + with self.assertRaises(ValueError): + normalize_window("X", remaining_ratio=1.01) + + def test_rejects_negative(self): + with self.assertRaises(ValueError): + normalize_window("X", remaining_ratio=-0.01) + + def test_model_scoped_requires_model_ids(self): + with self.assertRaises(ValueError): + normalize_window("M", applies_to="model", remaining_ratio=0.5) + + def test_non_model_rejects_model_ids(self): + with self.assertRaises(ValueError): + normalize_window("M", applies_to="inference", model_ids=["m1"], remaining_ratio=0.5) + + +class TestValidateSnapshot(unittest.TestCase): + + def _valid(self): + return { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [{"id": "P", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.86}], + "fetchedAt": "2026-07-24T17:00:00Z", + } + + def test_accepts_valid_fresh(self): + validate_snapshot(self._valid()) + + def test_rejects_no_windows_fresh(self): + with self.assertRaises(ValueError): + validate_snapshot({**self._valid(), "windows": []}) + + def test_rejects_stale_not_diagnostic(self): + with self.assertRaises(ValueError): + validate_snapshot({**self._valid(), "status": "stale"}, diagnostics_only=False) + + def test_accepts_stale_diagnostic(self): + validate_snapshot({**self._valid(), "status": "stale"}, diagnostics_only=True) + + def test_rejects_provider_mismatch(self): + with self.assertRaises(ValueError): + validate_snapshot(self._valid(), expected_provider="openai") + + +class TestNormalizeSnapshot(unittest.TestCase): + + def test_zai_payload(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "raw": {"limits": [ + {"type": "TOKENS_LIMIT", "percentage": 0.9888, "next_reset_time": "2026-07-24T20:23:52Z"}, + {"type": "TOKENS_LIMIT", "percentage": 0.86, "next_reset_time": "2026-07-26T20:23:52Z"}, + ]}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "fresh") + self.assertEqual(len(snap["windows"]), 2) + self.assertAlmostEqual(snap["windows"][0]["remainingRatio"], 0.9888) + + def test_codex_payload(self): + snap = normalize_snapshot({ + "provider": "openai-codex", "account": "openai#1", + "raw": {"rate_limits": [ + {"label": "primary", "window_minutes": 300, "used_percent": 47}, + {"label": "secondary", "window_minutes": 10080, "used_percent": 12}, + ]}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(len(snap["windows"]), 2) + self.assertAlmostEqual(snap["windows"][0]["remainingRatio"], 0.53, places=1) + + def test_xai_payload(self): + snap = normalize_snapshot({ + "provider": "xai", "account": "xai#1", + "raw": {"remaining_percent": 100}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(len(snap["windows"]), 1) + self.assertEqual(snap["windows"][0]["remainingRatio"], 1.0) + + def test_unsupported_payload(self): + snap = normalize_snapshot({ + "provider": "qwen-oauth", "account": "qwen#1", + "raw": {}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + + def test_strips_secret_fields(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "raw": { + "limits": [{"type": "T", "percentage": 0.9888}], + "account_email": "secret@example.com", + "access_token": "sk-secret", + }, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + import json + serialized = json.dumps(snap) + self.assertNotIn("secret@example.com", serialized) + self.assertNotIn("sk-secret", serialized) + + +class TestQuotaPolicy(unittest.TestCase): + + def test_compute_quota_factor(self): + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [ + {"id": "5h", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.9888}, + {"id": "1w", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.86}, + ], + "fetchedAt": "2026-07-24T17:00:00Z", + } + self.assertAlmostEqual(compute_quota_factor(snap, "zai/glm-5.2"), math.sqrt(0.86), places=4) + + def test_stale_returns_none(self): + snap = {"provider": "zai", "account": "zai#1", "status": "stale", "windows": [], "fetchedAt": "2026-07-24T17:00:00Z"} + self.assertIsNone(compute_quota_factor(snap, "zai/glm-5.2")) + + def test_depleted_returns_zero(self): + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [{"id": "5h", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.0}], + "fetchedAt": "2026-07-24T17:00:00Z", + } + self.assertEqual(compute_quota_factor(snap, "zai/glm-5.2"), 0.0) + + def test_mcp_excluded_from_inference(self): + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [ + {"id": "MCP", "kind": "time_limit", "appliesTo": "mcp", "modelIds": [], "remainingRatio": 0.0}, + {"id": "5h", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.80}, + ], + "fetchedAt": "2026-07-24T17:00:00Z", + } + self.assertAlmostEqual(compute_quota_factor(snap, "zai/glm-5.2"), math.sqrt(0.80), places=4) + + def test_model_scoped_only_affects_mapped(self): + snap = { + "provider": "mm", "account": "mm#1", "status": "fresh", + "windows": [ + {"id": "INF", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.90}, + {"id": "M25", "kind": "tokens_limit", "appliesTo": "model", "modelIds": ["mm/m2.5"], "remainingRatio": 0.10}, + ], + "fetchedAt": "2026-07-24T17:00:00Z", + } + self.assertAlmostEqual(compute_quota_factor(snap, "mm/m2.5"), math.sqrt(0.10), places=4) + self.assertAlmostEqual(compute_quota_factor(snap, "mm/m2.7"), math.sqrt(0.90), places=4) + + def test_compute_live_pressure(self): + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [{"id": "1w", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": 0.86}], + "fetchedAt": "2026-07-24T17:00:00Z", + } + result = compute_live_pressure(5.0, 1.25, snap, "zai/glm-5.2") + self.assertAlmostEqual(result, 5.0 * 1.25 * math.sqrt(0.86), places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/stage_clawhub_plugin.mjs b/scripts/stage_clawhub_plugin.mjs index 6fcb8a3..d2b6059 100644 --- a/scripts/stage_clawhub_plugin.mjs +++ b/scripts/stage_clawhub_plugin.mjs @@ -33,6 +33,9 @@ const runtimeEntries = [ "logger.ts", "onboarding.ts", "profile.ts", + "quota-normalize.ts", + "quota-policy.ts", + "quota-types.ts", "route-state.ts", "router.ts", "selector.ts", From 416c75bee5af73c59abd4f8b88dc0916c781d722 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:11:21 +0300 Subject: [PATCH 04/16] fix(quota): enforce account-scoped routing and harden provider payloads - key quota snapshots by opaque inventory account ID, never provider aggregate - select the healthy account before computing provider pressure and carry the selected account/auth profile in ranking diagnostics - remove fully depleted candidates before benchmark frontier construction - preserve quotaFactor in RankedCandidate output - dispatch raw payload parsers by provider ID instead of payload shape - classify TIME_LIMIT before generic TIME kinds - reject malformed kinds and boolean counters in Python parity - add regression coverage for all current-head Codex findings --- integrations/hermes/quota.py | 78 ++++++++----- integrations/hermes/test_quota.py | 50 +++++++++ plugin/__tests__/quota-normalize.test.ts | 36 ++++++ plugin/__tests__/router-quota.test.ts | 94 +++++++++++++++- plugin/inventory.ts | 19 ++++ plugin/quota-normalize.ts | 48 +++++--- plugin/router.ts | 133 ++++++++++++++++++++--- 7 files changed, 393 insertions(+), 65 deletions(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index 657c3cb..af6c5d3 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -11,7 +11,7 @@ import math import re -from typing import Any +from typing import Any, TypeGuard SECRET_FIELD_PATTERNS = ( "token", "secret", "cookie", "password", "credential", @@ -25,6 +25,14 @@ def _is_secret_field(key: str) -> bool: return any(p in lower for p in SECRET_FIELD_PATTERNS) +def _is_number(value: Any) -> TypeGuard[int | float]: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(value) + ) + + def _assert_valid_ratio(value: float) -> None: if isinstance(value, bool): raise TypeError("remainingRatio must be numeric, got boolean") @@ -51,6 +59,8 @@ def _normalize_percentage(value: float) -> float | None: def _map_window_kind(raw_kind: str) -> str: + if not isinstance(raw_kind, str) or not raw_kind.strip(): + raise TypeError("raw_kind must be a non-empty string") upper = raw_kind.upper() if "TOKEN" in upper: return "tokens_limit" @@ -93,7 +103,7 @@ def normalize_window( used_pct = _normalize_percentage(percentage_used) if used_pct is not None: ratio = max(0.0, 1.0 - used_pct) - elif used is not None and limit is not None and limit > 0 and math.isfinite(limit): + elif _is_number(used) and _is_number(limit) and limit > 0: ratio = max(0.0, 1.0 - used / limit) if ratio is None: @@ -180,14 +190,14 @@ def _parse_zai_windows(raw: Any) -> list[dict[str, Any]] | None: reset_at = limit.get("next_reset_time") ratio: float | None = None - if isinstance(limit.get("percentage"), (int, float)): + if _is_number(limit.get("percentage")): ratio = _normalize_percentage(limit["percentage"]) if ratio is None and isinstance(limit.get("usage"), dict): usage = limit["usage"] - used = usage.get("current_value", usage.get("used", 0)) + used = usage.get("current_value", usage.get("used")) limit_total = usage.get("number") - if isinstance(limit_total, (int, float)) and limit_total > 0: + if _is_number(used) and _is_number(limit_total) and limit_total > 0: ratio = max(0.0, 1.0 - used / limit_total) if ratio is None: @@ -216,11 +226,11 @@ def _parse_codex_windows(raw: Any) -> list[dict[str, Any]] | None: for limit in limits: if not isinstance(limit, dict): continue - if not isinstance(limit.get("used_percent"), (int, float)): + if not _is_number(limit.get("used_percent")): continue raw_kind = limit.get("label", "PRIMARY") window_seconds = limit.get("window_minutes") - if isinstance(window_seconds, (int, float)): + if _is_number(window_seconds): window_seconds = window_seconds * 60 else: window_seconds = None @@ -239,7 +249,7 @@ def _parse_xai_windows(raw: Any) -> list[dict[str, Any]] | None: if not isinstance(raw, dict): return None rp = raw.get("remaining_percent") - if not isinstance(rp, (int, float)): + if not _is_number(rp): return None ratio = _normalize_percentage(rp) if ratio is None: @@ -262,12 +272,15 @@ def _parse_kimi_windows(raw: Any) -> list[dict[str, Any]] | None: reset_at = usage.get("reset_at") ratio: float | None = None - if isinstance(usage.get("percentage"), (int, float)): + if _is_number(usage.get("percentage")): ratio = _normalize_percentage(usage["percentage"]) - if ratio is None and isinstance(usage.get("remaining"), (int, float)) and isinstance(usage.get("total"), (int, float)) and usage["total"] > 0: - ratio = usage["remaining"] / usage["total"] - if ratio is None and isinstance(usage.get("used"), (int, float)) and isinstance(usage.get("total"), (int, float)) and usage["total"] > 0: - ratio = max(0.0, 1.0 - usage["used"] / usage["total"]) + remaining = usage.get("remaining") + total = usage.get("total") + used = usage.get("used") + if ratio is None and _is_number(remaining) and _is_number(total) and total > 0: + ratio = remaining / total + if ratio is None and _is_number(used) and _is_number(total) and total > 0: + ratio = max(0.0, 1.0 - used / total) if ratio is None: continue try: @@ -286,12 +299,15 @@ def _parse_minimax_windows(raw: Any) -> list[dict[str, Any]] | None: return None ratio: float | None = None - if isinstance(remains.get("percentage"), (int, float)): + if _is_number(remains.get("percentage")): ratio = _normalize_percentage(remains["percentage"]) - if ratio is None and isinstance(remains.get("remaining"), (int, float)) and isinstance(remains.get("total"), (int, float)) and remains["total"] > 0: - ratio = remains["remaining"] / remains["total"] - if ratio is None and isinstance(remains.get("used"), (int, float)) and isinstance(remains.get("total"), (int, float)) and remains["total"] > 0: - ratio = max(0.0, 1.0 - remains["used"] / remains["total"]) + remaining = remains.get("remaining") + total = remains.get("total") + used = remains.get("used") + if ratio is None and _is_number(remaining) and _is_number(total) and total > 0: + ratio = remaining / total + if ratio is None and _is_number(used) and _is_number(total) and total > 0: + ratio = max(0.0, 1.0 - used / total) if ratio is None: return None return [normalize_window( @@ -309,15 +325,23 @@ def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: windows: list[dict[str, Any]] | None = None status = "fresh" - windows = _parse_zai_windows(raw) - if windows is None: - windows = _parse_codex_windows(raw) - if windows is None: - windows = _parse_xai_windows(raw) - if windows is None: - windows = _parse_kimi_windows(raw) - if windows is None: - windows = _parse_minimax_windows(raw) + parsers = { + "zai": _parse_zai_windows, + "openai": _parse_codex_windows, + "openai-codex": _parse_codex_windows, + "xai": _parse_xai_windows, + "xai-oauth": _parse_xai_windows, + "kimi": _parse_kimi_windows, + "moonshot": _parse_kimi_windows, + "minimax": _parse_minimax_windows, + "minimax-portal": _parse_minimax_windows, + } + parser = parsers.get(str(provider).strip().lower()) + if parser is not None: + try: + windows = parser(raw) + except (AttributeError, TypeError, ValueError): + windows = None if windows is None or len(windows) == 0: status = "unsupported" diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index d2760e3..0de9725 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -21,6 +21,11 @@ def test_normalizes_tokens_limit_with_percentage(self): self.assertAlmostEqual(w["remainingRatio"], 0.9888) self.assertEqual(w["appliesTo"], "inference") + def test_classifies_time_limit_before_generic_time(self): + w = normalize_window("TIME_LIMIT", remaining_ratio=0.75, applies_to="mcp") + self.assertEqual(w["kind"], "time_limit") + self.assertEqual(w["appliesTo"], "mcp") + def test_derives_ratio_from_usage_limit(self): w = normalize_window("PRIMARY", used=400, limit=800) self.assertAlmostEqual(w["remainingRatio"], 0.5) @@ -126,6 +131,51 @@ def test_unsupported_payload(self): }) self.assertEqual(snap["status"], "unsupported") + def test_provider_dispatch_does_not_guess_by_shape(self): + snap = normalize_snapshot({ + "provider": "qwen-oauth", "account": "qwen#1", + "raw": {"remains": {"percentage": 90, "plan_type": "NOT_MINIMAX"}}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["windows"], []) + + def test_malformed_kind_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "raw": {"limits": [{"type": 1, "percentage": 0.5}]}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["windows"], []) + + def test_rejects_boolean_zai_counters(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "raw": {"limits": [{ + "type": "TOKENS_LIMIT", + "usage": {"used": False, "number": True}, + }]}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + + def test_rejects_boolean_kimi_counters(self): + snap = normalize_snapshot({ + "provider": "moonshot", "account": "kimi#1", + "raw": {"usages": [{"type": "TOKENS", "remaining": False, "total": True}]}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + + def test_rejects_boolean_minimax_counters(self): + snap = normalize_snapshot({ + "provider": "minimax-portal", "account": "minimax#1", + "raw": {"remains": {"remaining": False, "total": True}}, + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "unsupported") + def test_strips_secret_fields(self): snap = normalize_snapshot({ "provider": "zai", "account": "zai#1", diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 43ef82c..424ffbc 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -21,6 +21,16 @@ describe("normalizeQuotaWindow", () => { expect(window.id).toBe("TOKENS_LIMIT"); }); + it("classifies TIME_LIMIT before generic TIME kinds", () => { + const window = normalizeQuotaWindow({ + rawKind: "TIME_LIMIT", + remainingRatio: 0.75, + appliesTo: "mcp", + }); + expect(window.kind).toBe("time_limit"); + expect(window.appliesTo).toBe("mcp"); + }); + it("derives remaining ratio from usage/limit counters", () => { const window = normalizeQuotaWindow({ rawKind: "PRIMARY", @@ -286,6 +296,32 @@ describe("normalizeSnapshot", () => { expect(snapshot.windows).toHaveLength(0); }); + it("dispatches by provider instead of guessing from payload shape", () => { + const snapshot = normalizeSnapshot({ + provider: "qwen-oauth", + account: "qwen#1", + raw: { + remains: { percentage: 90, plan_type: "NOT_MINIMAX" }, + }, + fetchedAt: "2026-07-24T17:33:47Z", + }); + expect(snapshot.status).toBe("unsupported"); + expect(snapshot.windows).toEqual([]); + }); + + it("fails closed on a malformed provider window kind", () => { + const snapshot = normalizeSnapshot({ + provider: "zai", + account: "zai#1", + raw: { + limits: [{ type: 1, percentage: 0.5 }], + }, + fetchedAt: "2026-07-24T17:33:47Z", + }); + expect(snapshot.status).toBe("unsupported"); + expect(snapshot.windows).toEqual([]); + }); + it("strips raw payload identity fields from the normalized snapshot", () => { const payload: ProviderQuotaPayload = { provider: "zai", diff --git a/plugin/__tests__/router-quota.test.ts b/plugin/__tests__/router-quota.test.ts index b2ab930..7d895e2 100644 --- a/plugin/__tests__/router-quota.test.ts +++ b/plugin/__tests__/router-quota.test.ts @@ -98,8 +98,8 @@ describe("router with live quota snapshots", () => { }, }; const quotaSnapshots = new Map([ - ["openai", snap("openai", "openai#1", ["5h", 0.02])], - ["zai", snap("zai", "zai#1", ["1w", 0.90])], + ["openai#1", snap("openai", "openai#1", ["5h", 0.02])], + ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], ]); const ranked = rankSubscriptionWeightedCandidates( @@ -114,8 +114,8 @@ describe("router with live quota snapshots", () => { it("preserves benchmark-first routing for coding-aware code category even under quota pressure", () => { const quotaSnapshots = new Map([ - ["openai", snap("openai", "openai#1", ["5h", 0.02])], - ["zai", snap("zai", "zai#1", ["1w", 0.90])], + ["openai#1", snap("openai", "openai#1", ["5h", 0.02])], + ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], ]); const codeRules: Record = { @@ -140,9 +140,93 @@ describe("router with live quota snapshots", () => { } }); + it("removes fully depleted candidates before benchmark frontier ranking", () => { + const quotaSnapshots = new Map([ + ["openai#1", snap("openai", "openai#1", ["5h", 0])], + ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], + ]); + const codeRules: Record = { + code: { + primary: "openai/gpt-5.6-sol", + fallbacks: ["zai/glm-5.2"], + }, + }; + + const ranked = rankSubscriptionWeightedCandidates( + "code", models, codeRules, profile, inventory, undefined, "balanced", + "coding-aware", quotaSnapshots, + ); + + expect(ranked.map((item) => item.candidate)).not.toContain("openai/gpt-5.6-sol"); + expect(ranked[0]?.candidate).toBe("zai/glm-5.2"); + }); + + it("selects a healthy secondary account when the static preferred account is depleted", () => { + const multiAccountInventory: SubscriptionInventory = { + version: "1", + accounts: { + "openai#1": { + provider: "openai", + enabled: true, + authProfile: "openai-profile-1", + tierId: "plus", + }, + "openai#2": { + provider: "openai", + enabled: true, + authProfile: "openai-profile-2", + tierId: "plus", + }, + }, + }; + const quotaSnapshots = new Map([ + ["openai#1", snap("openai", "openai#1", ["5h", 0])], + ["openai#2", snap("openai", "openai#2", ["5h", 0.80])], + ]); + const openaiOnlyModels = { + "openai/gpt-5.6-sol": models["openai/gpt-5.6-sol"], + }; + const openaiOnlyRules: Record = { + default: { primary: "openai/gpt-5.6-sol", fallbacks: [] }, + }; + + const ranked = rankSubscriptionWeightedCandidates( + "default", + openaiOnlyModels, + openaiOnlyRules, + profile, + multiAccountInventory, + undefined, + "balanced", + undefined, + quotaSnapshots, + ); + + expect(ranked).toHaveLength(1); + expect(ranked[0].selectedAccountId).toBe("openai#2"); + expect(ranked[0].selectedAuthProfile).toBe("openai-profile-2"); + expect(ranked[0].quotaFactor).toBeCloseTo(Math.sqrt(0.80), 4); + }); + + it("ignores a snapshot whose map key does not match snapshot.account", () => { + const mismatched = new Map([ + ["openai#1", snap("openai", "openai#other", ["5h", 0])], + ]); + const noQuota = rankSubscriptionWeightedCandidates( + "default", models, rules, profile, inventory, undefined, "balanced", + ); + const ranked = rankSubscriptionWeightedCandidates( + "default", models, rules, profile, inventory, undefined, "balanced", + undefined, mismatched, + ); + + expect(ranked[0]?.candidate).toBe(noQuota[0]?.candidate); + expect(ranked.find((item) => item.candidate === "openai/gpt-5.6-sol")?.quotaFactor).toBeNull(); + }); + it("treats stale quota as no-quota (static pressure only)", () => { const staleSnapshots = new Map([ - ["openai", { ...snap("openai", "openai#1", ["5h", 0.02]), status: "stale" }], + ["openai#1", { ...snap("openai", "openai#1", ["5h", 0.02]), status: "stale" }], ]); const ranked = rankSubscriptionWeightedCandidates( diff --git a/plugin/inventory.ts b/plugin/inventory.ts index 2553897..05bbad7 100644 --- a/plugin/inventory.ts +++ b/plugin/inventory.ts @@ -17,6 +17,12 @@ export type ResolvedProviderCapacity = { preferredAuthProfile: string | null; }; +export type ResolvedProviderAccountCapacity = { + accountId: string; + authProfile: string | null; + routingWeight: number; +}; + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -150,6 +156,19 @@ function resolveInventoryAccounts(params: { }; } +export function resolveProviderAccountCapacities(params: { + inventory: SubscriptionInventory | undefined; + providerId: string; + category?: TaskCategory; +}): ResolvedProviderAccountCapacity[] { + const accounts = resolveInventoryAccounts(params); + return accounts.scoringAccounts.map((account) => ({ + accountId: account.accountId, + authProfile: account.authProfile, + routingWeight: account.weight, + })); +} + function pickPreferredInventoryAccount( accounts: Array<{ accountId: string; weight: number; authProfile: string | null }>, ): { accountId: string; authProfile: string | null } | null { diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 0ee459a..a1eb246 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -68,14 +68,17 @@ function mapWindowKind(rawKind: string): QuotaWindowKind { if (upper.includes("REQUEST") || upper.includes("RPM")) return "requests_limit"; if (upper.includes("CREDIT")) return "credits"; if (upper.includes("MESSAGE")) return "messages"; - if (upper.includes("COMPUTE") || upper.includes("TIME")) return "compute"; if (upper.includes("TIME_LIMIT")) return "time_limit"; + if (upper.includes("COMPUTE") || upper.includes("TIME")) return "compute"; if (upper.includes("PERCENT") || upper === "USAGE") return "percent"; return "tokens_limit"; } /** Normalize a single quota window. */ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuotaWindow { + if (typeof input.rawKind !== "string" || input.rawKind.trim().length === 0) { + throw new TypeError("rawKind must be a non-empty string"); + } const kind = mapWindowKind(input.rawKind); let remainingRatio: number | null = null; @@ -364,9 +367,33 @@ function parseMiniMaxWindows(raw: unknown): NormalizedQuotaWindow[] | null { ]; } +function parserForProvider( + provider: string, +): ((raw: unknown) => NormalizedQuotaWindow[] | null) | null { + switch (provider.trim().toLowerCase()) { + case "zai": + return parseZaiWindows; + case "openai": + case "openai-codex": + return parseCodexWindows; + case "xai": + case "xai-oauth": + return parseXaiWindows; + case "kimi": + case "moonshot": + return parseKimiWindows; + case "minimax": + case "minimax-portal": + return parseMiniMaxWindows; + default: + return null; + } +} + /** * Provider-specific raw payload normalizer. - * Dispatches to the right parser, strips secret fields from the output. + * Dispatches only by the declared provider ID; it never guesses a provider + * from payload shape. The output contains no raw fields or credentials. */ export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuotaSnapshot { const { provider, account, raw, fetchedAt } = payload; @@ -374,21 +401,12 @@ export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuot let windows: NormalizedQuotaWindow[] | null = null; let status: QuotaSnapshotStatus = "fresh"; - // Try provider-specific parsers in order. - const parsers: Array<(raw: unknown) => NormalizedQuotaWindow[] | null> = [ - parseZaiWindows, - parseCodexWindows, - parseXaiWindows, - parseKimiWindows, - parseMiniMaxWindows, - ]; - - for (const parse of parsers) { + const parser = parserForProvider(provider); + if (parser) { try { - windows = parse(raw); - if (windows !== null) break; + windows = parser(raw); } catch { - continue; + windows = null; } } diff --git a/plugin/router.ts b/plugin/router.ts index 8b1ae2a..c9d946e 100644 --- a/plugin/router.ts +++ b/plugin/router.ts @@ -1,4 +1,4 @@ -import { getProviderCatalogEntry } from "./subscriptions.js"; +import { getCanonicalOpenClawProviderId, getProviderCatalogEntry } from "./subscriptions.js"; import type { ModelCapabilities, RoutingModifier, @@ -7,7 +7,11 @@ import type { SubscriptionInventory, TaskCategory, } from "./types.js"; -import { resolveProviderCapacity } from "./inventory.js"; +import { + resolveProviderAccountCapacities, + resolveProviderCapacity, + type ResolvedProviderCapacity, +} from "./inventory.js"; import type { SubscriptionProfile } from "./profile.js"; import type { NormalizedQuotaSnapshot } from "./quota-types.js"; import { computeQuotaFactor } from "./quota-policy.js"; @@ -198,6 +202,94 @@ function getModifierAccountBonus(params: { * pressure, and whether the candidate cleared the benchmark frontier) without changing * any routing decision. See references/explainability-contract.md. */ +type QuotaAwareProviderCapacity = { + routingWeight: number; + quotaFactor: number | null; + selectedAccountId: string | null; + selectedAuthProfile: string | null; + fullyDepleted: boolean; +}; + +function snapshotMatchesAccount( + snapshot: NormalizedQuotaSnapshot | undefined, + accountId: string, + providerId: string, +): snapshot is NormalizedQuotaSnapshot { + if (!snapshot || snapshot.account !== accountId) return false; + return getCanonicalOpenClawProviderId(snapshot.provider) + === getCanonicalOpenClawProviderId(providerId); +} + +function resolveQuotaAwareProviderCapacity(params: { + resolved: ResolvedProviderCapacity | null; + inventory: SubscriptionInventory | undefined; + providerId: string; + category: TaskCategory; + model: string; + quotaSnapshots: ReadonlyMap | undefined; +}): QuotaAwareProviderCapacity { + const { resolved, inventory, providerId, category, model, quotaSnapshots } = params; + const staticWeight = resolved?.routingWeight ?? 0; + const staticResult: QuotaAwareProviderCapacity = { + routingWeight: staticWeight, + quotaFactor: null, + selectedAccountId: resolved?.preferredAccountId ?? null, + selectedAuthProfile: resolved?.preferredAuthProfile ?? null, + fullyDepleted: false, + }; + + // Legacy profile-only capacity has no opaque account scope. Applying a + // provider-level snapshot would be ambiguous, so fail open to static weight. + if (!resolved || resolved.source !== "inventory" || !quotaSnapshots) { + return staticResult; + } + + const accounts = resolveProviderAccountCapacities({ inventory, providerId, category }); + if (accounts.length === 0) return staticResult; + + const evaluated = accounts.map((account) => { + const snapshot = quotaSnapshots.get(account.accountId); + const quotaFactor = snapshotMatchesAccount(snapshot, account.accountId, providerId) + ? computeQuotaFactor(snapshot, model) + : null; + return { + ...account, + quotaFactor, + liveWeight: account.routingWeight * (quotaFactor ?? 1), + }; + }); + + const eligible = evaluated + .filter((account) => account.quotaFactor !== 0) + .sort((a, b) => { + if (b.liveWeight !== a.liveWeight) return b.liveWeight - a.liveWeight; + return a.accountId.localeCompare(b.accountId); + }); + + if (eligible.length === 0) { + return { + routingWeight: 0, + quotaFactor: 0, + selectedAccountId: null, + selectedAuthProfile: null, + fullyDepleted: true, + }; + } + + const selected = eligible[0]; + const redundancyBonus = Math.min(1, 0.25 * Math.max(0, eligible.length - 1)); + const liveRoutingWeight = selected.liveWeight + redundancyBonus; + + return { + routingWeight: liveRoutingWeight, + quotaFactor: selected.quotaFactor, + selectedAccountId: selected.accountId, + selectedAuthProfile: selected.authProfile, + fullyDepleted: false, + }; +} + +/** Ranked model candidate with optional account-scoped quota diagnostics. */ export type RankedCandidate = { candidate: string; benchmarkStrength: number; @@ -206,6 +298,8 @@ export type RankedCandidate = { withinFrontier: boolean; originalIndex: number; quotaFactor?: number | null; + selectedAccountId?: string | null; + selectedAuthProfile?: string | null; }; export function rankSubscriptionWeightedCandidates( @@ -243,23 +337,23 @@ export function rankSubscriptionWeightedCandidates( availableModels[candidate], isModifierRelevant(routingModifier, category) ? routingModifier : undefined, ); + const quotaCapacity = resolveQuotaAwareProviderCapacity({ + resolved, + inventory, + providerId, + category, + model: candidate, + quotaSnapshots, + }); const modifierAccountBonus = getModifierAccountBonus({ routingModifier, category, inventory, - preferredAccountId: resolved?.preferredAccountId, + preferredAccountId: quotaCapacity.selectedAccountId, }); const speedPriority = getSpeedPriority(availableModels[candidate]); - - // Live quota factor: sqrt(min(applicable remaining ratios)). - // When no snapshot is available or snapshot is stale/unsupported, - // factor is null → quota does not modulate routing (static pressure only). - // When factor is 0 (depleted), the candidate's effective pressure is 0. - const quotaSnapshot = quotaSnapshots?.get(providerId) ?? null; - const quotaFactor = quotaSnapshots - ? computeQuotaFactor(quotaSnapshot, candidate) - : null; const basePressureScore = tierWeight * providerBias; + const livePressureScore = quotaCapacity.routingWeight * providerBias; return { candidate, @@ -268,15 +362,15 @@ export function rankSubscriptionWeightedCandidates( providerBias, benchmarkStrength, pressureScore: basePressureScore, - effectivePressureScore: - (quotaFactor === null - ? basePressureScore - : basePressureScore * quotaFactor) + modifierAccountBonus, + effectivePressureScore: livePressureScore + modifierAccountBonus, speedPriority, - quotaFactor, + quotaFactor: quotaCapacity.quotaFactor, + selectedAccountId: quotaCapacity.selectedAccountId, + selectedAuthProfile: quotaCapacity.selectedAuthProfile, + fullyDepleted: quotaCapacity.fullyDepleted, }; }) - .filter((item) => item.pressureScore > 0); + .filter((item) => item.pressureScore > 0 && !item.fullyDepleted); if (candidates.length === 0) return []; @@ -376,6 +470,9 @@ export function rankSubscriptionWeightedCandidates( effectivePressureScore: item.effectivePressureScore, withinFrontier: item.withinFrontier, originalIndex: item.originalIndex, + quotaFactor: item.quotaFactor, + selectedAccountId: item.selectedAccountId, + selectedAuthProfile: item.selectedAuthProfile, })); } From 4b7d054d01d731e1dfc0540905ad1f410d267fa4 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:30:41 +0300 Subject: [PATCH 05/16] refactor(quota): enforce host-normalized boundary --- integrations/hermes/quota.py | 421 ++++++++------------ integrations/hermes/test_quota.py | 101 +++-- plugin/__tests__/quota-normalize.test.ts | 165 ++++---- plugin/__tests__/quota-policy.test.ts | 12 +- plugin/quota-normalize.ts | 468 ++++++----------------- plugin/quota-policy.ts | 16 +- plugin/quota-types.ts | 14 +- 7 files changed, 476 insertions(+), 721 deletions(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index af6c5d3..c054fd8 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -1,28 +1,25 @@ -"""Pure-Python ZeroAPI live quota normalization and policy. +"""Pure-Python ZeroAPI quota normalization and pressure policy. -Mirrors plugin/quota-normalize.ts and plugin/quota-policy.ts so the Hermes -Python adapter can apply the same live depletion factor as the OpenClaw -plugin without calling Node or external APIs. - -This module is pure data transformation: no credentials, no network access. +Provider HTTP/RPC parsing and credentials remain host-owned. This module +accepts only token-free, provider-neutral quantitative windows and mirrors +plugin/quota-normalize.ts + plugin/quota-policy.ts. """ from __future__ import annotations import math -import re +from datetime import datetime from typing import Any, TypeGuard -SECRET_FIELD_PATTERNS = ( - "token", "secret", "cookie", "password", "credential", - "api_key", "apikey", "access", "refresh", "bearer", "session", - "email", "authorization", -) - - -def _is_secret_field(key: str) -> bool: - lower = key.lower() - return any(p in lower for p in SECRET_FIELD_PATTERNS) +VALID_STATUSES = { + "fresh", + "stale", + "auth_expired", + "rate_limited", + "network_error", + "invalid_response", + "unsupported", +} def _is_number(value: Any) -> TypeGuard[int | float]: @@ -33,34 +30,55 @@ def _is_number(value: Any) -> TypeGuard[int | float]: ) -def _assert_valid_ratio(value: float) -> None: +def _assert_finite_number(value: Any, label: str) -> None: if isinstance(value, bool): - raise TypeError("remainingRatio must be numeric, got boolean") + raise TypeError(f"{label} must be numeric, got boolean") if not isinstance(value, (int, float)): - raise TypeError("remainingRatio must be numeric") + raise TypeError(f"{label} must be numeric") if math.isnan(value): - raise ValueError("remainingRatio must not be NaN") + raise ValueError(f"{label} must not be NaN") if math.isinf(value): - raise ValueError("remainingRatio must be finite") + raise ValueError(f"{label} must be finite") + + +def _assert_valid_ratio(value: Any) -> None: + _assert_finite_number(value, "remainingRatio") if value < 0 or value > 1: raise ValueError("remainingRatio must be in [0, 1]") -def _normalize_percentage(value: float) -> float | None: - if isinstance(value, bool): - return None - if not isinstance(value, (int, float)) or math.isnan(value) or math.isinf(value): - return None - if value > 1: - return value / 100 +def _normalize_percentage(value: Any, label: str) -> float: + _assert_finite_number(value, label) if value < 0: - return None - return float(value) + raise ValueError(f"{label} must be non-negative") + ratio = value / 100 if value > 1 else float(value) + _assert_valid_ratio(ratio) + return ratio + + +def _normalize_identifier(value: Any, label: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{label} must be a string") + normalized = value.strip() + if not normalized: + raise ValueError(f"{label} must be non-empty") + if len(normalized) > 256: + raise ValueError(f"{label} is too long") + if any(ord(char) < 32 or ord(char) == 127 for char in normalized): + raise ValueError(f"{label} contains control characters") + return normalized + + +def _normalize_timestamp(value: Any, label: str) -> str: + normalized = _normalize_identifier(value, label) + try: + datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{label} must be an ISO-8601 timestamp") from exc + return normalized def _map_window_kind(raw_kind: str) -> str: - if not isinstance(raw_kind, str) or not raw_kind.strip(): - raise TypeError("raw_kind must be a non-empty string") upper = raw_kind.upper() if "TOKEN" in upper: return "tokens_limit" @@ -72,14 +90,17 @@ def _map_window_kind(raw_kind: str) -> str: return "messages" if "TIME_LIMIT" in upper: return "time_limit" - if "PERCENT" in upper or upper == "USAGE": + if "COMPUTE" in upper or "TIME" in upper: + return "compute" + if "PERCENT" in upper or upper in {"USAGE", "BILLING"}: return "percent" return "tokens_limit" def normalize_window( - raw_kind: str, + raw_kind: Any, *, + id: str | None = None, remaining_ratio: float | None = None, used: float | None = None, limit: float | None = None, @@ -90,45 +111,58 @@ def normalize_window( window_seconds: float | None = None, reset_at: str | None = None, ) -> dict[str, Any]: - kind = _map_window_kind(raw_kind) - ratio: float | None = None + normalized_kind = _normalize_identifier(raw_kind, "raw_kind") + window_id = _normalize_identifier(id if id is not None else normalized_kind, "window id") + kind = _map_window_kind(normalized_kind) if remaining_ratio is not None: - if isinstance(remaining_ratio, bool): - raise TypeError("remainingRatio must be numeric, got boolean") + _assert_valid_ratio(remaining_ratio) ratio = float(remaining_ratio) elif percentage_remaining is not None: - ratio = _normalize_percentage(percentage_remaining) + ratio = _normalize_percentage(percentage_remaining, "percentage_remaining") elif percentage_used is not None: - used_pct = _normalize_percentage(percentage_used) - if used_pct is not None: - ratio = max(0.0, 1.0 - used_pct) - elif _is_number(used) and _is_number(limit) and limit > 0: + ratio = max(0.0, 1.0 - _normalize_percentage(percentage_used, "percentage_used")) + elif used is not None and limit is not None: + _assert_finite_number(used, "used") + _assert_finite_number(limit, "limit") + if used < 0: + raise ValueError("used must be non-negative") + if limit <= 0: + raise ValueError("limit must be positive") ratio = max(0.0, 1.0 - used / limit) + _assert_valid_ratio(ratio) + else: + raise ValueError(f"cannot derive remainingRatio for window '{window_id}'") - if ratio is None: - raise ValueError(f"cannot derive remainingRatio for window kind '{raw_kind}'") - - _assert_valid_ratio(ratio) + if applies_to not in {"inference", "mcp", "model"}: + raise ValueError(f"unknown applies_to value '{applies_to}'") - mids = model_ids or [] - if applies_to == "model" and len(mids) == 0: + mids = [_normalize_identifier(model_id, "modelId") for model_id in (model_ids or [])] + if len(set(mids)) != len(mids): + raise ValueError("modelIds must be unique") + if applies_to == "model" and not mids: raise ValueError("model-scoped window requires at least one modelId") - if applies_to != "model" and len(mids) > 0: + if applies_to != "model" and mids: raise ValueError("non-model window must not carry modelIds") - window: dict[str, Any] = { - "id": raw_kind, + normalized_seconds: float | None = None + if window_seconds is not None: + _assert_finite_number(window_seconds, "windowSeconds") + if window_seconds <= 0: + raise ValueError("windowSeconds must be positive") + normalized_seconds = float(window_seconds) + + normalized_reset = None if reset_at is None else _normalize_timestamp(reset_at, "resetAt") + + return { + "id": window_id, "kind": kind, "appliesTo": applies_to, "modelIds": mids, "remainingRatio": ratio, + **({"windowSeconds": normalized_seconds} if normalized_seconds is not None else {}), + **({"resetAt": normalized_reset} if normalized_reset is not None else {}), } - if window_seconds is not None: - window["windowSeconds"] = window_seconds - if reset_at is not None: - window["resetAt"] = reset_at - return window def validate_snapshot( @@ -136,216 +170,75 @@ def validate_snapshot( expected_provider: str | None = None, diagnostics_only: bool = False, ) -> None: - provider = snapshot.get("provider") - account = snapshot.get("account") - if not provider or not account: - raise ValueError("snapshot provider and account are required") + provider = _normalize_identifier(snapshot.get("provider"), "snapshot provider") + _normalize_identifier(snapshot.get("account"), "snapshot account") + _normalize_timestamp(snapshot.get("fetchedAt"), "fetchedAt") + + status = snapshot.get("status") + if status not in VALID_STATUSES: + raise ValueError(f"unknown snapshot status '{status}'") if expected_provider is not None and provider != expected_provider: raise ValueError( f'snapshot provider "{provider}" does not match expected "{expected_provider}"' ) - status = snapshot.get("status", "fresh") if not diagnostics_only and status != "fresh": raise ValueError(f'routing snapshot must be fresh, got "{status}"') - if status == "fresh": - windows = snapshot.get("windows", []) - if len(windows) == 0: - raise ValueError("fresh snapshot requires at least one window") - for w in windows: - _assert_valid_ratio(w["remainingRatio"]) - -def _parse_time_window_seconds(tw: str | None) -> float | None: - if not tw: - return None - match = re.match(r"^(\d+(?:\.\d+)?)\s*(m|h|d|w)$", tw) - if not match: - return None - value = float(match.group(1)) - unit = match.group(2) - if unit == "m": - return value * 60 - if unit == "h": - return value * 3600 - if unit == "d": - return value * 86400 - if unit == "w": - return value * 604800 - return None - - -def _parse_zai_windows(raw: Any) -> list[dict[str, Any]] | None: - if not isinstance(raw, dict): - return None - limits = raw.get("limits") - if not isinstance(limits, list): - return None - - windows: list[dict[str, Any]] = [] - for limit in limits: - if not isinstance(limit, dict): - continue - raw_kind = limit.get("type", limit.get("limit_id", "UNKNOWN")) - window_seconds = _parse_time_window_seconds(limit.get("time_window")) - reset_at = limit.get("next_reset_time") - ratio: float | None = None - - if _is_number(limit.get("percentage")): - ratio = _normalize_percentage(limit["percentage"]) - - if ratio is None and isinstance(limit.get("usage"), dict): - usage = limit["usage"] - used = usage.get("current_value", usage.get("used")) - limit_total = usage.get("number") - if _is_number(used) and _is_number(limit_total) and limit_total > 0: - ratio = max(0.0, 1.0 - used / limit_total) - - if ratio is None: - continue - - applies_to = "mcp" if "TIME_LIMIT" in str(raw_kind).upper() else "inference" - try: - windows.append(normalize_window( - raw_kind, remaining_ratio=ratio, - applies_to=applies_to, window_seconds=window_seconds, reset_at=reset_at, - )) - except (ValueError, TypeError): - continue - - return windows if windows else None - - -def _parse_codex_windows(raw: Any) -> list[dict[str, Any]] | None: - if not isinstance(raw, dict): - return None - limits = raw.get("rate_limits") - if not isinstance(limits, list): - return None - - windows: list[dict[str, Any]] = [] - for limit in limits: - if not isinstance(limit, dict): - continue - if not _is_number(limit.get("used_percent")): - continue - raw_kind = limit.get("label", "PRIMARY") - window_seconds = limit.get("window_minutes") - if _is_number(window_seconds): - window_seconds = window_seconds * 60 - else: - window_seconds = None - try: - windows.append(normalize_window( - raw_kind, percentage_used=limit["used_percent"], - window_seconds=window_seconds, - )) - except (ValueError, TypeError): - continue - - return windows if windows else None - - -def _parse_xai_windows(raw: Any) -> list[dict[str, Any]] | None: - if not isinstance(raw, dict): - return None - rp = raw.get("remaining_percent") - if not _is_number(rp): - return None - ratio = _normalize_percentage(rp) - if ratio is None: - return None - return [normalize_window("BILLING", remaining_ratio=ratio)] + windows = snapshot.get("windows") + if not isinstance(windows, list): + raise TypeError("snapshot windows must be a list") + if status == "fresh" and not windows: + raise ValueError("fresh snapshot requires at least one window") + + ids: set[str] = set() + for window in windows: + _assert_valid_ratio(window["remainingRatio"]) + window_id = _normalize_identifier(window.get("id"), "window id") + if window_id in ids: + raise ValueError(f'duplicate window id "{window_id}"') + ids.add(window_id) + + +def _input_window(item: Any) -> dict[str, Any]: + if not isinstance(item, dict): + raise TypeError("window must be an object") + return normalize_window( + item.get("rawKind"), + id=item.get("id"), + remaining_ratio=item.get("remainingRatio"), + used=item.get("used"), + limit=item.get("limit"), + percentage_remaining=item.get("percentageRemaining"), + percentage_used=item.get("percentageUsed"), + applies_to=item.get("appliesTo", "inference"), + model_ids=item.get("modelIds"), + window_seconds=item.get("windowSeconds"), + reset_at=item.get("resetAt"), + ) -def _parse_kimi_windows(raw: Any) -> list[dict[str, Any]] | None: - if not isinstance(raw, dict): - return None - usages = raw.get("usages") - if not isinstance(usages, list): - return None +def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: + provider = _normalize_identifier(payload.get("provider"), "provider") + account = _normalize_identifier(payload.get("account"), "account") + fetched_at = _normalize_timestamp(payload.get("fetchedAt"), "fetchedAt") + requested_status = payload.get("status", "fresh") + status = requested_status if requested_status in VALID_STATUSES else "invalid_response" windows: list[dict[str, Any]] = [] - for usage in usages: - if not isinstance(usage, dict): - continue - raw_kind = usage.get("limit_id", usage.get("type", "UNKNOWN")) - reset_at = usage.get("reset_at") - ratio: float | None = None - - if _is_number(usage.get("percentage")): - ratio = _normalize_percentage(usage["percentage"]) - remaining = usage.get("remaining") - total = usage.get("total") - used = usage.get("used") - if ratio is None and _is_number(remaining) and _is_number(total) and total > 0: - ratio = remaining / total - if ratio is None and _is_number(used) and _is_number(total) and total > 0: - ratio = max(0.0, 1.0 - used / total) - if ratio is None: - continue - try: - windows.append(normalize_window(raw_kind, remaining_ratio=ratio, reset_at=reset_at)) - except (ValueError, TypeError): - continue - - return windows if windows else None - - -def _parse_minimax_windows(raw: Any) -> list[dict[str, Any]] | None: - if not isinstance(raw, dict): - return None - remains = raw.get("remains", raw) - if not isinstance(remains, dict): - return None - ratio: float | None = None - - if _is_number(remains.get("percentage")): - ratio = _normalize_percentage(remains["percentage"]) - remaining = remains.get("remaining") - total = remains.get("total") - used = remains.get("used") - if ratio is None and _is_number(remaining) and _is_number(total) and total > 0: - ratio = remaining / total - if ratio is None and _is_number(used) and _is_number(total) and total > 0: - ratio = max(0.0, 1.0 - used / total) - if ratio is None: - return None - return [normalize_window( - remains.get("plan_type", "CODING_PLAN"), - remaining_ratio=ratio, reset_at=remains.get("reset_at"), - )] - + try: + raw_windows = payload.get("windows") + if not isinstance(raw_windows, list): + raise TypeError("windows must be a list") + windows = [_input_window(item) for item in raw_windows] + ids = {window["id"] for window in windows} + if len(ids) != len(windows): + raise ValueError("window IDs must be unique") + except (KeyError, TypeError, ValueError): + windows = [] + status = "invalid_response" -def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: - provider = payload["provider"] - account = payload["account"] - raw = payload.get("raw", {}) - fetched_at = payload.get("fetchedAt", "") - - windows: list[dict[str, Any]] | None = None - status = "fresh" - - parsers = { - "zai": _parse_zai_windows, - "openai": _parse_codex_windows, - "openai-codex": _parse_codex_windows, - "xai": _parse_xai_windows, - "xai-oauth": _parse_xai_windows, - "kimi": _parse_kimi_windows, - "moonshot": _parse_kimi_windows, - "minimax": _parse_minimax_windows, - "minimax-portal": _parse_minimax_windows, - } - parser = parsers.get(str(provider).strip().lower()) - if parser is not None: - try: - windows = parser(raw) - except (AttributeError, TypeError, ValueError): - windows = None - - if windows is None or len(windows) == 0: + if status == "fresh" and not windows: status = "unsupported" - windows = [] snapshot = { "provider": provider, @@ -358,17 +251,17 @@ def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: return snapshot -# ── Policy ── +# ── Routing policy ────────────────────────────────────────────────────────── def applicable_windows(snapshot: dict[str, Any] | None, model: str) -> list[dict[str, Any]]: if not snapshot or snapshot.get("status") != "fresh": return [] result = [] - for w in snapshot.get("windows", []): - if w["appliesTo"] == "inference": - result.append(w) - elif w["appliesTo"] == "model" and model in w.get("modelIds", []): - result.append(w) + for window in snapshot.get("windows", []): + if window["appliesTo"] == "inference": + result.append(window) + elif window["appliesTo"] == "model" and model in window.get("modelIds", []): + result.append(window) return result @@ -376,7 +269,7 @@ def account_headroom(snapshot: dict[str, Any] | None, model: str) -> float | Non windows = applicable_windows(snapshot, model) if not windows: return None - return min(w["remainingRatio"] for w in windows) + return min(window["remainingRatio"] for window in windows) def compute_quota_factor(snapshot: dict[str, Any] | None, model: str) -> float | None: @@ -387,7 +280,7 @@ def compute_quota_factor(snapshot: dict[str, Any] | None, model: str) -> float | def compute_live_pressure( - static_pressure: float, + tier_weight: float, provider_bias: float, snapshot: dict[str, Any] | None, model: str, @@ -395,4 +288,4 @@ def compute_live_pressure( factor = compute_quota_factor(snapshot, model) if factor is None: return None - return static_pressure * provider_bias * factor + return tier_weight * provider_bias * factor diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 0de9725..1dd1810 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -86,16 +86,28 @@ def test_rejects_provider_mismatch(self): with self.assertRaises(ValueError): validate_snapshot(self._valid(), expected_provider="openai") + def test_rejects_invalid_timestamp(self): + with self.assertRaises(ValueError): + validate_snapshot({**self._valid(), "fetchedAt": "not-a-date"}) + class TestNormalizeSnapshot(unittest.TestCase): def test_zai_payload(self): snap = normalize_snapshot({ "provider": "zai", "account": "zai#1", - "raw": {"limits": [ - {"type": "TOKENS_LIMIT", "percentage": 0.9888, "next_reset_time": "2026-07-24T20:23:52Z"}, - {"type": "TOKENS_LIMIT", "percentage": 0.86, "next_reset_time": "2026-07-26T20:23:52Z"}, - ]}, + "windows": [ + { + "id": "5h", "rawKind": "TOKENS_LIMIT", + "used": 112, "limit": 10000, + "resetAt": "2026-07-24T20:23:52Z", + }, + { + "id": "weekly", "rawKind": "TOKENS_LIMIT", + "used": 1400, "limit": 10000, + "resetAt": "2026-07-26T20:23:52Z", + }, + ], "fetchedAt": "2026-07-24T17:00:00Z", }) self.assertEqual(snap["status"], "fresh") @@ -105,10 +117,16 @@ def test_zai_payload(self): def test_codex_payload(self): snap = normalize_snapshot({ "provider": "openai-codex", "account": "openai#1", - "raw": {"rate_limits": [ - {"label": "primary", "window_minutes": 300, "used_percent": 47}, - {"label": "secondary", "window_minutes": 10080, "used_percent": 12}, - ]}, + "windows": [ + { + "id": "primary", "rawKind": "TOKENS_LIMIT", + "windowSeconds": 300 * 60, "percentageUsed": 47, + }, + { + "id": "secondary", "rawKind": "TOKENS_LIMIT", + "windowSeconds": 10080 * 60, "percentageUsed": 12, + }, + ], "fetchedAt": "2026-07-24T17:00:00Z", }) self.assertEqual(len(snap["windows"]), 2) @@ -117,7 +135,12 @@ def test_codex_payload(self): def test_xai_payload(self): snap = normalize_snapshot({ "provider": "xai", "account": "xai#1", - "raw": {"remaining_percent": 100}, + "windows": [ + { + "id": "billing", "rawKind": "BILLING", + "percentageRemaining": 100, + }, + ], "fetchedAt": "2026-07-24T17:00:00Z", }) self.assertEqual(len(snap["windows"]), 1) @@ -126,70 +149,90 @@ def test_xai_payload(self): def test_unsupported_payload(self): snap = normalize_snapshot({ "provider": "qwen-oauth", "account": "qwen#1", - "raw": {}, + "windows": [], "fetchedAt": "2026-07-24T17:00:00Z", }) self.assertEqual(snap["status"], "unsupported") - def test_provider_dispatch_does_not_guess_by_shape(self): + def test_raw_shaped_fields_are_ignored(self): snap = normalize_snapshot({ "provider": "qwen-oauth", "account": "qwen#1", + "windows": [], "raw": {"remains": {"percentage": 90, "plan_type": "NOT_MINIMAX"}}, "fetchedAt": "2026-07-24T17:00:00Z", }) self.assertEqual(snap["status"], "unsupported") - self.assertEqual(snap["windows"], []) + self.assertNotIn("raw", snap) def test_malformed_kind_fails_closed(self): snap = normalize_snapshot({ "provider": "zai", "account": "zai#1", - "raw": {"limits": [{"type": 1, "percentage": 0.5}]}, + "windows": [{"id": "bad", "rawKind": 1, "remainingRatio": 0.5}], "fetchedAt": "2026-07-24T17:00:00Z", }) - self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["status"], "invalid_response") self.assertEqual(snap["windows"], []) def test_rejects_boolean_zai_counters(self): snap = normalize_snapshot({ "provider": "zai", "account": "zai#1", - "raw": {"limits": [{ - "type": "TOKENS_LIMIT", - "usage": {"used": False, "number": True}, - }]}, + "windows": [{ + "id": "5h", "rawKind": "TOKENS_LIMIT", + "used": False, "limit": True, + }], "fetchedAt": "2026-07-24T17:00:00Z", }) - self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["status"], "invalid_response") def test_rejects_boolean_kimi_counters(self): snap = normalize_snapshot({ "provider": "moonshot", "account": "kimi#1", - "raw": {"usages": [{"type": "TOKENS", "remaining": False, "total": True}]}, + "windows": [{ + "id": "weekly", "rawKind": "TOKENS_LIMIT", + "used": False, "limit": True, + }], "fetchedAt": "2026-07-24T17:00:00Z", }) - self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["status"], "invalid_response") def test_rejects_boolean_minimax_counters(self): snap = normalize_snapshot({ "provider": "minimax-portal", "account": "minimax#1", - "raw": {"remains": {"remaining": False, "total": True}}, + "windows": [{ + "id": "coding", "rawKind": "TOKENS_LIMIT", + "used": False, "limit": True, + }], "fetchedAt": "2026-07-24T17:00:00Z", }) - self.assertEqual(snap["status"], "unsupported") + self.assertEqual(snap["status"], "invalid_response") def test_strips_secret_fields(self): snap = normalize_snapshot({ "provider": "zai", "account": "zai#1", - "raw": { - "limits": [{"type": "T", "percentage": 0.9888}], - "account_email": "secret@example.com", - "access_token": "sk-secret", - }, + "windows": [{ + "id": "5h", "rawKind": "TOKENS_LIMIT", + "percentageRemaining": 98.88, + }], + "account_email": "secret@example.com", + "access_token": "test-only-secret-placeholder", "fetchedAt": "2026-07-24T17:00:00Z", }) import json serialized = json.dumps(snap) self.assertNotIn("secret@example.com", serialized) - self.assertNotIn("sk-secret", serialized) + self.assertNotIn("test-only-secret-placeholder", serialized) + + def test_duplicate_window_ids_fail_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [ + {"id": "weekly", "rawKind": "TOKENS_LIMIT", "remainingRatio": 0.8}, + {"id": "weekly", "rawKind": "TOKENS_LIMIT", "remainingRatio": 0.7}, + ], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + self.assertEqual(snap["windows"], []) class TestQuotaPolicy(unittest.TestCase): diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 424ffbc..d01ed59 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -42,6 +42,16 @@ describe("normalizeQuotaWindow", () => { expect(window.remainingRatio).toBeCloseTo(0.5); }); + it("rejects boolean usage counters", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "PRIMARY", + used: false as unknown as number, + limit: true as unknown as number, + }), + ).toThrow(); + }); + it("rejects NaN remainingRatio", () => { expect(() => normalizeQuotaWindow({ @@ -201,6 +211,12 @@ describe("validateNormalizedSnapshot", () => { ), ).not.toThrow(); }); + + it("rejects an invalid fetchedAt timestamp", () => { + expect(() => + validateNormalizedSnapshot({ ...validSnapshot, fetchedAt: "not-a-date" }), + ).toThrow(); + }); }); describe("normalizeSnapshot", () => { @@ -208,26 +224,24 @@ describe("normalizeSnapshot", () => { const payload: ProviderQuotaPayload = { provider: "zai", account: "zai#1", - raw: { - limits: [ - { - limit_id: "5_hour", - type: "TOKENS_LIMIT", - time_window: "5h", - usage: { used: 100, number: 10000, current_value: 100 }, - percentage: 0.9888, - next_reset_time: "2026-07-24T20:23:52Z", - }, - { - limit_id: "weekly", - type: "TOKENS_LIMIT", - time_window: "1w", - usage: { used: 1400, number: 10000, current_value: 1400 }, - percentage: 0.86, - next_reset_time: "2026-07-26T20:23:52Z", - }, - ], - }, + windows: [ + { + id: "5_hour", + rawKind: "TOKENS_LIMIT", + windowSeconds: 5 * 3600, + used: 112, + limit: 10000, + resetAt: "2026-07-24T20:23:52Z", + }, + { + id: "weekly", + rawKind: "TOKENS_LIMIT", + windowSeconds: 7 * 24 * 3600, + used: 1400, + limit: 10000, + resetAt: "2026-07-26T20:23:52Z", + }, + ], fetchedAt: "2026-07-24T17:33:47Z", }; const snapshot = normalizeSnapshot(payload); @@ -244,22 +258,20 @@ describe("normalizeSnapshot", () => { const payload: ProviderQuotaPayload = { provider: "openai-codex", account: "openai#1", - raw: { - rate_limits: [ - { - label: "primary", - window_minutes: 300, - used_percent: 47, - reset_seconds: 9000, - }, - { - label: "secondary", - window_minutes: 10080, - used_percent: 12, - reset_seconds: 360000, - }, - ], - }, + windows: [ + { + id: "primary", + rawKind: "TOKENS_LIMIT", + windowSeconds: 300 * 60, + percentageUsed: 47, + }, + { + id: "secondary", + rawKind: "TOKENS_LIMIT", + windowSeconds: 10080 * 60, + percentageUsed: 12, + }, + ], fetchedAt: "2026-07-24T17:33:47Z", }; const snapshot = normalizeSnapshot(payload); @@ -274,9 +286,13 @@ describe("normalizeSnapshot", () => { const payload: ProviderQuotaPayload = { provider: "xai", account: "xai#1", - raw: { - remaining_percent: 100, - }, + windows: [ + { + id: "billing", + rawKind: "BILLING", + percentageRemaining: 100, + }, + ], fetchedAt: "2026-07-24T17:33:47Z", }; const snapshot = normalizeSnapshot(payload); @@ -288,7 +304,7 @@ describe("normalizeSnapshot", () => { const payload: ProviderQuotaPayload = { provider: "qwen-oauth", account: "qwen#1", - raw: {}, + windows: [], fetchedAt: "2026-07-24T17:33:47Z", }; const snapshot = normalizeSnapshot(payload); @@ -296,53 +312,68 @@ describe("normalizeSnapshot", () => { expect(snapshot.windows).toHaveLength(0); }); - it("dispatches by provider instead of guessing from payload shape", () => { - const snapshot = normalizeSnapshot({ + it("ignores extra provider-raw fields outside the token-free contract", () => { + const payload = { provider: "qwen-oauth", account: "qwen#1", - raw: { - remains: { percentage: 90, plan_type: "NOT_MINIMAX" }, - }, + windows: [], fetchedAt: "2026-07-24T17:33:47Z", - }); + raw: { remains: { percentage: 90, plan_type: "NOT_MINIMAX" } }, + } as ProviderQuotaPayload & { raw: unknown }; + const snapshot = normalizeSnapshot(payload); expect(snapshot.status).toBe("unsupported"); + expect(JSON.stringify(snapshot)).not.toContain("NOT_MINIMAX"); + }); + + it("fails closed on a malformed host-normalized window kind", () => { + const snapshot = normalizeSnapshot({ + provider: "zai", + account: "zai#1", + windows: [ + { + id: "bad", + rawKind: 1 as unknown as string, + remainingRatio: 0.5, + }, + ], + fetchedAt: "2026-07-24T17:33:47Z", + }); + expect(snapshot.status).toBe("invalid_response"); expect(snapshot.windows).toEqual([]); }); - it("fails closed on a malformed provider window kind", () => { + it("fails closed on duplicate semantic window IDs", () => { const snapshot = normalizeSnapshot({ provider: "zai", account: "zai#1", - raw: { - limits: [{ type: 1, percentage: 0.5 }], - }, + windows: [ + { id: "weekly", rawKind: "TOKENS_LIMIT", remainingRatio: 0.8 }, + { id: "weekly", rawKind: "TOKENS_LIMIT", remainingRatio: 0.7 }, + ], fetchedAt: "2026-07-24T17:33:47Z", }); - expect(snapshot.status).toBe("unsupported"); + expect(snapshot.status).toBe("invalid_response"); expect(snapshot.windows).toEqual([]); }); - it("strips raw payload identity fields from the normalized snapshot", () => { - const payload: ProviderQuotaPayload = { + it("copies only allowlisted fields into the normalized snapshot", () => { + const payload = { provider: "zai", account: "zai#1", - raw: { - limits: [ - { - limit_id: "5h", - type: "TOKENS_LIMIT", - percentage: 0.9888, - next_reset_time: "2026-07-24T20:23:52Z", - }, - ], - account_email: "secret@example.com", - access_token: "sk-secret-token", - }, + windows: [ + { + id: "5h", + rawKind: "TOKENS_LIMIT", + percentageRemaining: 98.88, + }, + ], fetchedAt: "2026-07-24T17:33:47Z", - }; + account_email: "secret@example.com", + access_token: "test-only-secret-placeholder", + } as ProviderQuotaPayload & { account_email: string; access_token: string }; const snapshot = normalizeSnapshot(payload); expect(JSON.stringify(snapshot)).not.toContain("secret@example.com"); - expect(JSON.stringify(snapshot)).not.toContain("sk-secret-token"); + expect(JSON.stringify(snapshot)).not.toContain("test-only-secret-placeholder"); expect(JSON.stringify(snapshot)).not.toContain("account_email"); expect(JSON.stringify(snapshot)).not.toContain("access_token"); }); diff --git a/plugin/__tests__/quota-policy.test.ts b/plugin/__tests__/quota-policy.test.ts index f4dfa55..3673d3f 100644 --- a/plugin/__tests__/quota-policy.test.ts +++ b/plugin/__tests__/quota-policy.test.ts @@ -129,8 +129,8 @@ describe("computeLivePressure", () => { describe("selectAccountByQuota", () => { it("selects the account with higher live pressure", () => { const accounts = [ - { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.10]) }, - { provider: "openai", account: "openai#2", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#2", "fresh", ["5h", 0.80]) }, + { provider: "openai", account: "openai#1", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.10]) }, + { provider: "openai", account: "openai#2", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#2", "fresh", ["5h", 0.80]) }, ]; const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); expect(selected?.account).toBe("openai#2"); @@ -138,22 +138,22 @@ describe("selectAccountByQuota", () => { it("returns null when all accounts are stale", () => { const accounts = [ - { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "stale", ["5h", 1.0]) }, + { provider: "openai", account: "openai#1", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "stale", ["5h", 1.0]) }, ]; expect(selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol")).toBeNull(); }); it("returns null when all accounts are depleted", () => { const accounts = [ - { provider: "zai", account: "zai#1", staticPressure: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.0]) }, + { provider: "zai", account: "zai#1", tierWeight: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.0]) }, ]; expect(selectAccountByQuota(accounts, "zai", "zai/glm-5.2")).toBeNull(); }); it("filters by provider", () => { const accounts = [ - { provider: "zai", account: "zai#1", staticPressure: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.9]) }, - { provider: "openai", account: "openai#1", staticPressure: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.9]) }, + { provider: "zai", account: "zai#1", tierWeight: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.9]) }, + { provider: "openai", account: "openai#1", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.9]) }, ]; const selected = selectAccountByQuota(accounts, "zai", "zai/glm-5.2"); expect(selected?.account).toBe("zai#1"); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index a1eb246..46f9f3f 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -1,67 +1,74 @@ /** - * Quota normalization: converts provider-specific raw payloads into - * secret-free NormalizedQuotaSnapshot objects. + * Provider-neutral quota normalization. * - * Design constraints: - * - Never carry credentials, tokens, account emails, or raw payloads. - * - Reject booleans, NaN, Infinity, out-of-range ratios. - * - Preserve window applicability (inference vs mcp vs model). - * - Missing quantitative meter → unsupported, not 0% or 100%. + * Provider HTTP/RPC parsing and credential lifecycle belong to the host + * (OpenClaw/Hermes). ZeroAPI receives token-free quantitative windows only. + * This module validates and copies an allowlisted schema; unknown/raw fields + * never cross into NormalizedQuotaSnapshot. */ import type { NormalizedQuotaSnapshot, NormalizedQuotaWindow, - QuotaWindowKind, + NormalizeWindowInput, + ProviderQuotaPayload, QuotaAppliesTo, QuotaSnapshotStatus, - ProviderQuotaPayload, - NormalizeWindowInput, + QuotaWindowKind, } from "./quota-types.js"; -const SECRET_FIELD_PATTERNS = [ - "token", - "secret", - "cookie", - "password", - "credential", - "api_key", - "apikey", - "access", - "refresh", - "bearer", - "session", - "email", - "authorization", -]; - -function isSecretField(key: string): boolean { - const lower = key.toLowerCase(); - return SECRET_FIELD_PATTERNS.some((p) => lower.includes(p)); -} +class ValueError extends Error {} -/** Known numeric ratio validation. */ -function assertValidRatio(value: number): void { +const VALID_STATUSES = new Set([ + "fresh", + "stale", + "auth_expired", + "rate_limited", + "network_error", + "invalid_response", + "unsupported", +]); + +function assertFiniteNumber(value: unknown, label: string): asserts value is number { if (typeof value !== "number") { - if (typeof value === "boolean") throw new TypeError("remainingRatio must be numeric, got boolean"); - throw new TypeError("remainingRatio must be numeric"); + if (typeof value === "boolean") throw new TypeError(`${label} must be numeric, got boolean`); + throw new TypeError(`${label} must be numeric`); } - if (Number.isNaN(value)) throw new ValueError("remainingRatio must not be NaN"); - if (!Number.isFinite(value)) throw new ValueError("remainingRatio must be finite"); + if (Number.isNaN(value)) throw new ValueError(`${label} must not be NaN`); + if (!Number.isFinite(value)) throw new ValueError(`${label} must be finite`); +} + +function assertValidRatio(value: unknown): asserts value is number { + assertFiniteNumber(value, "remainingRatio"); if (value < 0 || value > 1) throw new ValueError("remainingRatio must be in [0, 1]"); } -class ValueError extends Error {} +function normalizePercentage(value: unknown, label: string): number { + assertFiniteNumber(value, label); + if (value < 0) throw new ValueError(`${label} must be non-negative`); + const ratio = value > 1 ? value / 100 : value; + assertValidRatio(ratio); + return ratio; +} + +function normalizeIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new TypeError(`${label} must be a string`); + const trimmed = value.trim(); + if (!trimmed) throw new ValueError(`${label} must be non-empty`); + if (trimmed.length > 256) throw new ValueError(`${label} is too long`); + if (/[\u0000-\u001f\u007f]/.test(trimmed)) { + throw new ValueError(`${label} contains control characters`); + } + return trimmed; +} -/** Normalize a percentage that may be 0..1 or 0..100. */ -function normalizePercentage(value: number): number | null { - if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) return null; - if (value > 1) return value / 100; - if (value < 0) return null; - return value; +function normalizeTimestamp(value: unknown, label: string): string { + const normalized = normalizeIdentifier(value, label); + const millis = Date.parse(normalized); + if (!Number.isFinite(millis)) throw new ValueError(`${label} must be an ISO-8601 timestamp`); + return normalized; } -/** Map provider-specific kind strings to canonical QuotaWindowKind. */ function mapWindowKind(rawKind: string): QuotaWindowKind { const upper = rawKind.toUpperCase(); if (upper.includes("TOKEN")) return "tokens_limit"; @@ -70,42 +77,45 @@ function mapWindowKind(rawKind: string): QuotaWindowKind { if (upper.includes("MESSAGE")) return "messages"; if (upper.includes("TIME_LIMIT")) return "time_limit"; if (upper.includes("COMPUTE") || upper.includes("TIME")) return "compute"; - if (upper.includes("PERCENT") || upper === "USAGE") return "percent"; + if (upper.includes("PERCENT") || upper === "USAGE" || upper === "BILLING") return "percent"; return "tokens_limit"; } -/** Normalize a single quota window. */ +/** Normalize one token-free host adapter window. */ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuotaWindow { - if (typeof input.rawKind !== "string" || input.rawKind.trim().length === 0) { - throw new TypeError("rawKind must be a non-empty string"); - } - const kind = mapWindowKind(input.rawKind); - - let remainingRatio: number | null = null; + const rawKind = normalizeIdentifier(input.rawKind, "rawKind"); + const id = normalizeIdentifier(input.id ?? rawKind, "window id"); + const kind = mapWindowKind(rawKind); + let remainingRatio: number; if (input.remainingRatio !== undefined) { + assertValidRatio(input.remainingRatio); remainingRatio = input.remainingRatio; } else if (input.percentageRemaining !== undefined) { - remainingRatio = normalizePercentage(input.percentageRemaining); + remainingRatio = normalizePercentage(input.percentageRemaining, "percentageRemaining"); } else if (input.percentageUsed !== undefined) { - const used = normalizePercentage(input.percentageUsed); - if (used !== null) remainingRatio = Math.max(0, 1 - used); + const usedRatio = normalizePercentage(input.percentageUsed, "percentageUsed"); + remainingRatio = Math.max(0, 1 - usedRatio); } else if (input.used !== undefined && input.limit !== undefined) { - const limit = input.limit; - if (typeof limit === "number" && limit > 0 && Number.isFinite(limit)) { - remainingRatio = Math.max(0, 1 - input.used / limit); - } - } - - if (remainingRatio === null) { - throw new ValueError(`cannot derive remainingRatio for window kind "${input.rawKind}"`); + assertFiniteNumber(input.used, "used"); + assertFiniteNumber(input.limit, "limit"); + if (input.used < 0) throw new ValueError("used must be non-negative"); + if (input.limit <= 0) throw new ValueError("limit must be positive"); + remainingRatio = Math.max(0, 1 - input.used / input.limit); + assertValidRatio(remainingRatio); + } else { + throw new ValueError(`cannot derive remainingRatio for window "${id}"`); } - assertValidRatio(remainingRatio); - const appliesTo: QuotaAppliesTo = input.appliesTo ?? "inference"; - const modelIds = input.modelIds ?? []; + if (appliesTo !== "inference" && appliesTo !== "mcp" && appliesTo !== "model") { + throw new ValueError(`unknown appliesTo value "${String(appliesTo)}"`); + } + const modelIds = (input.modelIds ?? []).map((modelId) => normalizeIdentifier(modelId, "modelId")); + if (new Set(modelIds).size !== modelIds.length) { + throw new ValueError("modelIds must be unique"); + } if (appliesTo === "model" && modelIds.length === 0) { throw new ValueError("model-scoped window requires at least one modelId"); } @@ -113,14 +123,25 @@ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuo throw new ValueError("non-model window must not carry modelIds"); } + let windowSeconds: number | undefined; + if (input.windowSeconds !== undefined) { + assertFiniteNumber(input.windowSeconds, "windowSeconds"); + if (input.windowSeconds <= 0) throw new ValueError("windowSeconds must be positive"); + windowSeconds = input.windowSeconds; + } + + const resetAt = input.resetAt === undefined + ? undefined + : normalizeTimestamp(input.resetAt, "resetAt"); + return { - id: input.rawKind, + id, kind, appliesTo, modelIds, remainingRatio, - windowSeconds: input.windowSeconds, - resetAt: input.resetAt, + windowSeconds, + resetAt, }; } @@ -130,302 +151,67 @@ export function validateNormalizedSnapshot( expectedProvider?: string, diagnosticsOnly: boolean = false, ): void { - if (!snapshot.provider || !snapshot.account) { - throw new ValueError("snapshot provider and account are required"); + const provider = normalizeIdentifier(snapshot.provider, "snapshot provider"); + normalizeIdentifier(snapshot.account, "snapshot account"); + normalizeTimestamp(snapshot.fetchedAt, "fetchedAt"); + + if (!VALID_STATUSES.has(snapshot.status)) { + throw new ValueError(`unknown snapshot status "${String(snapshot.status)}"`); } - if (expectedProvider !== undefined && snapshot.provider !== expectedProvider) { - throw new ValueError(`snapshot provider "${snapshot.provider}" does not match expected "${expectedProvider}"`); + if (expectedProvider !== undefined && provider !== expectedProvider) { + throw new ValueError(`snapshot provider "${provider}" does not match expected "${expectedProvider}"`); } if (!diagnosticsOnly && snapshot.status !== "fresh") { throw new ValueError(`routing snapshot must be fresh, got "${snapshot.status}"`); } - if (snapshot.status === "fresh") { - if (snapshot.windows.length === 0) { - throw new ValueError("fresh snapshot requires at least one window"); - } - for (const w of snapshot.windows) assertValidRatio(w.remainingRatio); - } -} - -/** Secret-strings to strip from JSON serialization. */ -function safeStringify(snapshot: NormalizedQuotaSnapshot): string { - return JSON.stringify(snapshot, (key, value) => { - if (isSecretField(key)) return undefined; - return value; - }); -} - -/** Detect Z.AI-style limits array. */ -interface ZaiLimit { - limit_id?: string; - type?: string; - time_window?: string; - usage?: { used?: number; number?: number; current_value?: number }; - percentage?: number; - next_reset_time?: string; -} - -function parseZaiWindows(raw: unknown): NormalizedQuotaWindow[] | null { - if (typeof raw !== "object" || raw === null) return null; - const limits = (raw as { limits?: unknown }).limits; - if (!Array.isArray(limits)) return null; - - const windows: NormalizedQuotaWindow[] = []; - for (const limit of limits as ZaiLimit[]) { - if (typeof limit !== "object" || limit === null) continue; - - const rawKind = limit.type ?? limit.limit_id ?? "UNKNOWN"; - const windowSeconds = parseTimeWindowSeconds(limit.time_window); - const resetAt = limit.next_reset_time; - - let remainingRatio: number | null = null; - - if (typeof limit.percentage === "number") { - remainingRatio = normalizePercentage(limit.percentage); - } - - if (remainingRatio === null && limit.usage) { - const used = limit.usage.current_value ?? limit.usage.used ?? 0; - const limitTotal = limit.usage.number; - if (typeof limitTotal === "number" && limitTotal > 0) { - remainingRatio = Math.max(0, 1 - used / limitTotal); - } - } - - if (remainingRatio === null) continue; - - const appliesTo: QuotaAppliesTo = rawKind.toUpperCase().includes("TIME_LIMIT") ? "mcp" : "inference"; - - try { - windows.push( - normalizeQuotaWindow({ - rawKind, - windowSeconds, - resetAt, - remainingRatio, - appliesTo, - }), - ); - } catch { - continue; - } - } - - return windows.length > 0 ? windows : null; -} - -function parseTimeWindowSeconds(tw: string | undefined): number | undefined { - if (!tw) return undefined; - const match = /^(\d+(?:\.\d+)?)\s*(m|h|d|w)$/.exec(tw); - if (!match) return undefined; - const value = parseFloat(match[1]); - const unit = match[2]; - if (unit === "m") return value * 60; - if (unit === "h") return value * 3600; - if (unit === "d") return value * 86400; - if (unit === "w") return value * 604800; - return undefined; -} - -/** Detect OpenAI Codex-style rate_limits array. */ -interface CodexRateLimit { - label?: string; - window_minutes?: number; - used_percent?: number; - reset_seconds?: number; -} - -function parseCodexWindows(raw: unknown): NormalizedQuotaWindow[] | null { - if (typeof raw !== "object" || raw === null) return null; - const limits = (raw as { rate_limits?: unknown }).rate_limits; - if (!Array.isArray(limits)) return null; - - const windows: NormalizedQuotaWindow[] = []; - for (const limit of limits as CodexRateLimit[]) { - if (typeof limit !== "object" || limit === null) continue; - if (typeof limit.used_percent !== "number") continue; - - const rawKind = limit.label ?? "PRIMARY"; - const windowSeconds = typeof limit.window_minutes === "number" ? limit.window_minutes * 60 : undefined; - - windows.push( - normalizeQuotaWindow({ - rawKind, - windowSeconds, - percentageUsed: limit.used_percent, - }), - ); - } - - return windows.length > 0 ? windows : null; -} - -/** Detect xAI-style bare remaining_percent. */ -function parseXaiWindows(raw: unknown): NormalizedQuotaWindow[] | null { - if (typeof raw !== "object" || raw === null) return null; - const obj = raw as { remaining_percent?: unknown }; - if (typeof obj.remaining_percent !== "number") return null; - - return [ - normalizeQuotaWindow({ - rawKind: "BILLING", - remainingRatio: normalizePercentage(obj.remaining_percent) ?? undefined, - }), - ]; -} - -/** Detect Kimi-style usages array. */ -interface KimiUsage { - limit_id?: string; - type?: string; - period?: string; - used?: number; - total?: number; - remaining?: number; - percentage?: number; - reset_at?: string; -} - -function parseKimiWindows(raw: unknown): NormalizedQuotaWindow[] | null { - if (typeof raw !== "object" || raw === null) return null; - const usages = (raw as { usages?: unknown }).usages; - if (!Array.isArray(usages)) return null; - - const windows: NormalizedQuotaWindow[] = []; - for (const usage of usages as KimiUsage[]) { - if (typeof usage !== "object" || usage === null) continue; - - const rawKind = usage.limit_id ?? usage.type ?? "UNKNOWN"; - const resetAt = usage.reset_at; - - let remainingRatio: number | null = null; - - if (typeof usage.percentage === "number") { - remainingRatio = normalizePercentage(usage.percentage); - } - if (remainingRatio === null && typeof usage.remaining === "number" && typeof usage.total === "number" && usage.total > 0) { - remainingRatio = usage.remaining / usage.total; - } - if (remainingRatio === null && typeof usage.used === "number" && typeof usage.total === "number" && usage.total > 0) { - remainingRatio = Math.max(0, 1 - usage.used / usage.total); - } - - if (remainingRatio === null) continue; - - try { - windows.push( - normalizeQuotaWindow({ - rawKind, - resetAt, - remainingRatio, - }), - ); - } catch { - continue; - } - } - - return windows.length > 0 ? windows : null; -} - -/** Detect MiniMax-style remains object. */ -interface MiniMaxRemains { - used?: number; - total?: number; - remaining?: number; - percentage?: number; - reset_at?: string; - plan_type?: string; -} - -function parseMiniMaxWindows(raw: unknown): NormalizedQuotaWindow[] | null { - if (typeof raw !== "object" || raw === null) return null; - const remains = (raw as { remains?: unknown }).remains ?? raw; - if (typeof remains !== "object" || remains === null) return null; - const r = remains as MiniMaxRemains; - - let remainingRatio: number | null = null; - - if (typeof r.percentage === "number") { - remainingRatio = normalizePercentage(r.percentage); - } - if (remainingRatio === null && typeof r.remaining === "number" && typeof r.total === "number" && r.total > 0) { - remainingRatio = r.remaining / r.total; - } - if (remainingRatio === null && typeof r.used === "number" && typeof r.total === "number" && r.total > 0) { - remainingRatio = Math.max(0, 1 - r.used / r.total); + if (snapshot.status === "fresh" && snapshot.windows.length === 0) { + throw new ValueError("fresh snapshot requires at least one window"); } - if (remainingRatio === null) return null; - - return [ - normalizeQuotaWindow({ - rawKind: r.plan_type ?? "CODING_PLAN", - remainingRatio, - resetAt: r.reset_at, - }), - ]; -} - -function parserForProvider( - provider: string, -): ((raw: unknown) => NormalizedQuotaWindow[] | null) | null { - switch (provider.trim().toLowerCase()) { - case "zai": - return parseZaiWindows; - case "openai": - case "openai-codex": - return parseCodexWindows; - case "xai": - case "xai-oauth": - return parseXaiWindows; - case "kimi": - case "moonshot": - return parseKimiWindows; - case "minimax": - case "minimax-portal": - return parseMiniMaxWindows; - default: - return null; + const ids = new Set(); + for (const window of snapshot.windows) { + assertValidRatio(window.remainingRatio); + if (ids.has(window.id)) throw new ValueError(`duplicate window id "${window.id}"`); + ids.add(window.id); } } /** - * Provider-specific raw payload normalizer. - * Dispatches only by the declared provider ID; it never guesses a provider - * from payload shape. The output contains no raw fields or credentials. + * Convert a token-free host adapter payload into the allowlisted snapshot. + * A malformed quantitative window fails the whole observation closed as + * invalid_response; partial provider data is never used for routing. */ export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuotaSnapshot { - const { provider, account, raw, fetchedAt } = payload; - - let windows: NormalizedQuotaWindow[] | null = null; - let status: QuotaSnapshotStatus = "fresh"; - - const parser = parserForProvider(provider); - if (parser) { - try { - windows = parser(raw); - } catch { - windows = null; + const provider = normalizeIdentifier(payload.provider, "provider"); + const account = normalizeIdentifier(payload.account, "account"); + const fetchedAt = normalizeTimestamp(payload.fetchedAt, "fetchedAt"); + const requestedStatus = payload.status ?? "fresh"; + const status = VALID_STATUSES.has(requestedStatus) ? requestedStatus : "invalid_response"; + + let windows: NormalizedQuotaWindow[] = []; + let normalizedStatus: QuotaSnapshotStatus = status; + try { + windows = payload.windows.map(normalizeQuotaWindow); + const ids = new Set(windows.map((window) => window.id)); + if (ids.size !== windows.length) { + throw new ValueError("window IDs must be unique"); } + } catch { + windows = []; + normalizedStatus = "invalid_response"; } - if (windows === null || windows.length === 0) { - status = "unsupported"; - windows = []; + if (normalizedStatus === "fresh" && windows.length === 0) { + normalizedStatus = "unsupported"; } const snapshot: NormalizedQuotaSnapshot = { provider, account, - status, + status: normalizedStatus, windows, fetchedAt, }; - - // Validate + safe-serialize to prove no secrets leak. validateNormalizedSnapshot(snapshot, undefined, true); - safeStringify(snapshot); - return snapshot; } diff --git a/plugin/quota-policy.ts b/plugin/quota-policy.ts index 9c9b683..dcd7200 100644 --- a/plugin/quota-policy.ts +++ b/plugin/quota-policy.ts @@ -66,26 +66,24 @@ export function computeQuotaFactor( export type QuotaAwareAccount = { provider: string; account: string; - /** Declared static pressure (tierWeight * providerBias). */ - staticPressure: number; + tierWeight: number; providerBias: number; snapshot: NormalizedQuotaSnapshot | null; }; /** * Compute live pressure for a candidate model. - * Returns null if quota factor cannot be computed (stale/unsupported). - * Returns 0 if account is depleted. + * staticPressure = tierWeight * providerBias; livePressure adds quotaFactor. */ export function computeLivePressure( - staticPressure: number, + tierWeight: number, providerBias: number, snapshot: NormalizedQuotaSnapshot | null, model: string, ): number | null { const factor = computeQuotaFactor(snapshot, model); if (factor === null) return null; - return staticPressure * providerBias * factor; + return tierWeight * providerBias * factor; } /** @@ -99,15 +97,15 @@ export function selectAccountByQuota( ): QuotaAwareAccount | null { const eligible = accounts.filter((a) => { if (a.provider !== provider) return false; - const pressure = computeLivePressure(a.staticPressure, a.providerBias, a.snapshot, model); + const pressure = computeLivePressure(a.tierWeight, a.providerBias, a.snapshot, model); return pressure !== null && pressure > 0; }); if (eligible.length === 0) return null; return eligible.reduce((best, current) => { - const bestPressure = computeLivePressure(best.staticPressure, best.providerBias, best.snapshot, model) ?? 0; - const currentPressure = computeLivePressure(current.staticPressure, current.providerBias, current.snapshot, model) ?? 0; + const bestPressure = computeLivePressure(best.tierWeight, best.providerBias, best.snapshot, model) ?? 0; + const currentPressure = computeLivePressure(current.tierWeight, current.providerBias, current.snapshot, model) ?? 0; if (currentPressure > bestPressure) return current; if (currentPressure === bestPressure && current.account < best.account) return current; return best; diff --git a/plugin/quota-types.ts b/plugin/quota-types.ts index a85343e..ff46f53 100644 --- a/plugin/quota-types.ts +++ b/plugin/quota-types.ts @@ -57,22 +57,26 @@ export type NormalizedQuotaSnapshot = { }; /** - * Raw provider payload input — what a provider adapter produces. - * Contains the raw response plus identity; the normalizer strips secrets. + * Token-free host adapter payload. + * + * Hermes/OpenClaw own provider HTTP/RPC parsing and credential lifecycle. + * ZeroAPI receives only these provider-neutral quantitative windows. */ export type ProviderQuotaPayload = { provider: string; account: string; - /** Raw provider response or pre-parsed object. */ - raw: unknown; + status?: QuotaSnapshotStatus; + windows: NormalizeWindowInput[]; fetchedAt: string; }; /** - * Input parameters for a single window normalization call. + * Input parameters for a single host-normalized window. * Either remainingRatio or (used + limit) must be provided. */ export type NormalizeWindowInput = { + /** Non-secret semantic ID; defaults to rawKind. */ + id?: string; rawKind: string; windowSeconds?: number; resetAt?: string; From 9d9aa400102b14f1c634f4089c11079945f4842e Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:35:15 +0300 Subject: [PATCH 06/16] fix(quota): validate snapshots at policy boundary --- integrations/hermes/quota.py | 42 ++++++++++++++++++++++++++- integrations/hermes/test_quota.py | 8 +++++ plugin/__tests__/quota-policy.test.ts | 5 ++++ plugin/quota-normalize.ts | 39 +++++++++++++++++++++++-- plugin/quota-policy.ts | 6 ++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index c054fd8..f72ac8d 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -20,6 +20,16 @@ "invalid_response", "unsupported", } +VALID_WINDOW_KINDS = { + "tokens_limit", + "requests_limit", + "credits", + "messages", + "compute", + "time_limit", + "percent", +} +VALID_APPLICABILITY = {"inference", "mcp", "model"} def _is_number(value: Any) -> TypeGuard[int | float]: @@ -192,8 +202,32 @@ def validate_snapshot( ids: set[str] = set() for window in windows: - _assert_valid_ratio(window["remainingRatio"]) + if not isinstance(window, dict): + raise TypeError("window must be an object") + _assert_valid_ratio(window.get("remainingRatio")) window_id = _normalize_identifier(window.get("id"), "window id") + if window.get("kind") not in VALID_WINDOW_KINDS: + raise ValueError(f'unknown window kind "{window.get("kind")}"') + applies_to = window.get("appliesTo") + if applies_to not in VALID_APPLICABILITY: + raise ValueError(f'unknown appliesTo value "{applies_to}"') + model_ids = window.get("modelIds") + if not isinstance(model_ids, list): + raise TypeError("modelIds must be a list") + normalized_models = [_normalize_identifier(model_id, "modelId") for model_id in model_ids] + if len(set(normalized_models)) != len(normalized_models): + raise ValueError("modelIds must be unique") + if applies_to == "model" and not normalized_models: + raise ValueError("model-scoped window requires at least one modelId") + if applies_to != "model" and normalized_models: + raise ValueError("non-model window must not carry modelIds") + if "windowSeconds" in window: + seconds = window["windowSeconds"] + _assert_finite_number(seconds, "windowSeconds") + if seconds <= 0: + raise ValueError("windowSeconds must be positive") + if "resetAt" in window: + _normalize_timestamp(window["resetAt"], "resetAt") if window_id in ids: raise ValueError(f'duplicate window id "{window_id}"') ids.add(window_id) @@ -266,6 +300,12 @@ def applicable_windows(snapshot: dict[str, Any] | None, model: str) -> list[dict def account_headroom(snapshot: dict[str, Any] | None, model: str) -> float | None: + if not snapshot or snapshot.get("status") != "fresh": + return None + try: + validate_snapshot(snapshot) + except (KeyError, TypeError, ValueError): + return None windows = applicable_windows(snapshot, model) if not windows: return None diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 1dd1810..1c13cbe 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -252,6 +252,14 @@ def test_stale_returns_none(self): snap = {"provider": "zai", "account": "zai#1", "status": "stale", "windows": [], "fetchedAt": "2026-07-24T17:00:00Z"} self.assertIsNone(compute_quota_factor(snap, "zai/glm-5.2")) + def test_malformed_fresh_returns_none(self): + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [{"id": "5h", "kind": "tokens_limit", "appliesTo": "inference", "modelIds": [], "remainingRatio": float("nan")}], + "fetchedAt": "2026-07-24T17:00:00Z", + } + self.assertIsNone(compute_quota_factor(snap, "zai/glm-5.2")) + def test_depleted_returns_zero(self): snap = { "provider": "zai", "account": "zai#1", "status": "fresh", diff --git a/plugin/__tests__/quota-policy.test.ts b/plugin/__tests__/quota-policy.test.ts index 3673d3f..b1b42f5 100644 --- a/plugin/__tests__/quota-policy.test.ts +++ b/plugin/__tests__/quota-policy.test.ts @@ -39,6 +39,11 @@ describe("computeQuotaFactor", () => { expect(computeQuotaFactor(snapshot, "zai/glm-5.2")).toBeNull(); }); + it("fails open for a malformed fresh snapshot", () => { + const snapshot = snap("zai", "zai#1", "fresh", ["5h", Number.NaN]); + expect(computeQuotaFactor(snapshot, "zai/glm-5.2")).toBeNull(); + }); + it("returns null for an unsupported snapshot", () => { const snapshot = snap("qwen-oauth", "qwen#1", "unsupported"); expect(computeQuotaFactor(snapshot, "qwen-oauth/qwen3.5")).toBeNull(); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 46f9f3f..a9a5268 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -29,6 +29,18 @@ const VALID_STATUSES = new Set([ "unsupported", ]); +const VALID_WINDOW_KINDS = new Set([ + "tokens_limit", + "requests_limit", + "credits", + "messages", + "compute", + "time_limit", + "percent", +]); + +const VALID_APPLICABILITY = new Set(["inference", "mcp", "model"]); + function assertFiniteNumber(value: unknown, label: string): asserts value is number { if (typeof value !== "number") { if (typeof value === "boolean") throw new TypeError(`${label} must be numeric, got boolean`); @@ -170,9 +182,32 @@ export function validateNormalizedSnapshot( const ids = new Set(); for (const window of snapshot.windows) { + const windowId = normalizeIdentifier(window.id, "window id"); assertValidRatio(window.remainingRatio); - if (ids.has(window.id)) throw new ValueError(`duplicate window id "${window.id}"`); - ids.add(window.id); + if (!VALID_WINDOW_KINDS.has(window.kind)) { + throw new ValueError(`unknown window kind "${String(window.kind)}"`); + } + if (!VALID_APPLICABILITY.has(window.appliesTo)) { + throw new ValueError(`unknown appliesTo value "${String(window.appliesTo)}"`); + } + if (!Array.isArray(window.modelIds)) throw new TypeError("modelIds must be an array"); + const modelIds = window.modelIds.map((modelId) => normalizeIdentifier(modelId, "modelId")); + if (new Set(modelIds).size !== modelIds.length) { + throw new ValueError("modelIds must be unique"); + } + if (window.appliesTo === "model" && modelIds.length === 0) { + throw new ValueError("model-scoped window requires at least one modelId"); + } + if (window.appliesTo !== "model" && modelIds.length > 0) { + throw new ValueError("non-model window must not carry modelIds"); + } + if (window.windowSeconds !== undefined) { + assertFiniteNumber(window.windowSeconds, "windowSeconds"); + if (window.windowSeconds <= 0) throw new ValueError("windowSeconds must be positive"); + } + if (window.resetAt !== undefined) normalizeTimestamp(window.resetAt, "resetAt"); + if (ids.has(windowId)) throw new ValueError(`duplicate window id "${windowId}"`); + ids.add(windowId); } } diff --git a/plugin/quota-policy.ts b/plugin/quota-policy.ts index dcd7200..7ccd86a 100644 --- a/plugin/quota-policy.ts +++ b/plugin/quota-policy.ts @@ -17,6 +17,7 @@ */ import type { NormalizedQuotaSnapshot, NormalizedQuotaWindow } from "./quota-types.js"; +import { validateNormalizedSnapshot } from "./quota-normalize.js"; /** * Select windows that apply to a specific candidate model. @@ -43,6 +44,11 @@ export function accountHeadroom( model: string, ): number | null { if (!snapshot || snapshot.status !== "fresh") return null; + try { + validateNormalizedSnapshot(snapshot); + } catch { + return null; + } const windows = applicableWindows(snapshot, model); if (windows.length === 0) return null; return Math.min(...windows.map((w) => w.remainingRatio)); From 3bae7460236f2113756aceeff33e3c96993ea8a5 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:41:39 +0300 Subject: [PATCH 07/16] refactor(quota): defer runtime wiring to host integration --- integrations/hermes/quota.py | 6 +- integrations/hermes/test_quota.py | 6 + plugin/__tests__/quota-normalize.test.ts | 7 + plugin/__tests__/router-quota.test.ts | 245 ----------------------- plugin/inventory.ts | 19 -- plugin/quota-normalize.ts | 4 +- plugin/quota-types.ts | 4 +- plugin/router.ts | 133 +----------- 8 files changed, 26 insertions(+), 398 deletions(-) delete mode 100644 plugin/__tests__/router-quota.test.ts diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index f72ac8d..ce5a3cc 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -59,9 +59,9 @@ def _assert_valid_ratio(value: Any) -> None: def _normalize_percentage(value: Any, label: str) -> float: _assert_finite_number(value, label) - if value < 0: - raise ValueError(f"{label} must be non-negative") - ratio = value / 100 if value > 1 else float(value) + if value < 0 or value > 100: + raise ValueError(f"{label} must be in [0, 100]") + ratio = value / 100 _assert_valid_ratio(ratio) return ratio diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 1c13cbe..51163c6 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -42,6 +42,12 @@ def test_rejects_boolean(self): with self.assertRaises(TypeError): normalize_window("X", remaining_ratio=True) + def test_percentage_fields_use_zero_to_one_hundred_scale(self): + used = normalize_window("PRIMARY", percentage_used=1) + remaining = normalize_window("PRIMARY", percentage_remaining=1) + self.assertAlmostEqual(used["remainingRatio"], 0.99) + self.assertAlmostEqual(remaining["remainingRatio"], 0.01) + def test_rejects_over_one(self): with self.assertRaises(ValueError): normalize_window("X", remaining_ratio=1.01) diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index d01ed59..039b061 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -52,6 +52,13 @@ describe("normalizeQuotaWindow", () => { ).toThrow(); }); + it("treats percentage fields as 0-100 values, including exactly one percent", () => { + expect(normalizeQuotaWindow({ rawKind: "PRIMARY", percentageUsed: 1 }).remainingRatio) + .toBeCloseTo(0.99); + expect(normalizeQuotaWindow({ rawKind: "PRIMARY", percentageRemaining: 1 }).remainingRatio) + .toBeCloseTo(0.01); + }); + it("rejects NaN remainingRatio", () => { expect(() => normalizeQuotaWindow({ diff --git a/plugin/__tests__/router-quota.test.ts b/plugin/__tests__/router-quota.test.ts deleted file mode 100644 index 7d895e2..0000000 --- a/plugin/__tests__/router-quota.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { rankSubscriptionWeightedCandidates } from "../router.js"; -import type { - ModelCapabilities, - RoutingRule, - SubscriptionInventory, - SubscriptionProfile, -} from "../types.js"; -import type { NormalizedQuotaSnapshot } from "../quota-types.js"; - -const models: Record = { - "openai/gpt-5.6-sol": { - context_window: 1000, - supports_vision: false, - speed_tps: 82, - ttft_seconds: 1.5, - benchmarks: { intelligence: 57, coding: 57, gpqa: 0.92, tau2: 0.87, ifbench: 0.74, tau3_banking: 0.5 }, - }, - "zai/glm-5.2": { - context_window: 1000, - supports_vision: false, - speed_tps: 47, - ttft_seconds: 0.9, - benchmarks: { intelligence: 55, coding: 55, gpqa: 0.90, tau2: 0.98, ifbench: 0.76, tau3_banking: 0.55 }, - }, - "xai/grok-4.5": { - context_window: 1000, - supports_vision: false, - speed_tps: 60, - ttft_seconds: 1.0, - benchmarks: { intelligence: 54, coding: 53, gpqa: 0.88, tau2: 0.85, ifbench: 0.70, tau3_banking: 0.48 }, - }, -}; - -const rules: Record = { - default: { - primary: "openai/gpt-5.6-sol", - fallbacks: ["zai/glm-5.2", "xai/grok-4.5"], - }, -}; - -const profile: SubscriptionProfile = { - version: "1.1.0", - global: { - "openai": { enabled: true, tierId: "plus" }, - "zai": { enabled: true, tierId: "max" }, - "xai": { enabled: true, tierId: "premium+" }, - }, -}; - -const inventory: SubscriptionInventory = { - version: "1", - accounts: { - "openai#1": { provider: "openai", enabled: true, authProfile: "openai#1" }, - "zai#1": { provider: "zai", enabled: true, authProfile: "zai#1" }, - "xai#1": { provider: "xai", enabled: true, authProfile: "xai#1" }, - }, -}; - -function snap(provider: string, account: string, ...windows: Array<[string, number]>): NormalizedQuotaSnapshot { - return { - provider, - account, - status: "fresh", - windows: windows.map(([id, ratio]) => ({ - id, - kind: "tokens_limit" as const, - appliesTo: "inference" as const, - modelIds: [], - remainingRatio: ratio, - })), - fetchedAt: "2026-07-24T17:00:00Z", - }; -} - -describe("router with live quota snapshots", () => { - it("preserves existing ranking when no quota snapshots are provided", () => { - const ranked = rankSubscriptionWeightedCandidates( - "default", models, rules, profile, inventory, undefined, "balanced", - ); - expect(ranked.length).toBeGreaterThan(0); - // Without quota, the winner should be the same as before - expect(ranked[0].candidate).toBeTruthy(); - }); - - it("de-prioritizes a depleted provider in routine/default routing", () => { - // Use a simpler 2-model setup where OpenAI has higher benchmark - // but is depleted (0.02), and ZAI has good quota (0.90). - // In balanced/default, effectivePressureScore should determine the winner. - const simpleModels: Record = { - "openai/gpt-5.6-sol": models["openai/gpt-5.6-sol"], - "zai/glm-5.2": models["zai/glm-5.2"], - }; - const simpleRules: Record = { - default: { - primary: "openai/gpt-5.6-sol", - fallbacks: ["zai/glm-5.2"], - }, - }; - const quotaSnapshots = new Map([ - ["openai#1", snap("openai", "openai#1", ["5h", 0.02])], - ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], - ]); - - const ranked = rankSubscriptionWeightedCandidates( - "default", simpleModels, simpleRules, profile, inventory, undefined, "balanced", - undefined, quotaSnapshots, - ); - - expect(ranked.length).toBe(2); - // ZAI has higher effectivePressureScore despite lower benchmark - expect(ranked[0].candidate).toBe("zai/glm-5.2"); - }); - - it("preserves benchmark-first routing for coding-aware code category even under quota pressure", () => { - const quotaSnapshots = new Map([ - ["openai#1", snap("openai", "openai#1", ["5h", 0.02])], - ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], - ]); - - const codeRules: Record = { - code: { - primary: "openai/gpt-5.6-sol", - fallbacks: ["zai/glm-5.2"], - }, - }; - - const ranked = rankSubscriptionWeightedCandidates( - "code", models, codeRules, profile, inventory, undefined, "balanced", - "coding-aware", quotaSnapshots, - ); - - // coding-aware: benchmark first, so even depleted openai stays in frontier - // but the winner should not be openai (depleted) - expect(ranked.length).toBeGreaterThan(0); - const openaiEntry = ranked.find((r) => r.candidate === "openai/gpt-5.6-sol"); - if (openaiEntry) { - // If openai is still ranked, its effective pressure should be very low - expect(openaiEntry.effectivePressureScore).toBeLessThan(0.5); - } - }); - - it("removes fully depleted candidates before benchmark frontier ranking", () => { - const quotaSnapshots = new Map([ - ["openai#1", snap("openai", "openai#1", ["5h", 0])], - ["zai#1", snap("zai", "zai#1", ["1w", 0.90])], - ]); - const codeRules: Record = { - code: { - primary: "openai/gpt-5.6-sol", - fallbacks: ["zai/glm-5.2"], - }, - }; - - const ranked = rankSubscriptionWeightedCandidates( - "code", models, codeRules, profile, inventory, undefined, "balanced", - "coding-aware", quotaSnapshots, - ); - - expect(ranked.map((item) => item.candidate)).not.toContain("openai/gpt-5.6-sol"); - expect(ranked[0]?.candidate).toBe("zai/glm-5.2"); - }); - - it("selects a healthy secondary account when the static preferred account is depleted", () => { - const multiAccountInventory: SubscriptionInventory = { - version: "1", - accounts: { - "openai#1": { - provider: "openai", - enabled: true, - authProfile: "openai-profile-1", - tierId: "plus", - }, - "openai#2": { - provider: "openai", - enabled: true, - authProfile: "openai-profile-2", - tierId: "plus", - }, - }, - }; - const quotaSnapshots = new Map([ - ["openai#1", snap("openai", "openai#1", ["5h", 0])], - ["openai#2", snap("openai", "openai#2", ["5h", 0.80])], - ]); - const openaiOnlyModels = { - "openai/gpt-5.6-sol": models["openai/gpt-5.6-sol"], - }; - const openaiOnlyRules: Record = { - default: { primary: "openai/gpt-5.6-sol", fallbacks: [] }, - }; - - const ranked = rankSubscriptionWeightedCandidates( - "default", - openaiOnlyModels, - openaiOnlyRules, - profile, - multiAccountInventory, - undefined, - "balanced", - undefined, - quotaSnapshots, - ); - - expect(ranked).toHaveLength(1); - expect(ranked[0].selectedAccountId).toBe("openai#2"); - expect(ranked[0].selectedAuthProfile).toBe("openai-profile-2"); - expect(ranked[0].quotaFactor).toBeCloseTo(Math.sqrt(0.80), 4); - }); - - it("ignores a snapshot whose map key does not match snapshot.account", () => { - const mismatched = new Map([ - ["openai#1", snap("openai", "openai#other", ["5h", 0])], - ]); - const noQuota = rankSubscriptionWeightedCandidates( - "default", models, rules, profile, inventory, undefined, "balanced", - ); - const ranked = rankSubscriptionWeightedCandidates( - "default", models, rules, profile, inventory, undefined, "balanced", - undefined, mismatched, - ); - - expect(ranked[0]?.candidate).toBe(noQuota[0]?.candidate); - expect(ranked.find((item) => item.candidate === "openai/gpt-5.6-sol")?.quotaFactor).toBeNull(); - }); - - it("treats stale quota as no-quota (static pressure only)", () => { - const staleSnapshots = new Map([ - ["openai#1", { ...snap("openai", "openai#1", ["5h", 0.02]), status: "stale" }], - ]); - - const ranked = rankSubscriptionWeightedCandidates( - "default", models, rules, profile, inventory, undefined, "balanced", - undefined, staleSnapshots, - ); - - // Stale quota should be treated as no-quota → static pressure wins - expect(ranked.length).toBeGreaterThan(0); - // With stale (null factor), the static ranking should be preserved - const noQuotaRanked = rankSubscriptionWeightedCandidates( - "default", models, rules, profile, inventory, undefined, "balanced", - ); - expect(ranked[0].candidate).toBe(noQuotaRanked[0].candidate); - }); -}); diff --git a/plugin/inventory.ts b/plugin/inventory.ts index 05bbad7..2553897 100644 --- a/plugin/inventory.ts +++ b/plugin/inventory.ts @@ -17,12 +17,6 @@ export type ResolvedProviderCapacity = { preferredAuthProfile: string | null; }; -export type ResolvedProviderAccountCapacity = { - accountId: string; - authProfile: string | null; - routingWeight: number; -}; - function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -156,19 +150,6 @@ function resolveInventoryAccounts(params: { }; } -export function resolveProviderAccountCapacities(params: { - inventory: SubscriptionInventory | undefined; - providerId: string; - category?: TaskCategory; -}): ResolvedProviderAccountCapacity[] { - const accounts = resolveInventoryAccounts(params); - return accounts.scoringAccounts.map((account) => ({ - accountId: account.accountId, - authProfile: account.authProfile, - routingWeight: account.weight, - })); -} - function pickPreferredInventoryAccount( accounts: Array<{ accountId: string; weight: number; authProfile: string | null }>, ): { accountId: string; authProfile: string | null } | null { diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index a9a5268..b959550 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -57,8 +57,8 @@ function assertValidRatio(value: unknown): asserts value is number { function normalizePercentage(value: unknown, label: string): number { assertFiniteNumber(value, label); - if (value < 0) throw new ValueError(`${label} must be non-negative`); - const ratio = value > 1 ? value / 100 : value; + if (value < 0 || value > 100) throw new ValueError(`${label} must be in [0, 100]`); + const ratio = value / 100; assertValidRatio(ratio); return ratio; } diff --git a/plugin/quota-types.ts b/plugin/quota-types.ts index ff46f53..ac1faa5 100644 --- a/plugin/quota-types.ts +++ b/plugin/quota-types.ts @@ -86,9 +86,9 @@ export type NormalizeWindowInput = { used?: number; /** Total limit, when provider reports usage/limit. */ limit?: number; - /** Percentage remaining, when provider reports percent (0..1 or 0..100). */ + /** Remaining percentage on the explicit 0-100 scale. */ percentageRemaining?: number; - /** Percentage used (0..1 or 0..100). */ + /** Consumed percentage on the explicit 0-100 scale. */ percentageUsed?: number; appliesTo?: QuotaAppliesTo; modelIds?: string[]; diff --git a/plugin/router.ts b/plugin/router.ts index c9d946e..99f409d 100644 --- a/plugin/router.ts +++ b/plugin/router.ts @@ -1,4 +1,4 @@ -import { getCanonicalOpenClawProviderId, getProviderCatalogEntry } from "./subscriptions.js"; +import { getProviderCatalogEntry } from "./subscriptions.js"; import type { ModelCapabilities, RoutingModifier, @@ -7,14 +7,8 @@ import type { SubscriptionInventory, TaskCategory, } from "./types.js"; -import { - resolveProviderAccountCapacities, - resolveProviderCapacity, - type ResolvedProviderCapacity, -} from "./inventory.js"; +import { resolveProviderCapacity } from "./inventory.js"; import type { SubscriptionProfile } from "./profile.js"; -import type { NormalizedQuotaSnapshot } from "./quota-types.js"; -import { computeQuotaFactor } from "./quota-policy.js"; const MODIFIER_TARGET_CATEGORIES: Record = { "coding-aware": ["code"], @@ -202,94 +196,6 @@ function getModifierAccountBonus(params: { * pressure, and whether the candidate cleared the benchmark frontier) without changing * any routing decision. See references/explainability-contract.md. */ -type QuotaAwareProviderCapacity = { - routingWeight: number; - quotaFactor: number | null; - selectedAccountId: string | null; - selectedAuthProfile: string | null; - fullyDepleted: boolean; -}; - -function snapshotMatchesAccount( - snapshot: NormalizedQuotaSnapshot | undefined, - accountId: string, - providerId: string, -): snapshot is NormalizedQuotaSnapshot { - if (!snapshot || snapshot.account !== accountId) return false; - return getCanonicalOpenClawProviderId(snapshot.provider) - === getCanonicalOpenClawProviderId(providerId); -} - -function resolveQuotaAwareProviderCapacity(params: { - resolved: ResolvedProviderCapacity | null; - inventory: SubscriptionInventory | undefined; - providerId: string; - category: TaskCategory; - model: string; - quotaSnapshots: ReadonlyMap | undefined; -}): QuotaAwareProviderCapacity { - const { resolved, inventory, providerId, category, model, quotaSnapshots } = params; - const staticWeight = resolved?.routingWeight ?? 0; - const staticResult: QuotaAwareProviderCapacity = { - routingWeight: staticWeight, - quotaFactor: null, - selectedAccountId: resolved?.preferredAccountId ?? null, - selectedAuthProfile: resolved?.preferredAuthProfile ?? null, - fullyDepleted: false, - }; - - // Legacy profile-only capacity has no opaque account scope. Applying a - // provider-level snapshot would be ambiguous, so fail open to static weight. - if (!resolved || resolved.source !== "inventory" || !quotaSnapshots) { - return staticResult; - } - - const accounts = resolveProviderAccountCapacities({ inventory, providerId, category }); - if (accounts.length === 0) return staticResult; - - const evaluated = accounts.map((account) => { - const snapshot = quotaSnapshots.get(account.accountId); - const quotaFactor = snapshotMatchesAccount(snapshot, account.accountId, providerId) - ? computeQuotaFactor(snapshot, model) - : null; - return { - ...account, - quotaFactor, - liveWeight: account.routingWeight * (quotaFactor ?? 1), - }; - }); - - const eligible = evaluated - .filter((account) => account.quotaFactor !== 0) - .sort((a, b) => { - if (b.liveWeight !== a.liveWeight) return b.liveWeight - a.liveWeight; - return a.accountId.localeCompare(b.accountId); - }); - - if (eligible.length === 0) { - return { - routingWeight: 0, - quotaFactor: 0, - selectedAccountId: null, - selectedAuthProfile: null, - fullyDepleted: true, - }; - } - - const selected = eligible[0]; - const redundancyBonus = Math.min(1, 0.25 * Math.max(0, eligible.length - 1)); - const liveRoutingWeight = selected.liveWeight + redundancyBonus; - - return { - routingWeight: liveRoutingWeight, - quotaFactor: selected.quotaFactor, - selectedAccountId: selected.accountId, - selectedAuthProfile: selected.authProfile, - fullyDepleted: false, - }; -} - -/** Ranked model candidate with optional account-scoped quota diagnostics. */ export type RankedCandidate = { candidate: string; benchmarkStrength: number; @@ -297,9 +203,6 @@ export type RankedCandidate = { effectivePressureScore: number; withinFrontier: boolean; originalIndex: number; - quotaFactor?: number | null; - selectedAccountId?: string | null; - selectedAuthProfile?: string | null; }; export function rankSubscriptionWeightedCandidates( @@ -311,7 +214,6 @@ export function rankSubscriptionWeightedCandidates( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, - quotaSnapshots?: ReadonlyMap, ): RankedCandidate[] { if (routingMode !== "balanced") return []; @@ -337,23 +239,13 @@ export function rankSubscriptionWeightedCandidates( availableModels[candidate], isModifierRelevant(routingModifier, category) ? routingModifier : undefined, ); - const quotaCapacity = resolveQuotaAwareProviderCapacity({ - resolved, - inventory, - providerId, - category, - model: candidate, - quotaSnapshots, - }); const modifierAccountBonus = getModifierAccountBonus({ routingModifier, category, inventory, - preferredAccountId: quotaCapacity.selectedAccountId, + preferredAccountId: resolved?.preferredAccountId, }); const speedPriority = getSpeedPriority(availableModels[candidate]); - const basePressureScore = tierWeight * providerBias; - const livePressureScore = quotaCapacity.routingWeight * providerBias; return { candidate, @@ -361,16 +253,12 @@ export function rankSubscriptionWeightedCandidates( tierWeight, providerBias, benchmarkStrength, - pressureScore: basePressureScore, - effectivePressureScore: livePressureScore + modifierAccountBonus, + pressureScore: tierWeight * providerBias, + effectivePressureScore: (tierWeight * providerBias) + modifierAccountBonus, speedPriority, - quotaFactor: quotaCapacity.quotaFactor, - selectedAccountId: quotaCapacity.selectedAccountId, - selectedAuthProfile: quotaCapacity.selectedAuthProfile, - fullyDepleted: quotaCapacity.fullyDepleted, }; }) - .filter((item) => item.pressureScore > 0 && !item.fullyDepleted); + .filter((item) => item.pressureScore > 0); if (candidates.length === 0) return []; @@ -470,9 +358,6 @@ export function rankSubscriptionWeightedCandidates( effectivePressureScore: item.effectivePressureScore, withinFrontier: item.withinFrontier, originalIndex: item.originalIndex, - quotaFactor: item.quotaFactor, - selectedAccountId: item.selectedAccountId, - selectedAuthProfile: item.selectedAuthProfile, })); } @@ -485,7 +370,6 @@ export function getSubscriptionWeightedCandidates( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, - quotaSnapshots?: ReadonlyMap, ): string[] { return rankSubscriptionWeightedCandidates( category, @@ -496,7 +380,6 @@ export function getSubscriptionWeightedCandidates( agentId, routingMode, routingModifier, - quotaSnapshots, ).map((item) => item.candidate); } @@ -508,7 +391,6 @@ export function rankSubscriptionWeightedCandidatesFromPool( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, - quotaSnapshots?: ReadonlyMap, ): RankedCandidate[] { const candidates = Object.keys(availableModels); if (candidates.length === 0) return []; @@ -530,7 +412,6 @@ export function rankSubscriptionWeightedCandidatesFromPool( agentId, routingMode, routingModifier, - quotaSnapshots, ); } @@ -542,7 +423,6 @@ export function getSubscriptionWeightedCandidatesFromPool( agentId: string | undefined, routingMode: RoutingMode = "balanced", routingModifier?: RoutingModifier, - quotaSnapshots?: ReadonlyMap, ): string[] { return rankSubscriptionWeightedCandidatesFromPool( category, @@ -552,6 +432,5 @@ export function getSubscriptionWeightedCandidatesFromPool( agentId, routingMode, routingModifier, - quotaSnapshots, ).map((item) => item.candidate); } From a2ebb96d7f539ac7693580c1a50b7339c8c92599 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:45:46 +0300 Subject: [PATCH 08/16] fix(quota): validate timestamp syntax and calendar dates --- integrations/hermes/quota.py | 8 +++++++- integrations/hermes/test_quota.py | 10 ++++++++-- plugin/__tests__/quota-normalize.test.ts | 13 +++++++++---- plugin/quota-normalize.ts | 24 ++++++++++++++++++++++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index ce5a3cc..672ae6b 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -8,6 +8,7 @@ from __future__ import annotations import math +import re from datetime import datetime from typing import Any, TypeGuard @@ -30,6 +31,9 @@ "percent", } VALID_APPLICABILITY = {"inference", "mcp", "model"} +TIMESTAMP_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$" +) def _is_number(value: Any) -> TypeGuard[int | float]: @@ -81,10 +85,12 @@ def _normalize_identifier(value: Any, label: str) -> str: def _normalize_timestamp(value: Any, label: str) -> str: normalized = _normalize_identifier(value, label) + if TIMESTAMP_RE.fullmatch(normalized) is None: + raise ValueError(f"{label} must be an ISO-8601 timestamp with timezone") try: datetime.fromisoformat(normalized.replace("Z", "+00:00")) except ValueError as exc: - raise ValueError(f"{label} must be an ISO-8601 timestamp") from exc + raise ValueError(f"{label} must contain a valid calendar date and time") from exc return normalized diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 51163c6..bbb3d87 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -93,8 +93,14 @@ def test_rejects_provider_mismatch(self): validate_snapshot(self._valid(), expected_provider="openai") def test_rejects_invalid_timestamp(self): - with self.assertRaises(ValueError): - validate_snapshot({**self._valid(), "fetchedAt": "not-a-date"}) + for fetched_at in ( + "1", + "07/24/2026", + "2026-07-24T17:00:00", + "2026-02-30T00:00:00Z", + ): + with self.subTest(fetched_at=fetched_at), self.assertRaises(ValueError): + validate_snapshot({**self._valid(), "fetchedAt": fetched_at}) class TestNormalizeSnapshot(unittest.TestCase): diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 039b061..5269bd3 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -219,10 +219,15 @@ describe("validateNormalizedSnapshot", () => { ).not.toThrow(); }); - it("rejects an invalid fetchedAt timestamp", () => { - expect(() => - validateNormalizedSnapshot({ ...validSnapshot, fetchedAt: "not-a-date" }), - ).toThrow(); + it("rejects non-ISO, timezone-free, and impossible timestamps", () => { + for (const fetchedAt of [ + "1", + "07/24/2026", + "2026-07-24T17:00:00", + "2026-02-30T00:00:00Z", + ]) { + expect(() => validateNormalizedSnapshot({ ...validSnapshot, fetchedAt })).toThrow(); + } }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index b959550..2d70642 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -76,8 +76,28 @@ function normalizeIdentifier(value: unknown, label: string): string { function normalizeTimestamp(value: unknown, label: string): string { const normalized = normalizeIdentifier(value, label); - const millis = Date.parse(normalized); - if (!Number.isFinite(millis)) throw new ValueError(`${label} must be an ISO-8601 timestamp`); + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/.exec(normalized); + if (!match) throw new ValueError(`${label} must be an ISO-8601 timestamp with timezone`); + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === undefined ? 0 : Number(match[8]); + const offsetMinute = match[9] === undefined ? 0 : Number(match[9]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + if ( + month < 1 || month > 12 || + day < 1 || day > daysInMonth[month - 1] || + hour > 23 || minute > 59 || second > 59 || + offsetHour > 23 || offsetMinute > 59 + ) { + throw new ValueError(`${label} must contain a valid calendar date and time`); + } return normalized; } From 5b90538f0f4cf36e4e7f6c099554c186dbeb7b72 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:45:55 +0300 Subject: [PATCH 09/16] fix: harden quota normalization against malformed host payloads Codex P2 findings on PR #55: - Reject unhashable snapshot status (list/dict) before membership test - Catch OverflowError from math.isnan on oversized integers - Validate modelIds is a list before iteration (Python + TS parity) - Verify snapshot provider/account identity in selectAccountByQuota All malformed inputs now fail closed to invalid_response instead of crashing the normalization path. Tests: Python 220/220, TS plugin 46/46, scripts 83/83. --- integrations/hermes/quota.py | 21 ++++++--- integrations/hermes/test_quota.py | 64 +++++++++++++++++++++++++++ plugin/__tests__/quota-policy.test.ts | 23 ++++++++++ plugin/quota-normalize.ts | 10 ++++- plugin/quota-policy.ts | 5 +++ 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index 672ae6b..1a9adb0 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -49,10 +49,13 @@ def _assert_finite_number(value: Any, label: str) -> None: raise TypeError(f"{label} must be numeric, got boolean") if not isinstance(value, (int, float)): raise TypeError(f"{label} must be numeric") - if math.isnan(value): - raise ValueError(f"{label} must not be NaN") - if math.isinf(value): - raise ValueError(f"{label} must be finite") + try: + if math.isnan(value): + raise ValueError(f"{label} must not be NaN") + if math.isinf(value): + raise ValueError(f"{label} must be finite") + except OverflowError: + raise ValueError(f"{label} is too large to represent as a finite number") def _assert_valid_ratio(value: Any) -> None: @@ -153,6 +156,8 @@ def normalize_window( if applies_to not in {"inference", "mcp", "model"}: raise ValueError(f"unknown applies_to value '{applies_to}'") + if model_ids is not None and not isinstance(model_ids, list): + raise TypeError("modelIds must be a list") mids = [_normalize_identifier(model_id, "modelId") for model_id in (model_ids or [])] if len(set(mids)) != len(mids): raise ValueError("modelIds must be unique") @@ -262,7 +267,11 @@ def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: account = _normalize_identifier(payload.get("account"), "account") fetched_at = _normalize_timestamp(payload.get("fetchedAt"), "fetchedAt") requested_status = payload.get("status", "fresh") - status = requested_status if requested_status in VALID_STATUSES else "invalid_response" + status = ( + requested_status + if isinstance(requested_status, str) and requested_status in VALID_STATUSES + else "invalid_response" + ) windows: list[dict[str, Any]] = [] try: @@ -273,7 +282,7 @@ def normalize_snapshot(payload: dict[str, Any]) -> dict[str, Any]: ids = {window["id"] for window in windows} if len(ids) != len(windows): raise ValueError("window IDs must be unique") - except (KeyError, TypeError, ValueError): + except (KeyError, TypeError, ValueError, OverflowError): windows = [] status = "invalid_response" diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index bbb3d87..2cc7e95 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -246,6 +246,70 @@ def test_duplicate_window_ids_fail_closed(self): self.assertEqual(snap["status"], "invalid_response") self.assertEqual(snap["windows"], []) + def test_unhashable_status_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "status": ["fresh"], + "windows": [{"id": "w", "rawKind": "TOKENS_LIMIT", "remainingRatio": 0.5}], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + + def test_dict_status_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "status": {"evil": True}, + "windows": [{"id": "w", "rawKind": "TOKENS_LIMIT", "remainingRatio": 0.5}], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + + def test_oversized_integer_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "remainingRatio": 10 ** 10000, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + self.assertEqual(snap["windows"], []) + + def test_oversized_integer_in_used_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "used": 10 ** 10000, "limit": 10 ** 10001, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + + def test_non_list_model_ids_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "remainingRatio": 0.5, "modelIds": {"fake-model": True}, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + self.assertEqual(snap["windows"], []) + + def test_false_model_ids_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "remainingRatio": 0.5, "appliesTo": "model", "modelIds": False, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + class TestQuotaPolicy(unittest.TestCase): diff --git a/plugin/__tests__/quota-policy.test.ts b/plugin/__tests__/quota-policy.test.ts index b1b42f5..a49f1e1 100644 --- a/plugin/__tests__/quota-policy.test.ts +++ b/plugin/__tests__/quota-policy.test.ts @@ -163,4 +163,27 @@ describe("selectAccountByQuota", () => { const selected = selectAccountByQuota(accounts, "zai", "zai/glm-5.2"); expect(selected?.account).toBe("zai#1"); }); + + it("rejects a mismatched snapshot from another account", () => { + const healthyOther: NormalizedQuotaSnapshot = { + provider: "openai", + account: "openai#1", + status: "fresh", + windows: [{ id: "5h", kind: "tokens_limit", appliesTo: "inference", modelIds: [], remainingRatio: 0.99 }], + fetchedAt: "2026-07-24T17:00:00Z", + }; + const accounts = [ + { provider: "openai", account: "openai#2", tierWeight: 2.1, providerBias: 0.7, snapshot: healthyOther }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected).toBeNull(); + }); + + it("accepts a matched snapshot and scores normally", () => { + const accounts = [ + { provider: "openai", account: "openai#1", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "fresh", ["5h", 0.80]) }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected?.account).toBe("openai#1"); + }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 2d70642..07792fa 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -144,7 +144,11 @@ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuo throw new ValueError(`unknown appliesTo value "${String(appliesTo)}"`); } - const modelIds = (input.modelIds ?? []).map((modelId) => normalizeIdentifier(modelId, "modelId")); + const modelIdsInput = input.modelIds ?? []; + if (!Array.isArray(modelIdsInput)) { + throw new TypeError("modelIds must be an array"); + } + const modelIds = modelIdsInput.map((modelId) => normalizeIdentifier(modelId, "modelId")); if (new Set(modelIds).size !== modelIds.length) { throw new ValueError("modelIds must be unique"); } @@ -241,7 +245,9 @@ export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuot const account = normalizeIdentifier(payload.account, "account"); const fetchedAt = normalizeTimestamp(payload.fetchedAt, "fetchedAt"); const requestedStatus = payload.status ?? "fresh"; - const status = VALID_STATUSES.has(requestedStatus) ? requestedStatus : "invalid_response"; + const status = typeof requestedStatus === "string" && VALID_STATUSES.has(requestedStatus as QuotaSnapshotStatus) + ? requestedStatus + : "invalid_response"; let windows: NormalizedQuotaWindow[] = []; let normalizedStatus: QuotaSnapshotStatus = status; diff --git a/plugin/quota-policy.ts b/plugin/quota-policy.ts index 7ccd86a..ff67307 100644 --- a/plugin/quota-policy.ts +++ b/plugin/quota-policy.ts @@ -95,6 +95,8 @@ export function computeLivePressure( /** * Select the best account for a provider and model. * Returns null if no account has a usable live pressure. + * Rejects snapshots whose provider or account do not match the candidate, + * guarding against mis-keyed or swapped snapshots during host integration. */ export function selectAccountByQuota( accounts: QuotaAwareAccount[], @@ -103,6 +105,9 @@ export function selectAccountByQuota( ): QuotaAwareAccount | null { const eligible = accounts.filter((a) => { if (a.provider !== provider) return false; + if (a.snapshot && (a.snapshot.provider !== a.provider || a.snapshot.account !== a.account)) { + return false; + } const pressure = computeLivePressure(a.tierWeight, a.providerBias, a.snapshot, model); return pressure !== null && pressure > 0; }); From 5482cea99bbcd35e3c73a41e70652d547c415025 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:54:28 +0300 Subject: [PATCH 10/16] fix: reject explicit null status and appliesTo in TS normalizer Codex P2 follow-up on PR #55: - payload.status=null must produce invalid_response, not default to fresh - window.appliesTo=null must throw, not silently widen to inference - Replace ?? with explicit undefined checks for TS/Python parity Tests: TS plugin 48/48, Python 220/220, scripts 83/83. --- plugin/__tests__/quota-normalize.test.ts | 29 ++++++++++++++++++++++++ plugin/quota-normalize.ts | 7 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 5269bd3..81fb286 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -389,4 +389,33 @@ describe("normalizeSnapshot", () => { expect(JSON.stringify(snapshot)).not.toContain("account_email"); expect(JSON.stringify(snapshot)).not.toContain("access_token"); }); + + it("rejects explicit null status instead of defaulting to fresh", () => { + const snapshot = normalizeSnapshot({ + provider: "zai", + account: "zai#1", + status: null as unknown as undefined, + windows: [{ id: "w", rawKind: "TOKENS_LIMIT", remainingRatio: 0.5 }], + fetchedAt: "2026-07-24T17:33:47Z", + }); + expect(snapshot.status).toBe("invalid_response"); + }); + + it("rejects explicit null appliesTo instead of widening to inference", () => { + const snapshot = normalizeSnapshot({ + provider: "zai", + account: "zai#1", + windows: [ + { + id: "w", + rawKind: "TOKENS_LIMIT", + remainingRatio: 0.5, + appliesTo: null as unknown as undefined, + }, + ], + fetchedAt: "2026-07-24T17:33:47Z", + }); + expect(snapshot.status).toBe("invalid_response"); + expect(snapshot.windows).toEqual([]); + }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 07792fa..5ca8e03 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -139,12 +139,15 @@ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuo throw new ValueError(`cannot derive remainingRatio for window "${id}"`); } + if (input.appliesTo === null) { + throw new ValueError("appliesTo must not be null"); + } const appliesTo: QuotaAppliesTo = input.appliesTo ?? "inference"; if (appliesTo !== "inference" && appliesTo !== "mcp" && appliesTo !== "model") { throw new ValueError(`unknown appliesTo value "${String(appliesTo)}"`); } - const modelIdsInput = input.modelIds ?? []; + const modelIdsInput = input.modelIds === undefined ? [] : input.modelIds; if (!Array.isArray(modelIdsInput)) { throw new TypeError("modelIds must be an array"); } @@ -244,7 +247,7 @@ export function normalizeSnapshot(payload: ProviderQuotaPayload): NormalizedQuot const provider = normalizeIdentifier(payload.provider, "provider"); const account = normalizeIdentifier(payload.account, "account"); const fetchedAt = normalizeTimestamp(payload.fetchedAt, "fetchedAt"); - const requestedStatus = payload.status ?? "fresh"; + const requestedStatus = payload.status === undefined ? "fresh" : payload.status; const status = typeof requestedStatus === "string" && VALID_STATUSES.has(requestedStatus as QuotaSnapshotStatus) ? requestedStatus : "invalid_response"; From 7139624a4a001f950468b7ba5e0200a4f1d9e3f7 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:03:15 +0300 Subject: [PATCH 11/16] fix: reject null quota fields and whitespace model IDs Codex P2 follow-up round 3 on PR #55: - Python _input_window: reject explicit null in any quantitative field - validate_snapshot: reject modelIds that need trimming (parity with TS) - TS validateNormalizedSnapshot: require pre-canonicalized model IDs Tests: Python 44/44, TS 49/49, scripts 83/83. --- integrations/hermes/quota.py | 9 +++++++ integrations/hermes/test_quota.py | 32 ++++++++++++++++++++++++ plugin/__tests__/quota-normalize.test.ts | 20 +++++++++++++++ plugin/quota-normalize.ts | 8 +++++- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index 1a9adb0..31947b8 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -226,6 +226,8 @@ def validate_snapshot( if not isinstance(model_ids, list): raise TypeError("modelIds must be a list") normalized_models = [_normalize_identifier(model_id, "modelId") for model_id in model_ids] + if any(nm != mid for nm, mid in zip(normalized_models, model_ids)): + raise ValueError("modelIds must be pre-canonicalized") if len(set(normalized_models)) != len(normalized_models): raise ValueError("modelIds must be unique") if applies_to == "model" and not normalized_models: @@ -247,6 +249,13 @@ def validate_snapshot( def _input_window(item: Any) -> dict[str, Any]: if not isinstance(item, dict): raise TypeError("window must be an object") + for key in ( + "rawKind", "id", "remainingRatio", "used", "limit", + "percentageRemaining", "percentageUsed", "appliesTo", + "modelIds", "windowSeconds", "resetAt", + ): + if key in item and item[key] is None: + raise TypeError(f"{key} must not be null") return normalize_window( item.get("rawKind"), id=item.get("id"), diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 2cc7e95..2ea93db 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -310,6 +310,38 @@ def test_false_model_ids_fails_closed(self): }) self.assertEqual(snap["status"], "invalid_response") + def test_null_remaining_ratio_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "remainingRatio": None, "used": 0, "limit": 100, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + + def test_null_field_in_window_fails_closed(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": None, + "remainingRatio": 0.5, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "invalid_response") + + def test_whitespace_model_id_in_validate_rejected(self): + from quota import validate_snapshot + snap = { + "provider": "zai", "account": "zai#1", "status": "fresh", + "windows": [{"id": "w", "kind": "tokens_limit", "appliesTo": "model", "modelIds": [" openai/gpt-5.6-sol "], "remainingRatio": 0.5}], + "fetchedAt": "2026-07-24T17:00:00Z", + } + with self.assertRaises(ValueError): + validate_snapshot(snap) + class TestQuotaPolicy(unittest.TestCase): diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 81fb286..2da6935 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -418,4 +418,24 @@ describe("normalizeSnapshot", () => { expect(snapshot.status).toBe("invalid_response"); expect(snapshot.windows).toEqual([]); }); + + it("rejects whitespace-padded model IDs in validateNormalizedSnapshot", () => { + expect(() => + validateNormalizedSnapshot({ + provider: "zai", + account: "zai#1", + status: "fresh", + windows: [ + { + id: "w", + kind: "tokens_limit", + appliesTo: "model", + modelIds: [" openai/gpt-5.6-sol "], + remainingRatio: 0.5, + } as any, + ], + fetchedAt: "2026-07-24T17:33:47Z", + }), + ).toThrow(); + }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 5ca8e03..5f3c503 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -218,7 +218,13 @@ export function validateNormalizedSnapshot( throw new ValueError(`unknown appliesTo value "${String(window.appliesTo)}"`); } if (!Array.isArray(window.modelIds)) throw new TypeError("modelIds must be an array"); - const modelIds = window.modelIds.map((modelId) => normalizeIdentifier(modelId, "modelId")); + const modelIds = window.modelIds.map((modelId) => { + const trimmed = normalizeIdentifier(modelId, "modelId"); + if (trimmed !== modelId) { + throw new ValueError("modelIds must be pre-canonicalized"); + } + return trimmed; + }); if (new Set(modelIds).size !== modelIds.length) { throw new ValueError("modelIds must be unique"); } From 23b5b65ca4157d0a66aef9a91f4b299093e20cac Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:11:23 +0300 Subject: [PATCH 12/16] fix: preserve static candidates when live quota is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on PR #55: selectAccountByQuota dropped accounts with null live pressure (stale/absent/unsupported), excluding higher-static candidates. Now fails open: null → static pressure fallback, only confirmed depleted (0) accounts are excluded. Tests: TS plugin 52/52, Python 223/223, scripts 83/83. --- plugin/__tests__/quota-policy.test.ts | 32 +++++++++++++++++++++++++-- plugin/quota-policy.ts | 19 +++++++++++----- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/plugin/__tests__/quota-policy.test.ts b/plugin/__tests__/quota-policy.test.ts index a49f1e1..bf596d1 100644 --- a/plugin/__tests__/quota-policy.test.ts +++ b/plugin/__tests__/quota-policy.test.ts @@ -141,11 +141,12 @@ describe("selectAccountByQuota", () => { expect(selected?.account).toBe("openai#2"); }); - it("returns null when all accounts are stale", () => { + it("returns an account via static fallback when all accounts are stale", () => { const accounts = [ { provider: "openai", account: "openai#1", tierWeight: 2.1, providerBias: 0.7, snapshot: snap("openai", "openai#1", "stale", ["5h", 1.0]) }, ]; - expect(selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol")).toBeNull(); + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected?.account).toBe("openai#1"); }); it("returns null when all accounts are depleted", () => { @@ -155,6 +156,33 @@ describe("selectAccountByQuota", () => { expect(selectAccountByQuota(accounts, "zai", "zai/glm-5.2")).toBeNull(); }); + it("falls open to static pressure when quota is stale", () => { + const accounts = [ + { provider: "openai", account: "openai#1", tierWeight: 1.0, providerBias: 1.0, snapshot: snap("openai", "openai#1", "stale", ["5h", 1.0]) }, + { provider: "openai", account: "openai#2", tierWeight: 3.0, providerBias: 2.0, snapshot: snap("openai", "openai#2", "stale", ["5h", 1.0]) }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected?.account).toBe("openai#2"); + }); + + it("falls open to static pressure when snapshot is null", () => { + const accounts = [ + { provider: "openai", account: "openai#1", tierWeight: 5.0, providerBias: 2.0, snapshot: null }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + expect(selected?.account).toBe("openai#1"); + }); + + it("prefers live-constrained account only when it still beats static fallback", () => { + const accounts = [ + { provider: "openai", account: "openai#1", tierWeight: 1.0, providerBias: 1.0, snapshot: null }, + { provider: "openai", account: "openai#2", tierWeight: 5.0, providerBias: 2.0, snapshot: snap("openai", "openai#2", "fresh", ["5h", 0.1]) }, + ]; + const selected = selectAccountByQuota(accounts, "openai", "openai/gpt-5.6-sol"); + // static=1.0 vs live=5*2*sqrt(0.1)=3.16 → live wins + expect(selected?.account).toBe("openai#2"); + }); + it("filters by provider", () => { const accounts = [ { provider: "zai", account: "zai#1", tierWeight: 5.0, providerBias: 1.25, snapshot: snap("zai", "zai#1", "fresh", ["5h", 0.9]) }, diff --git a/plugin/quota-policy.ts b/plugin/quota-policy.ts index ff67307..a4e5204 100644 --- a/plugin/quota-policy.ts +++ b/plugin/quota-policy.ts @@ -94,9 +94,13 @@ export function computeLivePressure( /** * Select the best account for a provider and model. - * Returns null if no account has a usable live pressure. + * Returns null if no account has a usable pressure. * Rejects snapshots whose provider or account do not match the candidate, * guarding against mis-keyed or swapped snapshots during host integration. + * + * Fail-open: when live quota is unavailable (stale/absent/unsupported), + * the account falls back to static pressure (tierWeight * providerBias). + * Only accounts with a confirmed depleted quota (live pressure = 0) are excluded. */ export function selectAccountByQuota( accounts: QuotaAwareAccount[], @@ -108,15 +112,20 @@ export function selectAccountByQuota( if (a.snapshot && (a.snapshot.provider !== a.provider || a.snapshot.account !== a.account)) { return false; } - const pressure = computeLivePressure(a.tierWeight, a.providerBias, a.snapshot, model); - return pressure !== null && pressure > 0; + const live = computeLivePressure(a.tierWeight, a.providerBias, a.snapshot, model); + // null = quota unavailable → fail open to static pressure + // 0 = confirmed depleted → exclude + if (live === 0) return false; + return true; }); if (eligible.length === 0) return null; return eligible.reduce((best, current) => { - const bestPressure = computeLivePressure(best.tierWeight, best.providerBias, best.snapshot, model) ?? 0; - const currentPressure = computeLivePressure(current.tierWeight, current.providerBias, current.snapshot, model) ?? 0; + const bestPressure = computeLivePressure(best.tierWeight, best.providerBias, best.snapshot, model) + ?? (best.tierWeight * best.providerBias); + const currentPressure = computeLivePressure(current.tierWeight, current.providerBias, current.snapshot, model) + ?? (current.tierWeight * current.providerBias); if (currentPressure > bestPressure) return current; if (currentPressure === bestPressure && current.account < best.account) return current; return best; From 380e3cdea8ad668e4b62674f7d970a039c2385f6 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:18:42 +0300 Subject: [PATCH 13/16] fix: reject null window id and honor explicitZeroUsage marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on PR #55: - normalizeQuotaWindow: reject explicit null id (parity with Python) - honor explicitZeroUsage=true → remainingRatio=0 (depleted) - Previously the advertised field was silently ignored Tests: TS plugin 55/55, Python 223/223, scripts 83/83. --- plugin/__tests__/quota-normalize.test.ts | 27 ++++++++++++++++++++++++ plugin/quota-normalize.ts | 7 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 2da6935..b4fcdd7 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -438,4 +438,31 @@ describe("normalizeSnapshot", () => { }), ).toThrow(); }); + + it("rejects explicit null window id", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + id: null as unknown as undefined, + remainingRatio: 0.5, + }), + ).toThrow(); + }); + + it("honors explicitZeroUsage marker as depleted (ratio=0)", () => { + const window = normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + explicitZeroUsage: true, + }); + expect(window.remainingRatio).toBe(0); + }); + + it("does not treat explicitZeroUsage=false as zero", () => { + const window = normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + explicitZeroUsage: false, + remainingRatio: 0.5, + }); + expect(window.remainingRatio).toBeCloseTo(0.5); + }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 5f3c503..0f01065 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -116,11 +116,16 @@ function mapWindowKind(rawKind: string): QuotaWindowKind { /** Normalize one token-free host adapter window. */ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuotaWindow { const rawKind = normalizeIdentifier(input.rawKind, "rawKind"); + if (input.id === null) { + throw new TypeError("window id must not be null"); + } const id = normalizeIdentifier(input.id ?? rawKind, "window id"); const kind = mapWindowKind(rawKind); let remainingRatio: number; - if (input.remainingRatio !== undefined) { + if (input.explicitZeroUsage === true) { + remainingRatio = 0; + } else if (input.remainingRatio !== undefined) { assertValidRatio(input.remainingRatio); remainingRatio = input.remainingRatio; } else if (input.percentageRemaining !== undefined) { From b8321d99f2f6c33c3065ca0b5b21bb66eb9ff77c Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:27:56 +0300 Subject: [PATCH 14/16] fix: explicitZeroUsage means fully available (ratio=1), not depleted Codex P2 correction: zero usage = no consumption = full quota. Was inverted to 0 (depleted) which would exclude healthy accounts. Tests: TS plugin 55/55. --- plugin/__tests__/quota-normalize.test.ts | 4 ++-- plugin/quota-normalize.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index b4fcdd7..675db36 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -449,12 +449,12 @@ describe("normalizeSnapshot", () => { ).toThrow(); }); - it("honors explicitZeroUsage marker as depleted (ratio=0)", () => { + it("honors explicitZeroUsage marker as fully available (ratio=1)", () => { const window = normalizeQuotaWindow({ rawKind: "TOKENS_LIMIT", explicitZeroUsage: true, }); - expect(window.remainingRatio).toBe(0); + expect(window.remainingRatio).toBe(1); }); it("does not treat explicitZeroUsage=false as zero", () => { diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 0f01065..25c29d2 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -124,7 +124,7 @@ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuo let remainingRatio: number; if (input.explicitZeroUsage === true) { - remainingRatio = 0; + remainingRatio = 1.0; } else if (input.remainingRatio !== undefined) { assertValidRatio(input.remainingRatio); remainingRatio = input.remainingRatio; From 838ed279cdf2ceff69ddbd1a7132d8027d4b10bc Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:34:47 +0300 Subject: [PATCH 15/16] fix: honor explicitZeroUsage in Python parity normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: TS now maps explicitZeroUsage=true → remainingRatio=1, but Python _input_window didn't forward the marker, causing the same valid observation to fail as invalid_response. Tests: Python 224/224, TS 55/55, scripts 83/83. --- integrations/hermes/quota.py | 6 +++++- integrations/hermes/test_quota.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/integrations/hermes/quota.py b/integrations/hermes/quota.py index 31947b8..00984ea 100644 --- a/integrations/hermes/quota.py +++ b/integrations/hermes/quota.py @@ -129,12 +129,15 @@ def normalize_window( model_ids: list[str] | None = None, window_seconds: float | None = None, reset_at: str | None = None, + explicit_zero_usage: bool = False, ) -> dict[str, Any]: normalized_kind = _normalize_identifier(raw_kind, "raw_kind") window_id = _normalize_identifier(id if id is not None else normalized_kind, "window id") kind = _map_window_kind(normalized_kind) - if remaining_ratio is not None: + if explicit_zero_usage is True: + ratio = 1.0 + elif remaining_ratio is not None: _assert_valid_ratio(remaining_ratio) ratio = float(remaining_ratio) elif percentage_remaining is not None: @@ -268,6 +271,7 @@ def _input_window(item: Any) -> dict[str, Any]: model_ids=item.get("modelIds"), window_seconds=item.get("windowSeconds"), reset_at=item.get("resetAt"), + explicit_zero_usage=item.get("explicitZeroUsage", False), ) diff --git a/integrations/hermes/test_quota.py b/integrations/hermes/test_quota.py index 2ea93db..d4aa613 100644 --- a/integrations/hermes/test_quota.py +++ b/integrations/hermes/test_quota.py @@ -342,6 +342,19 @@ def test_whitespace_model_id_in_validate_rejected(self): with self.assertRaises(ValueError): validate_snapshot(snap) + def test_explicit_zero_usage_means_full_quota(self): + snap = normalize_snapshot({ + "provider": "zai", "account": "zai#1", + "windows": [{ + "id": "w", "rawKind": "TOKENS_LIMIT", + "explicitZeroUsage": True, + }], + "fetchedAt": "2026-07-24T17:00:00Z", + }) + self.assertEqual(snap["status"], "fresh") + self.assertEqual(len(snap["windows"]), 1) + self.assertAlmostEqual(snap["windows"][0]["remainingRatio"], 1.0) + class TestQuotaPolicy(unittest.TestCase): From 104d028fec03b163f1fb823aca4831cadd582edd Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:41:59 +0300 Subject: [PATCH 16/16] fix: validate all supplied meter fields before selecting one (TS) Codex P2: normalizeQuotaWindow only validated the selected meter, skipping present-but-null alternatives. Now validates every supplied meter field upfront, matching Python _input_window parity. Tests: TS plugin 57/57, Python 224/224, scripts 83/83. --- plugin/__tests__/quota-normalize.test.ts | 18 ++++++++++++++++++ plugin/quota-normalize.ts | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/plugin/__tests__/quota-normalize.test.ts b/plugin/__tests__/quota-normalize.test.ts index 675db36..55a69b6 100644 --- a/plugin/__tests__/quota-normalize.test.ts +++ b/plugin/__tests__/quota-normalize.test.ts @@ -465,4 +465,22 @@ describe("normalizeSnapshot", () => { }); expect(window.remainingRatio).toBeCloseTo(0.5); }); + + it("rejects null in any supplied meter field even when a valid meter exists", () => { + expect(() => + normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + remainingRatio: 0.5, + used: null as unknown as number, + limit: 100, + }), + ).toThrow(); + expect(() => + normalizeQuotaWindow({ + rawKind: "TOKENS_LIMIT", + percentageUsed: 10, + percentageRemaining: null as unknown as number, + }), + ).toThrow(); + }); }); diff --git a/plugin/quota-normalize.ts b/plugin/quota-normalize.ts index 25c29d2..7e921ed 100644 --- a/plugin/quota-normalize.ts +++ b/plugin/quota-normalize.ts @@ -122,6 +122,19 @@ export function normalizeQuotaWindow(input: NormalizeWindowInput): NormalizedQuo const id = normalizeIdentifier(input.id ?? rawKind, "window id"); const kind = mapWindowKind(rawKind); + // Validate every supplied meter field before selecting one, matching + // the Python _input_window null-rejection contract. A present-but-null + // meter must fail closed rather than being silently skipped. + const meterFields = [ + "remainingRatio", "used", "limit", + "percentageRemaining", "percentageUsed", "windowSeconds", "resetAt", + ] as const; + for (const field of meterFields) { + if ((input as Record)[field] === null) { + throw new TypeError(`${field} must not be null`); + } + } + let remainingRatio: number; if (input.explicitZeroUsage === true) { remainingRatio = 1.0;