mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
flattenModelsDevCatalog,
|
||||
recommendModelCatalogPreset,
|
||||
searchModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "@/lib/model-catalog";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface CatalogCache {
|
||||
entries: ModelCatalogEntry[];
|
||||
expiresAt: number;
|
||||
inFlight?: Promise<ModelCatalogEntry[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsDevCatalogCache: CatalogCache | undefined;
|
||||
}
|
||||
|
||||
function getCache(): CatalogCache {
|
||||
return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 };
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const response = await fetch(MODELS_DEV_URL, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`);
|
||||
const entries = flattenModelsDevCatalog(await response.json());
|
||||
if (entries.length === 0) throw new Error("models.dev returned an empty catalog");
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const cache = getCache();
|
||||
if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries;
|
||||
if (!cache.inFlight) {
|
||||
cache.inFlight = fetchCatalog().then((entries) => {
|
||||
cache.entries = entries;
|
||||
cache.expiresAt = Date.now() + CATALOG_TTL_MS;
|
||||
return entries;
|
||||
}).finally(() => {
|
||||
cache.inFlight = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await cache.inFlight;
|
||||
} catch (error) {
|
||||
if (cache.entries.length > 0) return cache.entries;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const query = (searchParams.get("q") ?? "").slice(0, 120);
|
||||
const provider = (searchParams.get("provider") ?? "").slice(0, 120);
|
||||
const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500);
|
||||
const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10);
|
||||
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
||||
|
||||
try {
|
||||
const entries = await loadCatalog();
|
||||
const models = searchModelCatalog(entries, query, provider, limit);
|
||||
const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl);
|
||||
return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth";
|
||||
import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHeader(headers: Headers, name: string): boolean {
|
||||
return headers.has(name);
|
||||
}
|
||||
|
||||
function buildHeaders(api: string, apiKey: string | undefined, configured: Record<string, string>): Headers {
|
||||
const headers = new Headers(configured);
|
||||
if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json");
|
||||
if (!apiKey) return headers;
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey);
|
||||
if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01");
|
||||
} else if (api === "google-generative-ai") {
|
||||
if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey);
|
||||
} else if (!hasHeader(headers, "authorization")) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 });
|
||||
|
||||
const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : "";
|
||||
if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
||||
const api = typeof body.provider.api === "string" && body.provider.api
|
||||
? body.provider.api
|
||||
: "openai-completions";
|
||||
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = buildModelsListUrl(baseUrl, api);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await resolveModelDiscoveryAuth(providerName, body.provider);
|
||||
if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) {
|
||||
return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: "no-store",
|
||||
headers: buildHeaders(api, auth.apiKey, auth.headers),
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`,
|
||||
status: response.status,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(responseText);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 });
|
||||
}
|
||||
const models = parseDiscoveredModels(payload);
|
||||
if (models.length === 0) {
|
||||
return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ models, endpoint: endpoint.toString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getModelsPath(): string {
|
||||
return join(getAgentDir(), "models.json");
|
||||
}
|
||||
|
||||
function readModelsJson(): Record<string, unknown> {
|
||||
const path = getModelsPath();
|
||||
if (!existsSync(path)) return { providers: {} };
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { providers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeModelsJson(data: Record<string, unknown>): void {
|
||||
const path = getModelsPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readModelsJson());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
writeModelsJson(body);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Content-Type must be application/json" },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 });
|
||||
if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 });
|
||||
|
||||
const modelId = typeof body.model.id === "string" ? body.model.id.trim() : "";
|
||||
if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 });
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
...body.provider,
|
||||
models: [{ ...body.model, id: modelId }],
|
||||
},
|
||||
},
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ modelsPath });
|
||||
const loadError = modelRuntime.getError();
|
||||
if (loadError) return NextResponse.json({ ok: false, error: loadError });
|
||||
|
||||
const model = modelRuntime.getModel(providerName, modelId);
|
||||
if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` });
|
||||
|
||||
const resolved = await modelRuntime.getAuth(model);
|
||||
if (!resolved?.auth.apiKey) {
|
||||
return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
let status: number | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const message = await completeSimple(model, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: "Reply with OK only.",
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}, {
|
||||
apiKey: resolved.auth.apiKey,
|
||||
headers: resolved.auth.headers,
|
||||
maxTokens: 16,
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
maxRetries: 0,
|
||||
cacheRetention: "none",
|
||||
signal: controller.signal,
|
||||
onResponse: (response) => { status = response.status; },
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"),
|
||||
latencyMs,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
latencyMs,
|
||||
status,
|
||||
responseText: getAssistantText(message).slice(0, 300),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 });
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user