Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/app/.well-known/agent-card.json/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
72 changes: 71 additions & 1 deletion src/app/a2a/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, unknown> {
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<string, unknown> = {
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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/a2a-v1-compat-10839.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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)}`);
});