Skip to content

Commit ab77452

Browse files
committed
feat: normalize quota and combos API responses with shared contracts
Introduce `normalizeQuotaResponse` and `normalizeCombosResponse` helpers to handle varying API response shapes (array vs wrapped object) consistently across MCP server and advanced tools. Add optional `meta` field to checkQuotaOutput schema and update sourceEndpoints to reflect current API routes.
1 parent 1e1a9c9 commit ab77452

File tree

8 files changed

+289
-29
lines changed

8 files changed

+289
-29
lines changed

open-sse/mcp-server/schemas/tools.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ export const checkQuotaOutput = z.object({
225225
tokenStatus: z.enum(["valid", "expiring", "expired", "refreshing"]),
226226
})
227227
),
228+
meta: z
229+
.object({
230+
generatedAt: z.string(),
231+
filters: z.object({
232+
provider: z.string().nullable(),
233+
connectionId: z.string().nullable(),
234+
}),
235+
totalProviders: z.number(),
236+
})
237+
.optional(),
228238
});
229239

230240
export const checkQuotaTool: McpToolDefinition<typeof checkQuotaInput, typeof checkQuotaOutput> = {
@@ -236,7 +246,7 @@ export const checkQuotaTool: McpToolDefinition<typeof checkQuotaInput, typeof ch
236246
scopes: ["read:quota"],
237247
auditLevel: "basic",
238248
phase: 1,
239-
sourceEndpoints: ["/api/usage/[connectionId]", "/api/token-health"],
249+
sourceEndpoints: ["/api/usage/quota", "/api/token-health", "/api/rate-limits"],
240250
};
241251

242252
// --- Tool 6: omniroute_route_request ---

open-sse/mcp-server/server.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
handleExplainRoute,
3838
handleGetSessionSnapshot,
3939
} from "./tools/advancedTools.ts";
40+
import { normalizeQuotaResponse } from "@/shared/contracts/quota";
4041

4142
// ============ Configuration ============
4243

