Skip to content

Commit adc8fdf

Browse files
committed
feat: add A2A protocol support and refactor API validation layer
Implement Agent-to-Agent (A2A) JSON-RPC 2.0 endpoint with smart routing and quota management skills, SSE streaming, and task lifecycle management. - Add /a2a route with message/send, message/stream, tasks/get, tasks/cancel - Add /.well-known/agent.json agent card endpoint - Introduce Zod-based request validation schemas for all v1 API routes - Extract shared getUnifiedModelsResponse to reduce duplication across /models, /v1/models, and /a2a model listing - Refactor chat, embeddings, moderations, and models routes to use centralized validation and error handling - Add A2A task manager, routing logger, and streaming utilities
1 parent cc429d4 commit adc8fdf

File tree

79 files changed

+2700
-988
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+2700
-988
lines changed

src/app/a2a/route.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* A2A JSON-RPC 2.0 Router — `/a2a` endpoint
3+
*
4+
* Methods:
5+
* - message/send — Synchronous task execution
6+
* - message/stream — SSE streaming execution
7+
* - tasks/get — Query task by ID
8+
* - tasks/cancel — Cancel task by ID
9+
*
10+
* Auth: Bearer token via Authorization header
11+
*/
12+
13+
import { NextRequest, NextResponse } from "next/server";
14+
import { getTaskManager } from "@/lib/a2a/taskManager";
15+
import { executeSmartRouting } from "@/lib/a2a/skills/smartRouting";
16+
import { executeQuotaManagement } from "@/lib/a2a/skills/quotaManagement";
17+
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
18+
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
19+
20+
// ============ Skill Registry ============
21+
22+
const SKILL_HANDLERS: Record<string, (task: any) => Promise<any>> = {
23+
"smart-routing": executeSmartRouting,
24+
"quota-management": executeQuotaManagement,
25+
};
26+
27+
// ============ Auth ============
28+
29+
function authenticate(req: NextRequest): boolean {
30+
// If no API key is configured, allow all requests
31+
const configuredKey = process.env.OMNIROUTE_API_KEY;
32+
if (!configuredKey) return true;
33+
34+
const authHeader = req.headers.get("authorization") || "";
35+
const token = authHeader.replace(/^Bearer\s+/i, "");
36+
return token === configuredKey;
37+
}
38+
39+
// ============ JSON-RPC Helpers ============
40+
41+
function jsonRpcError(id: string | number | null, code: number, message: string, data?: unknown) {
42+
return NextResponse.json(
43+
{ jsonrpc: "2.0", id, error: { code, message, data } },
44+
{ status: code === -32600 ? 400 : code === -32601 ? 404 : code === -32603 ? 500 : 200 }
45+
);
46+
}
47+
48+
function jsonRpcResult(id: string | number | null, result: unknown) {
49+
return NextResponse.json({ jsonrpc: "2.0", id, result });
50+
}
51+
52+
// ============ Route Handler ============
53+
54+
export async function POST(req: NextRequest) {
55+
// Auth check
56+
if (!authenticate(req)) {
57+
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
58+
}
59+
60+
// Parse JSON-RPC body
61+
let body: any;
62+
try {
63+
body = await req.json();
64+
} catch {
65+
return jsonRpcError(null, -32700, "Parse error: invalid JSON");
66+
}
67+
68+
const { jsonrpc, id, method, params } = body;
69+
if (jsonrpc !== "2.0" || !method) {
70+
return jsonRpcError(id || null, -32600, "Invalid request: missing jsonrpc or method");
71+
}
72+
73+
const tm = getTaskManager();
74+
75+
switch (method) {
76+
// ── message/send ──────────────────────────────────────
77+
case "message/send": {
78+
const skill = params?.skill || "smart-routing";
79+
const messages = params?.messages || params?.message?.parts;
80+
if (!messages || !Array.isArray(messages)) {
81+
return jsonRpcError(id, -32602, "Invalid params: messages array required");
82+
}
83+
84+
const handler = SKILL_HANDLERS[skill];
85+
if (!handler) {
86+
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
87+
}
88+
89+
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
90+
try {
91+
tm.updateTask(task.id, "working");
92+
const result = await handler(task);
93+
tm.updateTask(task.id, "completed", result.artifacts);
94+
95+
// Log routing decision
96+
if (skill === "smart-routing" && result.metadata) {
97+
logRoutingDecision({
98+
taskType: (params?.metadata?.role as string) || "general",
99+
comboId: (params?.metadata?.combo as string) || "default",
100+
providerSelected:
101+
result.metadata?.routing_explanation?.match(/"([^"]+)"/)?.[1] || "unknown",
102+
modelUsed: (params?.metadata?.model as string) || "auto",
103+
score: 1,
104+
factors: [],
105+
fallbacksTriggered: [],
106+
success: true,
107+
latencyMs: 0,
108+
cost: result.metadata?.cost_envelope?.actual || 0,
109+
});
110+
}
111+
112+
return jsonRpcResult(id, {
113+
task: { id: task.id, state: "completed" },
114+
artifacts: result.artifacts,
115+
metadata: result.metadata,
116+
});
117+
} catch (err) {
118+
const msg = err instanceof Error ? err.message : String(err);
119+
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
120+
return jsonRpcError(id, -32603, `Skill execution failed: ${msg}`);
121+
}
122+
}
123+
124+
// ── message/stream ────────────────────────────────────
125+
case "message/stream": {
126+
const skill = params?.skill || "smart-routing";
127+
const messages = params?.messages || params?.message?.parts;
128+
if (!messages || !Array.isArray(messages)) {
129+
return jsonRpcError(id, -32602, "Invalid params: messages array required");
130+
}
131+
132+
const handler = SKILL_HANDLERS[skill];
133+
if (!handler) {
134+
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
135+
}
136+
137+
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
138+
tm.updateTask(task.id, "working");
139+
140+
const stream = createA2AStream(
141+
task,
142+
async (t) => {
143+
const result = await handler(t);
144+
tm.updateTask(t.id, "completed", result.artifacts);
145+
return result;
146+
},
147+
req.signal
148+
);
149+
150+
return new Response(stream, { headers: SSE_HEADERS });
151+
}
152+
153+
// ── tasks/get ─────────────────────────────────────────
154+
case "tasks/get": {
155+
const taskId = params?.taskId || params?.id;
156+
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
157+
158+
const task = tm.getTask(taskId);
159+
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
160+
161+
return jsonRpcResult(id, { task });
162+
}
163+
164+
// ── tasks/cancel ──────────────────────────────────────
165+
case "tasks/cancel": {
166+
const taskId = params?.taskId || params?.id;
167+
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
168+
169+
try {
170+
const task = tm.cancelTask(taskId);
171+
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
172+
} catch (err) {
173+
const msg = err instanceof Error ? err.message : String(err);
174+
return jsonRpcError(id, -32603, msg);
175+
}
176+
}
177+
178+
default:
179+
return jsonRpcError(id, -32601, `Method not found: ${method}`);
180+
}
181+
}
182+
183+
// Agent Card discovery via OPTIONS
184+
export async function OPTIONS() {
185+
return new NextResponse(null, {
186+
status: 204,
187+
headers: {
188+
Allow: "POST, OPTIONS",
189+
"Access-Control-Allow-Methods": "POST, OPTIONS",
190+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
191+
},
192+
});
193+
}

