Skip to content
Open
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
46 changes: 46 additions & 0 deletions apps/api/src/lib/pricing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildCapabilityMatrix,
getProviderById,
getProvidersByCategory,
protectedRouteBasePrices,
Expand Down Expand Up @@ -104,3 +105,48 @@ describe("provider catalog baseline", () => {
});
}
});

describe("capability matrix", () => {
it("returns all providers with correct shape", () => {
const matrix = buildCapabilityMatrix();
expect(matrix.length).toBe(providers.length);

for (const entry of matrix) {
expect(entry.caveat === null || typeof entry.caveat === "string").toBe(true);
expect(entry).toMatchObject({
id: expect.any(String),
name: expect.any(String),
category: expect.stringMatching(/^(search|news|scrape)$/),
priceUsd: expect.any(Number),
sourceType: expect.stringMatching(/^(live|deterministic-fallback|unavailable)$/),
latencyEstimateMs: expect.any(Number),
enabled: expect.any(Boolean),
hasFallback: true
});
expect(entry.priceUsd).toBeGreaterThan(0);
expect(entry.latencyEstimateMs).toBeGreaterThan(0);
}
});

it("sorts deterministically by category then id", () => {
const matrix = buildCapabilityMatrix();
for (let i = 1; i < matrix.length; i++) {
const prev = matrix[i - 1];
const curr = matrix[i];
const catCmp = prev.category.localeCompare(curr.category);
if (catCmp === 0) {
expect(prev.id.localeCompare(curr.id)).toBeLessThanOrEqual(0);
} else {
expect(catCmp).toBeLessThan(0);
}
}
});

it("reports caveat when GROQ_API_KEY is missing", () => {
const matrix = buildCapabilityMatrix();
const allHaveCaveat = matrix.every(
(entry) => entry.caveat !== null && entry.caveat.includes("GROQ_API_KEY")
);
expect(allHaveCaveat).toBe(true);
});
});
39 changes: 38 additions & 1 deletion apps/api/src/lib/pricing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
import type { ProviderDefinition } from "@query402/shared";
import type { ProviderCapability, ProviderDefinition } from "@query402/shared";

const envKeyMapping: Record<string, string[]> = {
"search.live": ["GROQ_API_KEY"],
"search.basic": ["GROQ_API_KEY"],
"search.pro": ["GROQ_API_KEY"],
"news.fast": ["GROQ_API_KEY"],
"news.deep": ["GROQ_API_KEY"],
"scrape.page": ["GROQ_API_KEY"],
"scrape.extract": ["GROQ_API_KEY"]
};

function computeCaveat(providerId: string): string | null {
const required = envKeyMapping[providerId];
if (!required) return null;
const missing = required.filter((key) => !process.env[key]);
if (missing.length === 0) return null;
return `${missing.join(", ")} not configured — falling back to deterministic results`;
}

export function buildCapabilityMatrix(): ProviderCapability[] {
return providers
.map((p) => ({
id: p.id,
name: p.name,
category: p.category,
priceUsd: p.priceUsd,
sourceType: p.sourceType,
latencyEstimateMs: p.latencyEstimateMs,
enabled: p.enabled,
hasFallback: true,
caveat: computeCaveat(p.id)
}))
.sort((a, b) => {
const cat = a.category.localeCompare(b.category);
return cat !== 0 ? cat : a.id.localeCompare(b.id);
});
}