@@ -105,7 +106,12 @@ async function handleGetHealth() {
105106
async function handleListCombos(args: { includeMetrics?: boolean }) {
106107
const start = Date.now();
107108
try {
108-
const combos = (await omniRouteFetch("/api/combos")) as any;
109+
const combosRaw = (await omniRouteFetch("/api/combos")) as any;
110+
const combos = Array.isArray(combosRaw?.combos)
111+
? combosRaw.combos
112+
: Array.isArray(combosRaw)
113+
? combosRaw
114+
: [];
109115
let metrics: Record<string, unknown> = {};
110116
if (args.includeMetrics) {
111117
metrics = (await omniRouteFetch("/api/combos/metrics").catch(() => ({}))) as Record<
@@ -174,10 +180,10 @@ async function handleCheckQuota(args: { provider?: string; connectionId?: string
174180
if (args.connectionId) path += `?connectionId=${encodeURIComponent(args.connectionId)}`;
175181
else if (args.provider) path += `?provider=${encodeURIComponent(args.provider)}`;
176182

177-
const raw = (await omniRouteFetch(path)) as any;
178-
const result = {
179-
providers: Array.isArray(raw?.providers) ? raw.providers : Array.isArray(raw) ? raw : [],
180-
};
183+
const result = normalizeQuotaResponse(await omniRouteFetch(path), {
184+
provider: args.provider || null,
185+
connectionId: args.connectionId || null,
186+
});
181187

182188
await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true);
183189
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };

open-sse/mcp-server/tools/advancedTools.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import { logToolCall } from "../audit.ts";
17+
import { normalizeQuotaResponse } from "@/shared/contracts/quota";
1718

1819
const OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
1920
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
@@ -33,6 +34,14 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise<unknow
3334
return response.json();
3435
}
3536

37+
function normalizeCombosResponse(raw: unknown): any[] {
38+
if (Array.isArray(raw)) return raw;
39+
if (raw && typeof raw === "object" && Array.isArray((raw as any).combos)) {
40+
return (raw as any).combos;
41+
}
42+
return [];
43+
}
44+
3645
// ============ In-Memory State ============
3746

3847
interface BudgetGuardState {
@@ -77,9 +86,12 @@ export async function handleSimulateRoute(args: {
7786
apiFetch("/api/usage/quota"),
7887
]);
7988

80-
const combos = combosRaw.status === "fulfilled" ? (combosRaw.value as any[]) : [];
89+
const combos = combosRaw.status === "fulfilled" ? normalizeCombosResponse(combosRaw.value) : [];
8190
const health = healthRaw.status === "fulfilled" ? (healthRaw.value as any) : {};
82-
const quota = quotaRaw.status === "fulfilled" ? (quotaRaw.value as any) : {};
91+
const quota =
92+
quotaRaw.status === "fulfilled"
93+
? normalizeQuotaResponse(quotaRaw.value)
94+
: normalizeQuotaResponse({});
8395

8496
// Find target combo
8597
const targetCombo = args.combo
@@ -97,7 +109,7 @@ export async function handleSimulateRoute(args: {
97109

98110
const models = targetCombo.models || targetCombo.data?.models || [];
99111
const breakers = health?.circuitBreakers || [];
100-
const providers = quota?.providers || (Array.isArray(quota) ? quota : []);
112+
const providers = quota.providers;
101113

102114
// Simulate path
103115
const simulatedPath = models.map((m: any, idx: number) => {
@@ -233,7 +245,7 @@ export async function handleTestCombo(args: { comboId: string; testPrompt: strin
233245
const start = Date.now();
234246
try {
235247
// Get combo details
236-
const combos = (await apiFetch("/api/combos")) as any[];
248+
const combos = normalizeCombosResponse(await apiFetch("/api/combos"));
237249
const combo = combos.find((c: any) => c.id === args.comboId || c.name === args.comboId);
238250
if (!combo) {
239251
return {
@@ -340,13 +352,14 @@ export async function handleGetProviderMetrics(args: { provider: string }) {
340352
]);
341353

342354
const health = healthRaw.status === "fulfilled" ? (healthRaw.value as any) : {};
343-
const quota = quotaRaw.status === "fulfilled" ? (quotaRaw.value as any) : {};
355+
const quota =
356+
quotaRaw.status === "fulfilled"
357+
? normalizeQuotaResponse(quotaRaw.value, { provider: args.provider })
358+
: normalizeQuotaResponse({});
344359
const analytics = analyticsRaw.status === "fulfilled" ? (analyticsRaw.value as any) : {};
345360

346361
const cb = (health.circuitBreakers || []).find((b: any) => b.provider === args.provider);
347-
const providerQuota = Array.isArray(quota?.providers)
348-
? quota.providers.find((p: any) => p.provider === args.provider)
349-
: null;
362+
const providerQuota = quota.providers.find((p) => p.provider === args.provider) || null;
350363

351364
const result = {
352365
provider: args.provider,
@@ -385,7 +398,7 @@ export async function handleBestComboForTask(args: {
385398
const start = Date.now();
386399
try {
387400
const fitness = TASK_FITNESS[args.taskType] || TASK_FITNESS.coding;
388-
const combos = (await apiFetch("/api/combos")) as any[];
401+
const combos = normalizeCombosResponse(await apiFetch("/api/combos"));
389402
const enabledCombos = combos.filter((c: any) => c.enabled !== false);
390403

391404
if (enabledCombos.length === 0) {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts vscode-extension/src/__tests__/*.test.ts",
6363
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
6464
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
65-
"test:all": "npm run test:unit && npm run test:vitest && npm run test:e2e",
65+
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
6666
"check": "npm run lint && npm run test",
6767
"prepublishOnly": "npm run build:cli",
6868
"postinstall": "node scripts/postinstall.mjs",

src/lib/a2a/skills/quotaManagement.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { A2ATask, TaskArtifact } from "../taskManager";
9+
import { normalizeQuotaResponse } from "@/shared/contracts/quota";
910

1011
const OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
1112
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
@@ -34,20 +35,42 @@ export async function executeQuotaManagement(task: A2ATask): Promise<QuotaManage
3435
quotaFetch("/api/combos"),
3536
]);
3637

37-
const quota = quotaRaw.status === "fulfilled" ? (quotaRaw.value as any) : {};
38-
const combos = combosRaw.status === "fulfilled" ? (combosRaw.value as any[]) : [];
39-
const providers: any[] = quota?.providers || (Array.isArray(quota) ? quota : []);
38+
const quota =
39+
quotaRaw.status === "fulfilled"
40+
? normalizeQuotaResponse(quotaRaw.value)
41+
: normalizeQuotaResponse({});
42+
const combos =
43+
combosRaw.status === "fulfilled"
44+
? Array.isArray((combosRaw.value as any)?.combos)
45+
? (combosRaw.value as any).combos
46+
: Array.isArray(combosRaw.value)
47+
? (combosRaw.value as any[])
48+
: []
49+
: [];
50+
const providers = quota.providers;
51+
52+
const availableQuota = (provider: { quotaTotal: number | null; quotaUsed: number }) => {
53+
if (provider.quotaTotal === null) return Number.POSITIVE_INFINITY;
54+
return provider.quotaTotal - provider.quotaUsed;
55+
};
4056

4157
// Query classification
4258
if (query.includes("ranking") || query.includes("most quota") || query.includes("best")) {
43-
const sorted = [...providers].sort(
44-
(a, b) => b.quotaTotal - b.quotaUsed - (a.quotaTotal - a.quotaUsed)
45-
);
59+
const sorted = [...providers].sort((a, b) => availableQuota(b) - availableQuota(a));
4660
return {
4761
artifacts: [
4862
{
4963
type: "text",
50-
content: `**Quota Ranking (most available first):**\n${sorted.map((p, i) => `${i + 1}. **${p.provider}** — ${(p.quotaTotal - p.quotaUsed).toLocaleString()} remaining (${Math.round(((p.quotaTotal - p.quotaUsed) / (p.quotaTotal || 1)) * 100)}%)`).join("\n")}`,
64+
content: `**Quota Ranking (most available first):**\n${sorted
65+
.map((p, i) => {
66+
const remaining = availableQuota(p);
67+
const remainingLabel =
68+
remaining === Number.POSITIVE_INFINITY ? "unlimited" : remaining.toLocaleString();
69+
const percentLabel =
70+
p.quotaTotal === null ? "n/a" : `${Math.round(p.percentRemaining)}%`;
71+
return `${i + 1}. **${p.provider}** — ${remainingLabel} remaining (${percentLabel})`;
72+
})
73+
.join("\n")}`,
5174
},
5275
],
5376
metadata: { queryType: "ranking", providers: sorted.map((p) => p.provider) },
@@ -76,13 +99,20 @@ export async function executeQuotaManagement(task: A2ATask): Promise<QuotaManage
7699
// Default: general quota summary
77100
const totalUsed = providers.reduce((sum, p) => sum + (p.quotaUsed || 0), 0);
78101
const totalAvailable = providers.reduce((sum, p) => sum + (p.quotaTotal || 0), 0);
79-
const warnings = providers.filter((p) => p.quotaTotal && p.quotaUsed / p.quotaTotal > 0.9);
102+
const warnings = providers.filter((p) => p.percentRemaining <= 10);
80103

81104
return {
82105
artifacts: [
83106
{
84107
type: "text",
85-
content: `**Quota Summary (${providers.length} providers):**\n- Total used: ${totalUsed.toLocaleString()} / ${totalAvailable.toLocaleString()}\n${providers.map((p) => `- **${p.provider}:** ${p.quotaUsed?.toLocaleString() || 0} / ${p.quotaTotal?.toLocaleString() || "∞"} (${p.tokenStatus || "ok"})`).join("\n")}${warnings.length > 0 ? `\n\n⚠️ **Warning:** ${warnings.map((w) => w.provider).join(", ")} above 90% usage` : ""}`,
108+
content: `**Quota Summary (${providers.length} providers):**\n- Total used: ${totalUsed.toLocaleString()} / ${totalAvailable.toLocaleString()}\n${providers
109+
.map(
110+
(p) =>
111+
`- **${p.provider}:** ${p.quotaUsed.toLocaleString()} / ${p.quotaTotal?.toLocaleString() || "∞"} (${p.tokenStatus})`
112+
)
113+
.join(
114+
"\n"
115+
)}${warnings.length > 0 ? `\n\n⚠️ **Warning:** ${warnings.map((w) => w.provider).join(", ")} at or below 10% remaining` : ""}`,
86116
},
87117
],
88118
metadata: { queryType: "summary", providerCount: providers.length, warnings: warnings.length },

src/shared/contracts/quota.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
export type QuotaTokenStatus = "valid" | "expiring" | "expired" | "refreshing";
2+
3+
export interface QuotaProviderEntry {
4+
name: string;
5+
provider: string;
6+
connectionId: string;
7+
quotaUsed: number;
8+
quotaTotal: number | null;
9+
percentRemaining: number;
10+
resetAt: string | null;
11+
tokenStatus: QuotaTokenStatus;
12+
}
13+
14+
export interface QuotaResponseMeta {
15+
generatedAt: string;
16+
filters: {
17+
provider: string | null;
18+
connectionId: string | null;
19+
};
20+
totalProviders: number;
21+
}
22+
23+
export interface QuotaResponse {
24+
providers: QuotaProviderEntry[];
25+
meta: QuotaResponseMeta;
26+
}
27+
28+
const TOKEN_STATUS_VALUES: QuotaTokenStatus[] = ["valid", "expiring", "expired", "refreshing"];
29+
30+
function toNumber(value: unknown): number | null {
31+
if (typeof value === "number" && Number.isFinite(value)) return value;
32+
if (typeof value === "string" && value.trim() !== "") {
33+
const parsed = Number(value);
34+
if (Number.isFinite(parsed)) return parsed;
35+
}
36+
return null;
37+
}
38+
39+
function clamp(value: number, min: number, max: number): number {
40+
return Math.min(max, Math.max(min, value));
41+
}
42+
43+
function normalizeTokenStatus(value: unknown): QuotaTokenStatus {
44+
if (typeof value === "string" && TOKEN_STATUS_VALUES.includes(value as QuotaTokenStatus)) {
45+
return value as QuotaTokenStatus;
46+
}
47+
return "valid";
48+
}
49+
50+
export function sanitizeQuotaProvider(input: unknown): QuotaProviderEntry {
51+
const source = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
52+
const provider = typeof source.provider === "string" ? source.provider : "unknown";
53+
const name = typeof source.name === "string" && source.name.trim() ? source.name : provider;
54+
const connectionId =
55+
typeof source.connectionId === "string" && source.connectionId.trim()
56+
? source.connectionId
57+
: "unknown";
58+
59+
const quotaTotalRaw = toNumber(source.quotaTotal);
60+
const quotaTotal = quotaTotalRaw !== null && quotaTotalRaw >= 0 ? quotaTotalRaw : null;
61+
62+
const quotaUsedRaw = toNumber(source.quotaUsed) ?? 0;
63+
const quotaUsed =
64+
quotaTotal !== null ? clamp(quotaUsedRaw, 0, quotaTotal) : Math.max(0, quotaUsedRaw);
65+
66+
let percentRemainingRaw = toNumber(source.percentRemaining);
67+
if (percentRemainingRaw === null) {
68+
if (quotaTotal && quotaTotal > 0) {
69+
percentRemainingRaw = ((quotaTotal - quotaUsed) / quotaTotal) * 100;
70+
} else {
71+
percentRemainingRaw = 100;
72+
}
73+
}
74+
const percentRemaining = clamp(percentRemainingRaw, 0, 100);
75+
76+
const resetAt =
77+
typeof source.resetAt === "string" && source.resetAt.trim() ? source.resetAt : null;
78+
79+
return {
80+
name,
81+
provider,
82+
connectionId,
83+
quotaUsed,
84+
quotaTotal,
85+
percentRemaining,
86+
resetAt,
87+
tokenStatus: normalizeTokenStatus(source.tokenStatus),
88+
};
89+
}
90+
91+
export function normalizeQuotaResponse(
92+
raw: unknown,
93+
filters: { provider?: string | null; connectionId?: string | null } = {}
94+
): QuotaResponse {
95+
const source = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
96+
const providersRaw = Array.isArray(source.providers)
97+
? source.providers
98+
: Array.isArray(raw)
99+
? raw
100+
: [];
101+
102+
const providers = providersRaw.map((entry) => sanitizeQuotaProvider(entry));
103+
104+
const sourceMeta =
105+
source.meta && typeof source.meta === "object" ? (source.meta as Record<string, unknown>) : {};
106+
const sourceFilters =
107+
sourceMeta.filters && typeof sourceMeta.filters === "object"
108+
? (sourceMeta.filters as Record<string, unknown>)
109+
: {};
110+
111+
const providerFilter =
112+
filters.provider ??
113+
(typeof sourceFilters.provider === "string" && sourceFilters.provider.trim()
114+
? sourceFilters.provider
115+
: null);
116+
const connectionFilter =
117+
filters.connectionId ??
118+
(typeof sourceFilters.connectionId === "string" && sourceFilters.connectionId.trim()
119+
? sourceFilters.connectionId
120+
: null);
121+
122+
const generatedAt =
123+
typeof sourceMeta.generatedAt === "string" && sourceMeta.generatedAt.trim()
124+
? sourceMeta.generatedAt
125+
: new Date().toISOString();
126+
127+
return {
128+
providers,
129+
meta: {
130+
generatedAt,
131+
filters: {
132+
provider: providerFilter || null,
133+
connectionId: connectionFilter || null,
134+
},
135+
totalProviders: providers.length,
136+
},
137+
};
138+
}

0 commit comments

Comments
 (0)