src/app/api/auth/login/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getSettings } from "@/lib/localDb";
33
import bcrypt from "bcryptjs";
44
import { SignJWT } from "jose";
55
import { cookies } from "next/headers";
6-
import { loginSchema, validateBody } from "@/shared/validation/schemas";
6+
import { isValidationFailure, loginSchema, validateBody } from "@/shared/validation/schemas";
77

88
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
99
if (!process.env.JWT_SECRET) {
@@ -25,7 +25,7 @@ export async function POST(request) {
2525

2626
// Zod validation
2727
const validation = validateBody(loginSchema, rawBody);
28-
if (!validation.success) {
28+
if (isValidationFailure(validation)) {
2929
return NextResponse.json({ error: validation.error }, { status: 400 });
3030
}
3131
const { password } = validation.data;
@@ -73,6 +73,7 @@ export async function POST(request) {
7373

7474
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
7575
} catch (error) {
76-
return NextResponse.json({ error: error.message }, { status: 500 });
76+
console.error("[AUTH] Login failed:", error);
77+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
7778
}
7879
}

src/app/api/cli-tools/antigravity-mitm/alias/route.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
import { NextResponse } from "next/server";
44
import { getMitmAlias, setMitmAliasAll } from "@/models";
5+
import {
6+
cliMitmAliasUpdateSchema,
7+
isValidationFailure,
8+
validateBody,
9+
} from "@/shared/validation/schemas";
510

611
// GET - Get MITM aliases for a tool
712
export async function GET(request) {
@@ -18,12 +23,27 @@ export async function GET(request) {
1823

1924
// PUT - Save MITM aliases for a specific tool
2025
export async function PUT(request) {
26+
let rawBody;
2127
try {
22-
const { tool, mappings } = await request.json();
28+
rawBody = await request.json();
29+
} catch {
30+
return NextResponse.json(
31+
{
32+
error: {
33+
message: "Invalid request",
34+
details: [{ field: "body", message: "Invalid JSON body" }],
35+
},
36+
},
37+
{ status: 400 }
38+
);
39+
}
2340

24-
if (!tool || !mappings || typeof mappings !== "object") {
25-
return NextResponse.json({ error: "tool and mappings required" }, { status: 400 });
41+
try {
42+
const validation = validateBody(cliMitmAliasUpdateSchema, rawBody);
43+
if (isValidationFailure(validation)) {
44+
return NextResponse.json({ error: validation.error }, { status: 400 });
2645
}
46+
const { tool, mappings } = validation.data;
2747

2848
const filtered: Record<string, string> = {};
2949
for (const [alias, model] of Object.entries(mappings)) {

src/app/api/cli-tools/antigravity-mitm/route.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import {
88
getCachedPassword,
99
setCachedPassword,
1010
} from "@/mitm/manager";
11+
import {
12+
cliMitmStartSchema,
13+
cliMitmStopSchema,
14+
isValidationFailure,
15+
validateBody,
16+
} from "@/shared/validation/schemas";
1117

1218
// GET - Check MITM status
1319
export async function GET() {
@@ -28,8 +34,27 @@ export async function GET() {
2834

2935
// POST - Start MITM proxy
3036
export async function POST(request) {
37+
let rawBody;
3138
try {
32-
const { apiKey, sudoPassword } = await request.json();
39+
rawBody = await request.json();
40+
} catch {
41+
return NextResponse.json(
42+
{
43+
error: {
44+
message: "Invalid request",
45+
details: [{ field: "body", message: "Invalid JSON body" }],
46+
},
47+
},
48+
{ status: 400 }
49+
);
50+
}
51+
52+
try {
53+
const validation = validateBody(cliMitmStartSchema, rawBody);
54+
if (isValidationFailure(validation)) {
55+
return NextResponse.json({ error: validation.error }, { status: 400 });
56+
}
57+
const { apiKey, sudoPassword } = validation.data;
3358
const isWin = process.platform === "win32";
3459
const pwd = sudoPassword || getCachedPassword() || "";
3560

@@ -59,8 +84,27 @@ export async function POST(request) {
5984

6085
// DELETE - Stop MITM proxy
6186
export async function DELETE(request) {
87+
let rawBody;
6288
try {
63-
const { sudoPassword } = await request.json();
89+
rawBody = await request.json();
90+
} catch {
91+
return NextResponse.json(
92+
{
93+
error: {
94+
message: "Invalid request",
95+
details: [{ field: "body", message: "Invalid JSON body" }],
96+
},
97+
},
98+
{ status: 400 }
99+
);
100+
}
101+
102+
try {
103+
const validation = validateBody(cliMitmStopSchema, rawBody);
104+
if (isValidationFailure(validation)) {
105+
return NextResponse.json({ error: validation.error }, { status: 400 });
106+
}
107+
const { sudoPassword } = validation.data;
64108
const isWin = process.platform === "win32";
65109
const pwd = sudoPassword || getCachedPassword() || "";
66110

0 commit comments

Comments
 (0)