diff --git a/src/app/.well-known/agent-card.json/route.ts b/src/app/.well-known/agent-card.json/route.ts new file mode 100644 index 000000000000..031c4081c6d2 --- /dev/null +++ b/src/app/.well-known/agent-card.json/route.ts @@ -0,0 +1,118 @@ +/** + * Agent Card Endpoint — /.well-known/agent-card.json + * + * Serves the OmniRoute A2A Agent Card in the A2A Protocol v1.0 shape for + * discovery by 1.0 clients (a2a-sdk 1.x, Hermes, …). The legacy v0.3 card + * remains available at /.well-known/agent.json. + * + * `supportedInterfaces` declares both protocol versions against the same + * JSON-RPC endpoint — the v1.0 route handler accepts both `SendMessage` and + * the legacy `message/send` (see src/app/a2a/route.ts). + */ + +import { NextResponse } from "next/server"; + +import { getFleetSkills } from "@/lib/conductor/fleetSkills"; + +const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1"; +const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; + +/** + * GET /.well-known/agent-card.json + * + * Returns the OmniRoute Agent Card (A2A v1.0). + */ +export async function GET() { + const fleetSkills = await getFleetSkills(); + + const agentCard = { + name: "OmniRoute AI Gateway", + description: + "Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " + + "format translation, and auto-managed combos. Routes AI requests to the optimal " + + "provider based on cost, latency, quota availability, and task requirements.", + url: `${BASE_URL}/a2a`, + version: PACKAGE_VERSION, + supportedInterfaces: [ + { + url: `${BASE_URL}/a2a`, + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + }, + { + url: `${BASE_URL}/a2a`, + protocolBinding: "JSONRPC", + protocolVersion: "0.3", + }, + ], + defaultInputModes: ["text/plain"], + defaultOutputModes: ["text/plain"], + capabilities: { + streaming: true, + pushNotifications: false, + }, + skills: [ + { + id: "smart-routing", + name: "Smart Request Routing", + description: + "Routes AI requests to the optimal provider based on quota, cost, latency, and reliability.", + tags: ["routing", "llm", "optimization", "fallback"], + examples: [ + "Route this coding task to the fastest available model", + "Send this review to an analytical model under a $0.50 budget", + ], + }, + { + id: "quota-management", + name: "Quota & Cost Management", + description: + "Tracks and manages API quotas across providers with auto-fallback when quotas are exhausted.", + tags: ["quota", "cost", "monitoring", "budget"], + examples: ["Check remaining quota for all providers", "Generate a cost report for today"], + }, + { + id: "provider-discovery", + name: "Provider Discovery", + description: + "Discovers providers that can handle a requested capability (chat, images, audio, search, embeddings, rerank, video).", + tags: ["providers", "discovery", "capabilities", "health"], + examples: ["Which providers can handle image generation?", "Find healthy providers for embeddings"], + }, + { + id: "cost-analysis", + name: "Cost Analysis", + description: "Analyzes usage costs by provider and model and returns cost-saving opportunities.", + tags: ["cost", "usage", "analytics", "optimization"], + examples: ["How much did we spend this week?", "Which provider costs the most?"], + }, + { + id: "health-report", + name: "Health Report", + description: + "Summarizes provider health, circuit-breaker state, rate-limit queues, and telemetry into a structured report.", + tags: ["health", "monitoring", "resilience", "telemetry"], + examples: ["Is everything healthy?", "Report degraded providers and retry timing"], + }, + { + id: "list-capabilities", + name: "List Capabilities", + description: "Returns the full catalog of OmniRoute agent skills.", + tags: ["discovery", "capabilities"], + examples: ["What can you do?", "List your skills"], + }, + ...fleetSkills, + ], + security: { + schemes: ["api-key"], + apiKeyHeader: "Authorization", + }, + }; + + return NextResponse.json(agentCard, { + headers: { + "Cache-Control": "public, max-age=3600", + "Content-Type": "application/json", + }, + }); +} diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts index 7a5d4ec37b53..e9e90a99abbc 100644 --- a/src/app/a2a/route.ts +++ b/src/app/a2a/route.ts @@ -18,6 +18,63 @@ import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming"; import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution"; import { getSettings } from "@/lib/db/settings"; +// ============ A2A v1.0 ↔ v0.3 compatibility layer ============ +// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage, +// message/stream → SendStreamingMessage) and changed the synchronous +// response shape: a 1.0 client reads the reply from +// `task.status.message.parts[].text` (and `task.artifacts`), whereas OmniRoute's +// v0.3 server returns top-level `artifacts`/`metadata`. This layer aliases the +// 1.0 method names and reshapes the synchronous response so 1.0 clients +// (a2a-sdk 1.x, Hermes, …) can call the endpoint unchanged. v0.3 clients are +// unaffected. + +const V1_METHOD_ALIASES: Record = { + SendMessage: "message/send", + SendStreamingMessage: "message/stream", +}; + +/** Map a v0.3 task state to the v1.0 TASK_STATE_* enum string. */ +function toV1State(state: string): string { + const s = state.toUpperCase(); + return s.startsWith("TASK_STATE_") ? s : `TASK_STATE_${s}`; +} + +/** + * Rebuild a v1.0 Task from a v0.3 task + skill result. v0.3 carries the reply in + * `artifacts[].content` (type: "text"); v1.0 expects the text inside + * `task.status.message.parts[].text` and `task.artifacts` as Message parts. + */ +function buildV1Task( + task: { id: string; state: string }, + result: { artifacts?: unknown }, + contextId?: unknown +): Record { + const text = Array.isArray(result.artifacts) + ? result.artifacts + .map((a) => + a && typeof a === "object" && typeof (a as { content?: unknown }).content === "string" + ? ((a as { content: string }).content) + : "" + ) + .filter((s) => s.length > 0) + .join("\n") + : ""; + + const v1Task: Record = { + id: task.id, + status: { + state: toV1State(task.state), + message: { + role: "ROLE_AGENT", + parts: [{ text, mediaType: "text/plain" }], + }, + }, + artifacts: [{ role: "ROLE_AGENT", parts: [{ text, mediaType: "text/plain" }] }], + }; + if (typeof contextId === "string" && contextId) v1Task.contextId = contextId; + return v1Task; +} + type A2AMessage = { role: string; content: string }; function toMessageArray(raw: unknown): A2AMessage[] | null { @@ -144,7 +201,11 @@ export async function POST(req: NextRequest) { const tm = getTaskManager(); - switch (method) { + // A2A 1.0 method-name compatibility (SendMessage → message/send, etc.) + const isV1Method = method in V1_METHOD_ALIASES; + const normalizedMethod = V1_METHOD_ALIASES[method] ?? method; + + switch (normalizedMethod) { // ── message/send ────────────────────────────────────── case "message/send": { const skill = params?.skill || "smart-routing"; @@ -189,6 +250,15 @@ export async function POST(req: NextRequest) { }); } + if (isV1Method) { + // A2A 1.0 SendMessageResponse — the reply text lives in + // task.status.message.parts (1.0 clients read it there; the v0.3 + // top-level artifacts/metadata are not part of the 1.0 shape). + return jsonRpcResult(id, { + task: buildV1Task(task, result, params?.message?.contextId), + }); + } + return jsonRpcResult(id, { task: { id: task.id, state: "completed" }, artifacts: result.artifacts, diff --git a/tests/unit/a2a-v1-compat-10839.test.ts b/tests/unit/a2a-v1-compat-10839.test.ts new file mode 100644 index 000000000000..075a3e29368d --- /dev/null +++ b/tests/unit/a2a-v1-compat-10839.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { NextRequest } from "next/server"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-a2a-v1-compat-")); +process.env.DATA_DIR = TEST_DATA_DIR; +delete process.env.OMNIROUTE_API_KEY; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const a2aRoute = await import("../../src/app/a2a/route.ts"); +const agentCardRoute = await import("../../src/app/.well-known/agent-card.json/route.ts"); + +function makeJsonRpcRequest(body: unknown): NextRequest { + return new Request("http://localhost/a2a", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as NextRequest; +} + +test.beforeEach(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await settingsDb.updateSettings({ a2aEnabled: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10839: v1.0 SendMessage is aliased to message/send and reshapes the response", async () => { + const res = await a2aRoute.POST( + makeJsonRpcRequest({ + jsonrpc: "2.0", + id: "1", + method: "SendMessage", + params: { + skill: "provider-discovery", + messages: [{ role: "user", content: "which providers support tools?" }], + }, + }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { + result?: { task?: { id: string; status?: { message?: { parts?: { text?: string }[] } }; artifacts?: unknown } }; + error?: unknown; + }; + assert.equal(body.error, undefined, JSON.stringify(body)); + assert.ok(body.result?.task?.id, "v1.0 response must carry a task id"); + const text = body.result?.task?.status?.message?.parts?.[0]?.text; + assert.equal(typeof text, "string"); + assert.ok((text as string).length > 0, "task.status.message.parts[].text must carry the reply"); + assert.ok(Array.isArray(body.result?.task?.artifacts)); +}); + +test("#10839: v0.3 message/send keeps its existing top-level artifacts/metadata shape", async () => { + const res = await a2aRoute.POST( + makeJsonRpcRequest({ + jsonrpc: "2.0", + id: "2", + method: "message/send", + params: { + skill: "provider-discovery", + messages: [{ role: "user", content: "which providers support tools?" }], + }, + }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { + result?: { task?: { id: string; state?: string }; artifacts?: unknown; metadata?: unknown }; + }; + assert.equal(body.result?.task?.state, "completed"); + assert.ok(Array.isArray(body.result?.artifacts), "v0.3 shape must keep top-level artifacts"); + // v0.3 responses must NOT carry the v1.0 status.message shape + assert.equal((body.result?.task as Record | undefined)?.status, undefined); +}); + +test("#10839: SendStreamingMessage no longer 404s (aliased to message/stream)", async () => { + const res = await a2aRoute.POST( + makeJsonRpcRequest({ + jsonrpc: "2.0", + id: "3", + method: "SendStreamingMessage", + params: { + skill: "provider-discovery", + messages: [{ role: "user", content: "hi" }], + }, + }) + ); + assert.notEqual(res.status, 404); + const contentType = res.headers.get("content-type") || ""; + assert.ok( + contentType.includes("text/event-stream") || res.status === 200, + `expected an SSE stream, got status=${res.status} content-type=${contentType}` + ); +}); + +test("#10839: GET /.well-known/agent-card.json serves a v1.0 card declaring both interfaces", async () => { + const res = await agentCardRoute.GET(); + assert.equal(res.status, 200); + const card = (await res.json()) as { supportedInterfaces?: { protocolVersion?: string }[] }; + assert.ok(Array.isArray(card.supportedInterfaces)); + const versions = (card.supportedInterfaces || []).map((i) => i.protocolVersion); + assert.ok(versions.includes("1.0"), `expected 1.0 in ${JSON.stringify(versions)}`); + assert.ok(versions.includes("0.3"), `expected 0.3 in ${JSON.stringify(versions)}`); +});