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,40 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Providers that use OAuth — handled separately via /api/auth/providers
|
||||
const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]);
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const all = modelRuntime.getModels();
|
||||
|
||||
// Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key)
|
||||
const seen = new Set<string>();
|
||||
const result: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
configured: boolean;
|
||||
source?: string;
|
||||
modelCount: number;
|
||||
}[] = [];
|
||||
|
||||
for (const provider of modelRuntime.getProviders()) {
|
||||
if (seen.has(provider.id)) continue;
|
||||
seen.add(provider.id);
|
||||
if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue;
|
||||
const status = modelRuntime.getProviderAuthStatus(provider.id);
|
||||
// Skip providers whose key comes from models.json (those are custom providers)
|
||||
if (status.source === "models_json_key") continue;
|
||||
const modelCount = all.filter((model) => model.provider === provider.id).length;
|
||||
result.push({
|
||||
id: provider.id,
|
||||
displayName: provider.name,
|
||||
configured: status.configured,
|
||||
source: status.source,
|
||||
modelCount,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { NextResponse } from "next/server";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ provider: string }> };
|
||||
|
||||
// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key)
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const status = modelRuntime.getProviderAuthStatus(provider);
|
||||
const displayName = modelRuntime.getProvider(provider)?.name ?? provider;
|
||||
const models = modelRuntime.getModels(provider).length;
|
||||
return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models });
|
||||
}
|
||||
|
||||
// POST /api/auth/api-key/[provider] body: { apiKey: string }
|
||||
export async function POST(req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const { apiKey } = await req.json() as { apiKey?: string };
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
let keySubmitted = false;
|
||||
await modelRuntime.login(provider, "api_key", {
|
||||
notify: () => {},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token");
|
||||
if (keyOption) return keyOption.id;
|
||||
throw new Error(`${provider} requires interactive authentication setup`);
|
||||
}
|
||||
if (!keySubmitted && prompt.type === "secret") {
|
||||
keySubmitted = true;
|
||||
return apiKey.trim();
|
||||
}
|
||||
throw new Error(`${provider} requires additional authentication settings`);
|
||||
},
|
||||
});
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/auth/api-key/[provider] — removes stored API key
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise
|
||||
declare global {
|
||||
var __piLoginCallbacks: Map<string, { resolve: (v: string) => void; reject: (e: Error) => void }> | undefined;
|
||||
}
|
||||
|
||||
function getCallbackRegistry() {
|
||||
if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map();
|
||||
return globalThis.__piLoginCallbacks;
|
||||
}
|
||||
|
||||
// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const { token, code } = (await req.json()) as { token?: string; code?: string };
|
||||
|
||||
if (!token || !code) {
|
||||
return Response.json({ error: "token and code required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const callbacks = registry.get(token);
|
||||
if (!callbacks) {
|
||||
return Response.json({ error: "No pending login for token" }, { status: 404 });
|
||||
}
|
||||
// Verify token belongs to this provider (token format: "<provider>-<ts>-<random>")
|
||||
if (!token.startsWith(`${provider}-`)) {
|
||||
return Response.json({ error: "Token does not match provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
callbacks.resolve(code);
|
||||
registry.delete(token);
|
||||
return Response.json({ ok: true, provider });
|
||||
}
|
||||
|
||||
// GET /api/auth/login/[provider] — SSE stream for OAuth flow
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const send = (controller: ReadableStreamDefaultController, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
// AbortController propagates client disconnect into ModelRuntime.login().
|
||||
const abort = new AbortController();
|
||||
req.signal.addEventListener("abort", () => abort.abort());
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
send(controller, { type: "error", message: `Unknown provider: ${provider}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const activeTokens = new Set<string>();
|
||||
let pendingManualRequest: { token: string; promise: Promise<string> } | undefined;
|
||||
|
||||
const createClientInputRequest = () => {
|
||||
const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeTokens.add(token);
|
||||
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
registry.set(token, {
|
||||
resolve: (value) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { token, promise };
|
||||
};
|
||||
|
||||
const getManualInputRequest = () => {
|
||||
if (!pendingManualRequest) {
|
||||
pendingManualRequest = createClientInputRequest();
|
||||
pendingManualRequest.promise
|
||||
.finally(() => {
|
||||
pendingManualRequest = undefined;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return pendingManualRequest;
|
||||
};
|
||||
|
||||
// Cleanup: remove pending token and abort any waiting promise
|
||||
const cleanup = () => {
|
||||
for (const token of activeTokens) {
|
||||
registry.get(token)?.reject(new Error("Login cancelled"));
|
||||
registry.delete(token);
|
||||
}
|
||||
activeTokens.clear();
|
||||
};
|
||||
|
||||
// Also cancel on client disconnect
|
||||
abort.signal.addEventListener("abort", cleanup);
|
||||
|
||||
try {
|
||||
await modelRuntime.login(provider, "oauth", {
|
||||
prompt: async (prompt: AuthPrompt) => {
|
||||
const request = prompt.type === "manual_code"
|
||||
? getManualInputRequest()
|
||||
: createClientInputRequest();
|
||||
if (prompt.type === "select") {
|
||||
send(controller, {
|
||||
type: "select_request",
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
token: request.token,
|
||||
});
|
||||
} else {
|
||||
send(controller, {
|
||||
type: "prompt_request",
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
}
|
||||
return request.promise;
|
||||
},
|
||||
notify: (event: AuthEvent) => {
|
||||
if (event.type === "auth_url") {
|
||||
const request = getManualInputRequest();
|
||||
send(controller, {
|
||||
type: "auth",
|
||||
url: event.url,
|
||||
instructions: event.instructions ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
} else if (event.type === "device_code") {
|
||||
send(controller, {
|
||||
type: "device_code",
|
||||
userCode: event.userCode,
|
||||
verificationUri: event.verificationUri,
|
||||
intervalSeconds: event.intervalSeconds ?? null,
|
||||
expiresInSeconds: event.expiresInSeconds ?? null,
|
||||
});
|
||||
} else {
|
||||
send(controller, { type: "progress", message: event.message });
|
||||
}
|
||||
},
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
invalidateModelsCache();
|
||||
send(controller, { type: "success" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Login cancelled") {
|
||||
send(controller, { type: "error", message: msg });
|
||||
} else {
|
||||
send(controller, { type: "cancelled" });
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
|
||||
}
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const credentials = await modelRuntime.listCredentials();
|
||||
const loggedInProviders = new Set(
|
||||
credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId),
|
||||
);
|
||||
const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth);
|
||||
|
||||
const EXCLUDED = new Set(["anthropic"]);
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
"openai-codex": "ChatGPT Plus/Pro",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
};
|
||||
|
||||
const result = await Promise.all(
|
||||
providers
|
||||
.filter((p) => !EXCLUDED.has(p.id))
|
||||
.map(async (p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
name: DISPLAY_NAMES[p.id] ?? p.name,
|
||||
usesCallbackServer: false,
|
||||
loggedIn: loggedInProviders.has(p.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
Reference in New Issue
Block a user