fix: Pi harness - dynamic models, correct RPC protocol, proper event handling
Backend: - /api/pi/models now calls 'pi --list-models' with stored API keys - pi-bridge.ts: callback-based event handling (matches pi-monorepo) - pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc) - pi-bridge.ts: pass API keys to Pi process env - websocket.ts: event handler runs in background, no blocking - rest.ts: fix user home path (getHomeDir instead of hardcoded) Frontend: - Fix /api/ double prefix in useChatSessions, useChatGroups, useModels - Add PROVIDER_DISPLAY mapping in SystemSettings.tsx - Provider tabs show friendly names (e.g., 'OpenCode Zen') UI (from previous session): - Grouped session list with collapsible folders - CreateGroupDialog, GroupContextMenu, SessionContextMenu components
This commit is contained in:
+69
-37
@@ -1,6 +1,8 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as storage from './storage';
|
||||
import { readApiKeys } from '../server-settings/pi-mono';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import type { ModelInfo } from './types';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -12,36 +14,66 @@ export const piRestRouter = createRouter();
|
||||
|
||||
/**
|
||||
* GET /api/pi/models
|
||||
* List available models
|
||||
* List available models by running `pi --list-models` with stored API keys
|
||||
*/
|
||||
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
// TODO: Implement dynamic model discovery
|
||||
// For now, return hardcoded models
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-5',
|
||||
name: 'Claude Opus 4.5',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'big-pickle',
|
||||
name: 'Big Pickle',
|
||||
provider: 'opencode-zen',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const storedKeys = await readApiKeys();
|
||||
|
||||
const proc = Bun.spawn(['pi', '--list-models'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
return ctx.json({ models });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
logger.error('pi --list-models failed', { exitCode: proc.exitCode });
|
||||
return ctx.json({ models: [] });
|
||||
}
|
||||
|
||||
// Parse the whitespace-separated table output:
|
||||
// provider model context max-out thinking images
|
||||
// anthropic claude-sonnet-4-6 200K 128K yes yes
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
const models: ModelInfo[] = [];
|
||||
|
||||
// Skip header line (first line)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i]!.trim().split(/\s+/);
|
||||
if (cols.length < 2) continue;
|
||||
|
||||
const [provider, model, context, maxOut] = cols;
|
||||
|
||||
// Parse context window (e.g., "200K" -> 200000)
|
||||
const parseSize = (s?: string): number => {
|
||||
if (!s) return 128000;
|
||||
const match = s.match(/^(\d+)([KMG])?$/i);
|
||||
if (!match) return 128000;
|
||||
const num = parseInt(match[1]!, 10);
|
||||
const unit = (match[2] ?? '').toUpperCase();
|
||||
if (unit === 'K') return num * 1000;
|
||||
if (unit === 'M') return num * 1000000;
|
||||
if (unit === 'G') return num * 1000000000;
|
||||
return num;
|
||||
};
|
||||
|
||||
models.push({
|
||||
id: `${provider}/${model}`,
|
||||
name: model!,
|
||||
provider: provider!,
|
||||
contextWindow: parseSize(context),
|
||||
maxTokens: parseSize(maxOut),
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json({ models });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list models', { error: String(err) });
|
||||
return ctx.json({ models: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -55,7 +87,7 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
// TODO: Implement proper user home directory resolution
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const sessions = await storage.listUserSessions(userHome);
|
||||
@@ -81,7 +113,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Try loading from root first
|
||||
@@ -140,7 +172,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Title is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
@@ -192,7 +224,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
@@ -251,7 +283,7 @@ piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Query parameter required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const results = await storage.searchSessions(userHome, query);
|
||||
@@ -282,7 +314,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Check if group already exists
|
||||
@@ -347,7 +379,7 @@ piRestRouter.get('/pi/groups', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const groups = await storage.listGroups(userHome);
|
||||
@@ -387,7 +419,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'No valid updates provided' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
||||
@@ -416,7 +448,7 @@ piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
await storage.deleteGroup(userHome, groupSlug);
|
||||
@@ -449,7 +481,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session in root or any group
|
||||
|
||||
Reference in New Issue
Block a user