export const providers: ProviderDefinition[] = [
{
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/routes/public.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
import { providerCapabilitySchema } from "@query402/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildTestUsageEvent } from "../test/storage-test-helpers.js";
import { applyApiTestEnv, resetApiTestStorage } from "../test/api-test-helpers.js";
Expand Down Expand Up @@ -259,4 +260,32 @@ describe("public routes", () => {
expect(analyticsResponse.body.totalSpendUsd).toBe(0.01);
expect(analyticsResponse.body.spendByCategory.search).toBe(0.01);
});

it("returns capability matrix with correct shape and deterministic order", async () => {
const app = await createPublicApp();
const response = await request(app).get("/api/matrix");

expect(response.status).toBe(200);
expect(response.body.updatedAt).toEqual(expect.any(String));

const { providers: matrix } = response.body;
expect(Array.isArray(matrix)).toBe(true);
expect(matrix.length).toBeGreaterThan(0);

for (const entry of matrix) {
const parsed = providerCapabilitySchema.safeParse(entry);
expect(parsed.success).toBe(true);
}

for (let i = 1; i < matrix.length; i++) {
const prev = matrix[i - 1];
const curr = matrix[i];
const catCmp = prev.category.localeCompare(curr.category);
if (catCmp === 0) {
expect(prev.id.localeCompare(curr.id)).toBeLessThanOrEqual(0);
} else {
expect(catCmp).toBeLessThan(0);
}
}
});
});
10 changes: 9 additions & 1 deletion apps/api/src/routes/public.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from "express";
import { z } from "zod";
import { providers } from "../lib/pricing.js";
import { buildCapabilityMatrix, providers } from "../lib/pricing.js";
import { getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js";
import { config, getConfigSnapshot } from "../lib/config.js";
import { apiVersion } from "../lib/build-metadata.js";
Expand All @@ -27,6 +27,7 @@ publicRouter.get("/health", (_req, res) => {
nodeEnv: config.NODE_ENV,
network: config.STELLAR_NETWORK,
sponsorshipEnabled: config.sponsorshipEnabled,
demoMode: config.demoMode,
timestamp: new Date().toISOString(),
uptimeSeconds: process.uptime(),
diagnostics: getConfigSnapshot()
Expand All @@ -41,6 +42,13 @@ publicRouter.get("/api/catalog", (_req, res) => {
res.json(getCatalog());
});

publicRouter.get("/api/matrix", (_req, res) => {
res.json({
updatedAt: new Date().toISOString(),
providers: buildCapabilityMatrix()
});
});

publicRouter.get("/api/usage", async (req, res, next) => {
try {
const parsed = usageQuerySchema.safeParse(req.query);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { HealthResponse } from "../types.js";

export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3001";

export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
Expand All @@ -22,6 +24,10 @@ export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T>
return (await response.json()) as T;
}

export async function fetchHealth(apiBaseUrl: string): Promise<HealthResponse> {
return fetchJson<HealthResponse>(`${apiBaseUrl}/health`);
}

export function money(value: number) {
return `$${value.toFixed(3)}`;
}
110 changes: 105 additions & 5 deletions apps/web/src/pages/ControlDeckPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import {
ShieldCheck,
Sparkles,
TerminalSquare,
XCircle
XCircle,
Check,
AlertTriangle,
Clock
} from "lucide-react";
import { Link } from "react-router-dom";
import type { AnalyticsResponse, PaidQueryResponse } from "../types.js";
import { API_BASE_URL, fetchJson, money } from "../lib/api.js";
import type { AnalyticsResponse, EvidenceCheckItem, PaidQueryResponse } from "../types.js";
import { API_BASE_URL, fetchHealth, fetchJson, money } from "../lib/api.js";
import {
fetchSponsorshipEnabled,
fetchSponsorshipPreview,
Expand Down Expand Up @@ -72,6 +75,7 @@ export default function ControlDeckPage() {
const [preview, setPreview] = useState<SponsorshipPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [demoMode, setDemoMode] = useState(false);

const modeProviders = useMemo(
() => providers.filter((provider) => provider.category === mode && provider.enabled),
Expand All @@ -91,6 +95,68 @@ export default function ControlDeckPage() {
? toTokenBaseUnits(selectedProviderDetails.priceUsd)
: "0";

const evidenceItems: EvidenceCheckItem[] = useMemo(() => {
const resultOk = result !== null;
const resultHasItems = (result?.result?.items?.length ?? 0) > 0;
const paymentCaptured = result?.payment?.paymentResponseHeader != null;
const hasUsage = (analytics?.totalQueries ?? 0) > 0;
const hasSpend = (analytics?.totalSpendUsd ?? 0) > 0;
const hasReceipts = (analytics?.recentTransactions?.length ?? 0) > 0;

return [
{
id: "catalog",
label: "Provider catalog loaded",
status: providers.length > 0 ? "pass" : "pending",
detail: providers.length > 0 ? `${providers.length} providers` : undefined
},
{
id: "query-exec",
label: "Paid/demo query executed",
status: resultOk ? "pass" : "pending",
detail: resultOk ? result!.result.providerName : undefined
},
{
id: "result",
label: "Result returned",
status: resultOk ? (resultHasItems ? "pass" : "warn") : "pending",
detail: resultOk
? `${result!.result.items.length} items, ${result!.result.latencyMs}ms`
: undefined
},
{
id: "payment",
label: "Payment evidence captured",
status: paymentCaptured ? "pass" : "pending",
detail: paymentCaptured
? demoMode
? "demo tx (DEMO_MODE)"
: result!.payment.paymentResponseHeader!.slice(0, 16) + "..."
: undefined
},
{
id: "usage",
label: "Usage event persisted",
status: hasUsage ? "pass" : "pending",
detail: hasUsage ? `${analytics!.totalQueries} total` : undefined
},
{
id: "analytics",
label: "Analytics updated",
status: hasSpend ? "pass" : "pending",
detail: hasSpend ? money(analytics!.totalSpendUsd) + " tracked" : undefined
},
{
id: "receipt",
label: "Receipt/export available",
status: hasReceipts ? "pass" : "pending",
detail: hasReceipts
? `${analytics!.recentTransactions.length} transaction(s)`
: undefined
}
];
}, [providers, result, analytics, demoMode]);

function shortAddress(address: string) {
if (address.length < 12) {
return address;
Expand Down Expand Up @@ -127,13 +193,15 @@ export default function ControlDeckPage() {

useEffect(() => {
async function bootstrap() {
const [providersResponse, sponsorshipActive] = await Promise.all([
const [providersResponse, sponsorshipActive, health] = await Promise.all([
fetchJson<{ providers: ProviderDefinition[] }>(`${API_BASE_URL}/api/providers`),
fetchSponsorshipEnabled(API_BASE_URL)
fetchSponsorshipEnabled(API_BASE_URL),
fetchHealth(API_BASE_URL)
]);
setProviders(providersResponse.providers);
setSelectedProvider(modeDefaultProvider.search);
setSponsorshipEnabled(sponsorshipActive);
setDemoMode(health.demoMode ?? false);
await refreshMetrics();
}

Expand Down Expand Up @@ -710,6 +778,19 @@ export default function ControlDeckPage() {
)}
</pre>
</div>

<div className="evidence-panel">
<h3>
<ShieldCheck size={14} />
SCF Evidence Checklist
{demoMode ? <span>DEMO</span> : null}
</h3>
<ul className="evidence-list">
{evidenceItems.map((item) => (
<EvidenceRow key={item.id} item={item} />
))}
</ul>
</div>
</aside>
</main>
</div>
Expand Down Expand Up @@ -965,3 +1046,22 @@ function denyActionableCopy(decision: string) {
return "Policy will deny this request. See the reason above and adjust inputs.";
}
}

const evidenceIconMap: Record<string, ReactNode> = {
pass: <Check size={10} />,
warn: <AlertTriangle size={10} />,
pending: <Clock size={10} />
};

function EvidenceRow(props: { item: EvidenceCheckItem }) {
const { item } = props;
return (
<li className="evidence-item">
<span className={`evidence-icon ${item.status}`}>
{evidenceIconMap[item.status]}
</span>
<span className="evidence-label">{item.label}</span>
{item.detail ? <span className="evidence-detail">{item.detail}</span> : null}
</li>
);
}
Loading