single source of truth for pi config via ~/.pi/agent
auth.json stores api keys, models.json stores local providers directly. no more copies, sync layers, or per-user config generation. docker containers mount pi config read-only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+71
-94
@@ -15,7 +15,7 @@ import {
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
||||||
type StoredApiKeys = { keys: { env: string; value: string }[] };
|
type StoredApiKeys = { keys: { provider: string; value: string }[] };
|
||||||
type LocalProviderEntry = {
|
type LocalProviderEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,31 +33,21 @@ type ProbeResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PI_PROVIDERS: { key: string; env: string[] }[] = [
|
const PI_PROVIDERS: { key: string; piId: string }[] = [
|
||||||
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
{ key: 'Anthropic', piId: 'anthropic' },
|
||||||
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
|
{ key: 'OpenAI', piId: 'openai' },
|
||||||
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
|
{ key: 'Google', piId: 'google' },
|
||||||
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
|
{ key: 'Groq', piId: 'groq' },
|
||||||
{ key: 'Groq', env: ['GROQ_API_KEY'] },
|
{ key: 'Mistral', piId: 'mistral' },
|
||||||
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
|
{ key: 'xAI', piId: 'xai' },
|
||||||
{ key: 'xAI', env: ['XAI_API_KEY'] },
|
{ key: 'OpenRouter', piId: 'openrouter' },
|
||||||
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
|
{ key: 'MiniMax', piId: 'minimax' },
|
||||||
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
|
{ key: 'Hugging Face', piId: 'huggingface' },
|
||||||
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
|
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
|
||||||
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
|
{ key: 'OpenCode Zen', piId: 'zai' },
|
||||||
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
|
{ key: 'Cerebras', piId: 'cerebras' },
|
||||||
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
|
|
||||||
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_FIELDS = new Set([
|
|
||||||
'AWS_REGION',
|
|
||||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
||||||
'GOOGLE_CLOUD_PROJECT',
|
|
||||||
'GOOGLE_CLOUD_LOCATION',
|
|
||||||
'AZURE_OPENAI_BASE_URL',
|
|
||||||
]);
|
|
||||||
|
|
||||||
type ProbeState =
|
type ProbeState =
|
||||||
| { step: 'url' }
|
| { step: 'url' }
|
||||||
| { step: 'probing' }
|
| { step: 'probing' }
|
||||||
@@ -117,24 +107,25 @@ export const AIHarnessesSection = () => {
|
|||||||
setAuthPassword('');
|
setAuthPassword('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const storedEnvs = new Set(piMonoKeys?.keys.map((k: { env: string }) => k.env) ?? []);
|
const storedKeys: StoredApiKeys['keys'] = piMonoKeys?.keys ?? [];
|
||||||
const connectedProviders = PI_PROVIDERS.filter((p) => p.env.some((e) => storedEnvs.has(e)));
|
const storedPiIds = new Set(storedKeys.map((k) => k.provider));
|
||||||
const unconnectedProviders = PI_PROVIDERS.filter((p) => !p.env.some((e) => storedEnvs.has(e)));
|
const connectedProviders = PI_PROVIDERS.filter((p) => storedPiIds.has(p.piId));
|
||||||
|
const unconnectedProviders = PI_PROVIDERS.filter((p) => !storedPiIds.has(p.piId));
|
||||||
|
|
||||||
const getStoredMasked = (env: string) =>
|
const getStoredMasked = (piId: string) =>
|
||||||
piMonoKeys?.keys.find((k: { env: string; value: string }) => k.env === env)?.value ?? '';
|
storedKeys.find((k) => k.provider === piId)?.value ?? '';
|
||||||
|
|
||||||
const saveApiKey = async (env: string) => {
|
const saveApiKey = async (piId: string) => {
|
||||||
const value = keyInputs[env];
|
const value = keyInputs[piId];
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
setSavingKey(env);
|
setSavingKey(piId);
|
||||||
try {
|
try {
|
||||||
await client.put('/server-settings/pi-mono/api-keys', { key: env, value });
|
await client.put('/server-settings/pi-mono/api-keys', { provider: piId, value });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
||||||
setKeyInputs((prev) => {
|
setKeyInputs((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[env];
|
delete next[piId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,9 +134,7 @@ export const AIHarnessesSection = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
|
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
|
||||||
for (const env of provider.env) {
|
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
|
||||||
await client.put('/server-settings/pi-mono/api-keys', { key: env, value: '' });
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
||||||
if (editingProvider === provider.key) setEditingProvider(null);
|
if (editingProvider === provider.key) setEditingProvider(null);
|
||||||
@@ -433,69 +422,57 @@ export const AIHarnessesSection = () => {
|
|||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
{connectedProviders.map((provider) => (
|
{connectedProviders.map((provider) => (
|
||||||
<div key={provider.key} className="flex flex-col gap-1">
|
<div key={provider.piId} className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
||||||
<span className="text-duck-dark/60 font-medium text-[11px]">{provider.key}</span>
|
<Input
|
||||||
<button
|
type="password"
|
||||||
type="button"
|
className="h-7 text-xs flex-1"
|
||||||
onClick={() => disconnectProvider(provider)}
|
placeholder={getStoredMasked(provider.piId) || 'Not set'}
|
||||||
className="p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
value={keyInputs[provider.piId] ?? ''}
|
||||||
title="Disconnect provider"
|
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||||
>
|
/>
|
||||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||||
{provider.env.map((env) => (
|
onClick={() => saveApiKey(provider.piId)}
|
||||||
<div key={env} className="flex items-center gap-2">
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||||
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
|
title="Save key"
|
||||||
<Input
|
>
|
||||||
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
|
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
className="h-7 text-xs flex-1"
|
</button>
|
||||||
placeholder={getStoredMasked(env) || 'Not set'}
|
<button
|
||||||
value={keyInputs[env] ?? ''}
|
type="button"
|
||||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
|
onClick={() => disconnectProvider(provider)}
|
||||||
/>
|
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||||
<button
|
title="Disconnect provider"
|
||||||
type="button"
|
>
|
||||||
disabled={keyInputs[env] === undefined || savingKey === env}
|
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||||
onClick={() => saveApiKey(env)}
|
</button>
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
|
||||||
title="Save key"
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editingProvider && (() => {
|
{editingProvider && (() => {
|
||||||
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
||||||
if (!provider || connectedProviders.includes(provider)) return null;
|
if (!provider || connectedProviders.includes(provider)) return null;
|
||||||
return (
|
return (
|
||||||
<div key={provider.key} className="flex flex-col gap-1">
|
<div key={provider.piId} className="flex items-center gap-2">
|
||||||
<span className="text-duck-dark/60 font-medium text-[11px] mt-1">{provider.key}</span>
|
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
||||||
{provider.env.map((env) => (
|
<Input
|
||||||
<div key={env} className="flex items-center gap-2">
|
type="password"
|
||||||
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
|
className="h-7 text-xs flex-1"
|
||||||
<Input
|
placeholder="Enter API key"
|
||||||
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
|
value={keyInputs[provider.piId] ?? ''}
|
||||||
className="h-7 text-xs flex-1"
|
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||||
placeholder="Not set"
|
autoFocus
|
||||||
value={keyInputs[env] ?? ''}
|
/>
|
||||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
|
<button
|
||||||
autoFocus={env === provider.env[0]}
|
type="button"
|
||||||
/>
|
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||||
<button
|
onClick={() => saveApiKey(provider.piId)}
|
||||||
type="button"
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||||
disabled={keyInputs[env] === undefined || savingKey === env}
|
title="Save key"
|
||||||
onClick={() => saveApiKey(env)}
|
>
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
title="Save key"
|
</button>
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getUserCount, createUser } from 'officerdb';
|
|||||||
import { sign, verify } from '@@/jwt';
|
import { sign, verify } from '@@/jwt';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
|
|
||||||
export const bootstrapHandler: Handler = async function (ctx) {
|
export const bootstrapHandler: Handler = async function (ctx) {
|
||||||
@@ -58,6 +57,5 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
status: 'Active',
|
status: 'Active',
|
||||||
});
|
});
|
||||||
|
|
||||||
syncUserPiConfig(payload.email).catch(() => {});
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
|||||||
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { getClaudeDir } from '@@/data-path';
|
import { getClaudeDir } from '@@/data-path';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ export const signinHandler: Handler = async function (ctx) {
|
|||||||
const { id, name, username, role } = dbUser;
|
const { id, name, username, role } = dbUser;
|
||||||
|
|
||||||
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
||||||
syncUserPiConfig(email).catch(() => {});
|
|
||||||
|
|
||||||
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
|
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const parseSize = (s?: string): number => {
|
|||||||
return Math.round(num);
|
return Math.round(num);
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listPiModels(envKeys: Record<string, string>): Promise<ModelInfo[]> {
|
export async function listPiModels(): Promise<ModelInfo[]> {
|
||||||
if (cachedModels && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
if (cachedModels && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||||
return cachedModels;
|
return cachedModels;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export async function listPiModels(envKeys: Record<string, string>): Promise<Mod
|
|||||||
const proc = Bun.spawn(['pi', '--list-models'], {
|
const proc = Bun.spawn(['pi', '--list-models'], {
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: { ...process.env, ...envKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
||||||
});
|
});
|
||||||
|
|
||||||
const output = await new Response(proc.stdout).text();
|
const output = await new Response(proc.stdout).text();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { homedir } from "node:os";
|
|||||||
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||||
import type { Subprocess } from "bun";
|
import type { Subprocess } from "bun";
|
||||||
import type { PiEvent, MessageCost } from "./types";
|
import type { PiEvent, MessageCost } from "./types";
|
||||||
import { readApiKeys } from "../server-settings/pi-mono";
|
|
||||||
import { readSearxngConfig } from "../server-settings/searxng";
|
import { readSearxngConfig } from "../server-settings/searxng";
|
||||||
import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||||
import { ensureDockerContainer } from "../terminal/websocket";
|
import { ensureDockerContainer } from "../terminal/websocket";
|
||||||
@@ -208,12 +207,10 @@ export async function spawnPi(
|
|||||||
|
|
||||||
if (sandbox) {
|
if (sandbox) {
|
||||||
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
|
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
|
||||||
const storedKeys = await readApiKeys();
|
|
||||||
const searxng = await readSearxngConfig();
|
const searxng = await readSearxngConfig();
|
||||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||||
const containerId = container.dockerId;
|
const containerId = container.dockerId;
|
||||||
const containerHome = `/home/${sandbox.username}`;
|
const containerHome = `/home/${sandbox.username}`;
|
||||||
const containerPiConfig = `${containerHome}/.pi/agent`;
|
|
||||||
|
|
||||||
// Collect skill/extension flags using container-side paths
|
// Collect skill/extension flags using container-side paths
|
||||||
const skillFlags = collectSkillFlags(sandbox.email, {
|
const skillFlags = collectSkillFlags(sandbox.email, {
|
||||||
@@ -244,7 +241,7 @@ export async function spawnPi(
|
|||||||
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
|
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
|
||||||
|
|
||||||
const envFlags = [
|
const envFlags = [
|
||||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
'-e', `PI_CODING_AGENT_DIR=/officer/pi-config`,
|
||||||
'-e', `HOME=${containerHome}`,
|
'-e', `HOME=${containerHome}`,
|
||||||
'-e', `OFFICER_USER_HOME=${containerHome}`,
|
'-e', `OFFICER_USER_HOME=${containerHome}`,
|
||||||
'-e', `OFFICER_USER_ROOT=/officer/user`,
|
'-e', `OFFICER_USER_ROOT=/officer/user`,
|
||||||
@@ -255,9 +252,6 @@ export async function spawnPi(
|
|||||||
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
||||||
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
|
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
|
||||||
];
|
];
|
||||||
for (const [key, value] of Object.entries(storedKeys)) {
|
|
||||||
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rel = relative(sandbox.homeDir, cwd);
|
const rel = relative(sandbox.homeDir, cwd);
|
||||||
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
|
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
|
||||||
@@ -281,7 +275,6 @@ export async function spawnPi(
|
|||||||
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const storedKeys = await readApiKeys();
|
|
||||||
const searxng = await readSearxngConfig();
|
const searxng = await readSearxngConfig();
|
||||||
const skillFlags = collectSkillFlags(email);
|
const skillFlags = collectSkillFlags(email);
|
||||||
const extensionFlags = collectExtensionFlags(email);
|
const extensionFlags = collectExtensionFlags(email);
|
||||||
@@ -308,7 +301,6 @@ export async function spawnPi(
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...storedKeys,
|
|
||||||
HOME: getHomeDir(email),
|
HOME: getHomeDir(email),
|
||||||
OFFICER_USER_HOME: getHomeDir(email),
|
OFFICER_USER_HOME: getHomeDir(email),
|
||||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import * as storage from './storage';
|
import * as storage from './storage';
|
||||||
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
|
import { readLocalProviders } from '../server-settings/pi-mono';
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
import { listPiModels } from './list-models';
|
import { listPiModels } from './list-models';
|
||||||
import { getHomeDir } from '../../data-path';
|
import { getHomeDir } from '../../data-path';
|
||||||
@@ -21,8 +21,7 @@ export const piRestRouter = createRouter();
|
|||||||
*/
|
*/
|
||||||
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||||
try {
|
try {
|
||||||
const storedKeys = await readApiKeys();
|
const models = await listPiModels();
|
||||||
const models = await listPiModels(storedKeys);
|
|
||||||
|
|
||||||
// Build providerNames map for officer-local-* providers
|
// Build providerNames map for officer-local-* providers
|
||||||
const providerNames: Record<string, string> = {};
|
const providerNames: Record<string, string> = {};
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { PI_CONFIG_DIR } from '../../data-path';
|
||||||
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
|
|
||||||
import { syncAllUserPiConfigs } from './sync-user-pi-config';
|
|
||||||
import { invalidateModelCache } from '../pi/list-models';
|
import { invalidateModelCache } from '../pi/list-models';
|
||||||
|
import { logger } from '../pi/logger';
|
||||||
|
|
||||||
export const piMonoRouter = createRouter();
|
export const piMonoRouter = createRouter();
|
||||||
|
|
||||||
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
|
const PI_AUTH_FILE = join(PI_CONFIG_DIR, 'auth.json');
|
||||||
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
|
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
||||||
const ACCESS_POLICY_FILE = join(DATA_PATH, 'pi_access_policy.json');
|
|
||||||
|
|
||||||
// --- Local provider types ---
|
// --- Local provider types ---
|
||||||
|
|
||||||
@@ -31,18 +29,63 @@ type ProbeResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function readLocalProviders(): Promise<LocalProvider[]> {
|
type PiModelConfig = {
|
||||||
|
providers: Record<string, {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
api: string;
|
||||||
|
models: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
reasoning: boolean;
|
||||||
|
input: string[];
|
||||||
|
contextWindow: number;
|
||||||
|
maxTokens: number;
|
||||||
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||||
|
}[];
|
||||||
|
_officer?: OfficerMeta;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OfficerMeta = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
apiType: LocalProvider['apiType'];
|
||||||
|
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readModelsConfig(): Promise<PiModelConfig> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(LOCAL_PROVIDERS_FILE);
|
const file = Bun.file(PI_MODELS_FILE);
|
||||||
if (!(await file.exists())) return [];
|
if (!(await file.exists())) return { providers: {} };
|
||||||
return (await file.json()) as LocalProvider[];
|
return (await file.json()) as PiModelConfig;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return { providers: {} };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeLocalProviders(providers: LocalProvider[]) {
|
async function writeModelsConfig(config: PiModelConfig): Promise<void> {
|
||||||
await Bun.write(LOCAL_PROVIDERS_FILE, JSON.stringify(providers, null, 2));
|
await Bun.write(PI_MODELS_FILE, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read local providers from models.json by extracting officer-local-* entries.
|
||||||
|
*/
|
||||||
|
export async function readLocalProviders(): Promise<LocalProvider[]> {
|
||||||
|
const config = await readModelsConfig();
|
||||||
|
const providers: LocalProvider[] = [];
|
||||||
|
for (const [key, entry] of Object.entries(config.providers)) {
|
||||||
|
if (!key.startsWith('officer-local-') || !entry._officer) continue;
|
||||||
|
const id = key.replace('officer-local-', '');
|
||||||
|
providers.push({
|
||||||
|
id,
|
||||||
|
name: entry._officer.name,
|
||||||
|
url: entry._officer.url,
|
||||||
|
apiType: entry._officer.apiType,
|
||||||
|
auth: entry._officer.auth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
|
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
|
||||||
@@ -151,60 +194,168 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
|
|||||||
return { success: false, error: 'Could not detect API type at this URL' };
|
return { success: false, error: 'Could not detect API type at this URL' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readApiKeys(): Promise<Record<string, string>> {
|
// --- Model fetching for local providers ---
|
||||||
|
|
||||||
|
const REASONING_PATTERNS = [/\bqwen3\b/i, /\bqwq\b/i, /\bdeepseek-r1\b/i, /\br1\b/i, /\breasoning\b/i, /\bthink/i];
|
||||||
|
|
||||||
|
function isReasoningModel(modelId: string): boolean {
|
||||||
|
return REASONING_PATTERNS.some((p) => p.test(modelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModelsFromProvider(
|
||||||
|
lp: LocalProvider,
|
||||||
|
): Promise<{ id: string; name?: string; contextWindow?: number; maxTokens?: number }[]> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(API_KEYS_FILE);
|
const base = lp.url.replace(/\/+$/, '');
|
||||||
|
const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
|
||||||
|
else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (lp.apiType === 'ollama' && data.models) {
|
||||||
|
return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
|
||||||
|
} else if (data.data) {
|
||||||
|
return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a local provider entry directly into models.json with probed models.
|
||||||
|
*/
|
||||||
|
async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise<void> {
|
||||||
|
const models = await fetchModelsFromProvider(lp);
|
||||||
|
if (models.length === 0) {
|
||||||
|
logger.warn('No models found for local provider', { provider: lp.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = lp.apiType === 'ollama'
|
||||||
|
? `${lp.url}/v1`
|
||||||
|
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
|
||||||
|
|
||||||
|
const config = await readModelsConfig();
|
||||||
|
config.providers[`officer-local-${lp.id}`] = {
|
||||||
|
baseUrl,
|
||||||
|
apiKey: lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none',
|
||||||
|
api: 'openai-completions',
|
||||||
|
models: models.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id,
|
||||||
|
reasoning: isReasoningModel(m.id),
|
||||||
|
input: ['text'],
|
||||||
|
contextWindow: m.contextWindow || 128000,
|
||||||
|
maxTokens: m.maxTokens || 4096,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
})),
|
||||||
|
_officer: {
|
||||||
|
name: lp.name,
|
||||||
|
url: lp.url,
|
||||||
|
apiType: lp.apiType,
|
||||||
|
auth: lp.auth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeModelsConfig(config);
|
||||||
|
invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLocalProviderFromModelsConfig(id: string): Promise<boolean> {
|
||||||
|
const config = await readModelsConfig();
|
||||||
|
const key = `officer-local-${id}`;
|
||||||
|
if (!(key in config.providers)) return false;
|
||||||
|
delete config.providers[key];
|
||||||
|
await writeModelsConfig(config);
|
||||||
|
invalidateModelCache();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-probe all local providers and update their models in models.json.
|
||||||
|
*/
|
||||||
|
async function refreshLocalProviders(): Promise<void> {
|
||||||
|
const config = await readModelsConfig();
|
||||||
|
for (const [key, entry] of Object.entries(config.providers)) {
|
||||||
|
if (!key.startsWith('officer-local-') || !entry._officer) continue;
|
||||||
|
const lp: LocalProvider = {
|
||||||
|
id: key.replace('officer-local-', ''),
|
||||||
|
name: entry._officer.name,
|
||||||
|
url: entry._officer.url,
|
||||||
|
apiType: entry._officer.apiType,
|
||||||
|
auth: entry._officer.auth,
|
||||||
|
};
|
||||||
|
const models = await fetchModelsFromProvider(lp);
|
||||||
|
|
||||||
|
const baseUrl = lp.apiType === 'ollama'
|
||||||
|
? `${lp.url}/v1`
|
||||||
|
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
|
||||||
|
|
||||||
|
entry.baseUrl = baseUrl;
|
||||||
|
entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none';
|
||||||
|
entry.models = models.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id,
|
||||||
|
reasoning: isReasoningModel(m.id),
|
||||||
|
input: ['text'],
|
||||||
|
contextWindow: m.contextWindow || 128000,
|
||||||
|
maxTokens: m.maxTokens || 4096,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await writeModelsConfig(config);
|
||||||
|
invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthEntry = { type: 'api_key'; key: string };
|
||||||
|
type AuthJson = Record<string, AuthEntry>;
|
||||||
|
|
||||||
|
async function readAuthJson(): Promise<AuthJson> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(PI_AUTH_FILE);
|
||||||
if (!(await file.exists())) return {};
|
if (!(await file.exists())) return {};
|
||||||
return (await file.json()) as Record<string, string>;
|
return (await file.json()) as AuthJson;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeApiKeys(keys: Record<string, string>) {
|
async function writeAuthJson(auth: AuthJson): Promise<void> {
|
||||||
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
await Bun.write(PI_AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccessPolicy = {
|
/**
|
||||||
allowedModels: string[]; // e.g. ["anthropic:anthropic/claude-sonnet-4"]
|
* Hardcoded map of display name → Pi provider ID.
|
||||||
};
|
* Pi's native providers have built-in models; adding a key to auth.json
|
||||||
|
* is all that's needed to make their models appear in `pi --list-models`.
|
||||||
export async function readAccessPolicy(): Promise<AccessPolicy> {
|
*/
|
||||||
try {
|
export const PROVIDERS: { key: string; piId: string }[] = [
|
||||||
const file = Bun.file(ACCESS_POLICY_FILE);
|
{ key: 'Anthropic', piId: 'anthropic' },
|
||||||
if (!(await file.exists())) return { allowedModels: [] };
|
{ key: 'OpenAI', piId: 'openai' },
|
||||||
return (await file.json()) as AccessPolicy;
|
{ key: 'Google', piId: 'google' },
|
||||||
} catch {
|
{ key: 'Groq', piId: 'groq' },
|
||||||
return { allowedModels: [] };
|
{ key: 'Mistral', piId: 'mistral' },
|
||||||
}
|
{ key: 'xAI', piId: 'xai' },
|
||||||
}
|
{ key: 'OpenRouter', piId: 'openrouter' },
|
||||||
|
{ key: 'MiniMax', piId: 'minimax' },
|
||||||
async function writeAccessPolicy(policy: AccessPolicy) {
|
{ key: 'Hugging Face', piId: 'huggingface' },
|
||||||
await Bun.write(ACCESS_POLICY_FILE, JSON.stringify(policy, null, 2));
|
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
|
||||||
}
|
{ key: 'OpenCode Zen', piId: 'zai' },
|
||||||
|
{ key: 'Cerebras', piId: 'cerebras' },
|
||||||
export const PROVIDERS: { key: string; env: string[] }[] = [
|
|
||||||
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
|
||||||
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
|
|
||||||
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
|
|
||||||
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
|
|
||||||
{ key: 'Groq', env: ['GROQ_API_KEY'] },
|
|
||||||
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
|
|
||||||
{ key: 'xAI', env: ['XAI_API_KEY'] },
|
|
||||||
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
|
|
||||||
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
|
|
||||||
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
|
|
||||||
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
|
|
||||||
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
|
|
||||||
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
|
|
||||||
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
piMonoRouter.get('/auth', async (ctx) => {
|
piMonoRouter.get('/auth', async (ctx) => {
|
||||||
const storedKeys = await readApiKeys();
|
const auth = await readAuthJson();
|
||||||
const providers = PROVIDERS.filter((p) =>
|
const providers = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => p.key);
|
||||||
p.env.some((e) => storedKeys[e]?.trim() || process.env[e]?.trim()),
|
|
||||||
).map((p) => p.key);
|
|
||||||
return ctx.json({ authenticated: providers.length > 0, providers });
|
return ctx.json({ authenticated: providers.length > 0, providers });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,29 +365,25 @@ const maskValue = (value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
piMonoRouter.get('/api-keys', async (ctx) => {
|
piMonoRouter.get('/api-keys', async (ctx) => {
|
||||||
const storedKeys = await readApiKeys();
|
const auth = await readAuthJson();
|
||||||
const keys = PROVIDERS.flatMap((p) =>
|
const keys = PROVIDERS
|
||||||
p.env
|
.filter((p) => auth[p.piId]?.key?.trim())
|
||||||
.filter((e) => storedKeys[e]?.trim())
|
.map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) }));
|
||||||
.map((e) => ({ env: e, value: maskValue(storedKeys[e]!) })),
|
|
||||||
);
|
|
||||||
return ctx.json({ keys });
|
return ctx.json({ keys });
|
||||||
});
|
});
|
||||||
|
|
||||||
piMonoRouter.put('/api-keys', async (ctx) => {
|
piMonoRouter.put('/api-keys', async (ctx) => {
|
||||||
const { key, value } = await ctx.req.json<{ key: string; value: string }>();
|
const { provider, value } = await ctx.req.json<{ provider: string; value: string }>();
|
||||||
const allEnvs = PROVIDERS.flatMap((p) => p.env);
|
if (!PROVIDERS.some((p) => p.piId === provider)) return ctx.json({ error: 'Invalid provider' }, 400);
|
||||||
if (!allEnvs.includes(key)) return ctx.json({ error: 'Invalid key' }, 400);
|
|
||||||
|
|
||||||
const keys = await readApiKeys();
|
const auth = await readAuthJson();
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
keys[key] = value.trim();
|
auth[provider] = { type: 'api_key', key: value.trim() };
|
||||||
} else {
|
} else {
|
||||||
delete keys[key];
|
delete auth[provider];
|
||||||
}
|
}
|
||||||
await writeApiKeys(keys);
|
await writeAuthJson(auth);
|
||||||
invalidateModelCache();
|
invalidateModelCache();
|
||||||
syncAllUserPiConfigs().catch(() => {});
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,7 +457,6 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => {
|
|||||||
|
|
||||||
piMonoRouter.post('/local-providers', async (ctx) => {
|
piMonoRouter.post('/local-providers', async (ctx) => {
|
||||||
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
|
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
|
||||||
const providers = await readLocalProviders();
|
|
||||||
|
|
||||||
const provider: LocalProvider = {
|
const provider: LocalProvider = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -320,51 +466,19 @@ piMonoRouter.post('/local-providers', async (ctx) => {
|
|||||||
auth: body.auth,
|
auth: body.auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
providers.push(provider);
|
await addLocalProviderToModelsConfig(provider);
|
||||||
await writeLocalProviders(providers);
|
|
||||||
|
|
||||||
// Sync to Pi config so Pi knows about this provider
|
|
||||||
await syncLocalProvidersToPiConfig();
|
|
||||||
syncAllUserPiConfigs().catch(() => {});
|
|
||||||
|
|
||||||
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
||||||
const { id } = ctx.req.param();
|
const { id } = ctx.req.param();
|
||||||
const providers = await readLocalProviders();
|
const removed = await removeLocalProviderFromModelsConfig(id);
|
||||||
const filtered = providers.filter((p) => p.id !== id);
|
if (!removed) return ctx.json({ error: 'Not found' }, 404);
|
||||||
if (filtered.length === providers.length) return ctx.json({ error: 'Not found' }, 404);
|
|
||||||
await writeLocalProviders(filtered);
|
|
||||||
|
|
||||||
// Sync to Pi config to remove this provider
|
|
||||||
await syncLocalProvidersToPiConfig();
|
|
||||||
syncAllUserPiConfigs().catch(() => {});
|
|
||||||
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Access policy ---
|
|
||||||
|
|
||||||
piMonoRouter.get('/access-policy', async (ctx) => {
|
|
||||||
const policy = await readAccessPolicy();
|
|
||||||
return ctx.json(policy);
|
|
||||||
});
|
|
||||||
|
|
||||||
piMonoRouter.put('/access-policy', async (ctx) => {
|
|
||||||
const body = await ctx.req.json<AccessPolicy>();
|
|
||||||
const policy: AccessPolicy = {
|
|
||||||
allowedModels: Array.isArray(body.allowedModels) ? body.allowedModels : [],
|
|
||||||
};
|
|
||||||
await writeAccessPolicy(policy);
|
|
||||||
await syncAllUserPiConfigs();
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
piMonoRouter.post('/local-providers/refresh', async (ctx) => {
|
piMonoRouter.post('/local-providers/refresh', async (ctx) => {
|
||||||
await syncLocalProvidersToPiConfig();
|
await refreshLocalProviders();
|
||||||
syncAllUserPiConfigs().catch(() => {});
|
|
||||||
invalidateModelCache();
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { join } from 'node:path';
|
|
||||||
import type { LocalProvider } from './pi-mono';
|
|
||||||
import { readLocalProviders } from './pi-mono';
|
|
||||||
import { PI_CONFIG_DIR } from '../../data-path';
|
|
||||||
import { invalidateModelCache } from '../pi/list-models';
|
|
||||||
import { logger } from '../pi/logger';
|
|
||||||
|
|
||||||
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
|
||||||
|
|
||||||
type PiModelConfig = {
|
|
||||||
providers: Record<string, {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
api: string;
|
|
||||||
models: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
input: string[];
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
cost: {
|
|
||||||
input: number;
|
|
||||||
output: number;
|
|
||||||
cacheRead: number;
|
|
||||||
cacheWrite: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync Officer's local providers to Pi's models.json config.
|
|
||||||
* Only local providers are synced — API providers are handled by Pi via env vars.
|
|
||||||
*/
|
|
||||||
export async function syncLocalProvidersToPiConfig(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const piModelsFile = Bun.file(PI_MODELS_FILE);
|
|
||||||
let piConfig: PiModelConfig;
|
|
||||||
|
|
||||||
if (await piModelsFile.exists()) {
|
|
||||||
piConfig = await piModelsFile.json();
|
|
||||||
} else {
|
|
||||||
piConfig = { providers: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove all officer-local-* entries (will be re-added below)
|
|
||||||
for (const providerId of Object.keys(piConfig.providers)) {
|
|
||||||
if (providerId.startsWith('officer-local-')) {
|
|
||||||
delete piConfig.providers[providerId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add local providers
|
|
||||||
const localProviders = await readLocalProviders();
|
|
||||||
logger.info('Syncing local providers to Pi config', { count: localProviders.length });
|
|
||||||
|
|
||||||
for (const lp of localProviders) {
|
|
||||||
const providerId = `officer-local-${lp.id}`;
|
|
||||||
const models = await fetchModelsFromLocalProvider(lp);
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
|
||||||
logger.warn('No models found for local provider', { provider: lp.name });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = lp.apiType === 'ollama'
|
|
||||||
? `${lp.url}/v1`
|
|
||||||
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
|
|
||||||
|
|
||||||
piConfig.providers[providerId] = {
|
|
||||||
baseUrl,
|
|
||||||
apiKey: lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none',
|
|
||||||
api: 'openai-completions',
|
|
||||||
models: models.map(m => ({
|
|
||||||
id: m.id,
|
|
||||||
name: m.name || m.id,
|
|
||||||
reasoning: isReasoningModel(m.id),
|
|
||||||
input: ['text'],
|
|
||||||
contextWindow: m.contextWindow || 128000,
|
|
||||||
maxTokens: m.maxTokens || 4096,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('Added local provider to Pi config', { providerId, providerName: lp.name, modelCount: models.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
await Bun.write(PI_MODELS_FILE, JSON.stringify(piConfig, null, 2));
|
|
||||||
invalidateModelCache();
|
|
||||||
logger.info('Successfully synced local providers to Pi config');
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to sync local providers to Pi config', { error: String(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detect reasoning-capable models by name patterns */
|
|
||||||
const REASONING_PATTERNS = [
|
|
||||||
/\bqwen3\b/i,
|
|
||||||
/\bqwq\b/i,
|
|
||||||
/\bdeepseek-r1\b/i,
|
|
||||||
/\br1\b/i,
|
|
||||||
/\breasoning\b/i,
|
|
||||||
/\bthink/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isReasoningModel(modelId: string): boolean {
|
|
||||||
return REASONING_PATTERNS.some(p => p.test(modelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchModelsFromLocalProvider(
|
|
||||||
lp: LocalProvider,
|
|
||||||
): Promise<{ id: string; name?: string; contextWindow?: number; maxTokens?: number }[]> {
|
|
||||||
try {
|
|
||||||
const base = lp.url.replace(/\/+$/, '');
|
|
||||||
const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models';
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (lp.auth?.type === 'api-key') {
|
|
||||||
headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
|
|
||||||
} else if (lp.auth?.type === 'basic') {
|
|
||||||
headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.error('Failed to fetch models from local provider', { provider: lp.name, status: res.status });
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (lp.apiType === 'ollama' && data.models) {
|
|
||||||
return data.models.map((m: any) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
|
|
||||||
} else if (data.data) {
|
|
||||||
return data.data.map((m: any) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to fetch models from local provider', { provider: lp.name, error: String(err) });
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { join } from 'node:path';
|
|
||||||
import { mkdir, copyFile, chmod } from 'node:fs/promises';
|
|
||||||
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
|
|
||||||
import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono';
|
|
||||||
import { getUsers } from 'officerdb';
|
|
||||||
|
|
||||||
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
|
||||||
const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json');
|
|
||||||
|
|
||||||
type PiModelConfig = {
|
|
||||||
providers: Record<string, {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
api: string;
|
|
||||||
models: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
input: string[];
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}[];
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BuildFilteredConfigParams = {
|
|
||||||
appConfig: PiModelConfig;
|
|
||||||
allowedModels: string[];
|
|
||||||
apiKeys: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps lowercase provider names to their env var names.
|
|
||||||
* e.g. "openai" → ["OPENAI_API_KEY"], "google" → ["GOOGLE_API_KEY", "GEMINI_API_KEY"]
|
|
||||||
*/
|
|
||||||
function buildProviderEnvMap(): Record<string, string[]> {
|
|
||||||
const map: Record<string, string[]> = {};
|
|
||||||
for (const p of PROVIDERS) {
|
|
||||||
map[p.key.toLowerCase()] = p.env;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a filtered models.json config with API keys embedded.
|
|
||||||
* - If allowedModels is empty, all models pass through (same convention as enabledModels).
|
|
||||||
* - Parse policy key: "anthropic:anthropic/claude-sonnet-4" → provider "anthropic", model id "claude-sonnet-4"
|
|
||||||
* - Local providers (officer-local-*) pass through unchanged with keys already embedded.
|
|
||||||
*/
|
|
||||||
function buildFilteredConfig({ appConfig, allowedModels, apiKeys }: BuildFilteredConfigParams): PiModelConfig {
|
|
||||||
const envMap = buildProviderEnvMap();
|
|
||||||
const hasPolicy = allowedModels.length > 0;
|
|
||||||
|
|
||||||
// Pre-parse policy into a map: provider → Set<modelId>
|
|
||||||
const policyMap = new Map<string, Set<string>>();
|
|
||||||
if (hasPolicy) {
|
|
||||||
for (const key of allowedModels) {
|
|
||||||
const colonIdx = key.indexOf(':');
|
|
||||||
if (colonIdx === -1) continue;
|
|
||||||
const provider = key.slice(0, colonIdx);
|
|
||||||
const fullModelId = key.slice(colonIdx + 1);
|
|
||||||
// Strip "provider/" prefix to get the models.json model id
|
|
||||||
const slashIdx = fullModelId.indexOf('/');
|
|
||||||
const modelId = slashIdx !== -1 ? fullModelId.slice(slashIdx + 1) : fullModelId;
|
|
||||||
|
|
||||||
if (!policyMap.has(provider)) policyMap.set(provider, new Set());
|
|
||||||
policyMap.get(provider)!.add(modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: PiModelConfig = { providers: {} };
|
|
||||||
|
|
||||||
for (const [providerId, providerConfig] of Object.entries(appConfig.providers)) {
|
|
||||||
// Local providers pass through unchanged
|
|
||||||
if (providerId.startsWith('officer-local-')) {
|
|
||||||
result.providers[providerId] = providerConfig;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerLower = providerId.toLowerCase();
|
|
||||||
|
|
||||||
// Filter models if policy is set
|
|
||||||
let models = providerConfig.models;
|
|
||||||
if (hasPolicy) {
|
|
||||||
const allowedSet = policyMap.get(providerLower);
|
|
||||||
if (!allowedSet) continue; // provider not in policy at all
|
|
||||||
models = models.filter((m) => allowedSet.has(m.id));
|
|
||||||
if (models.length === 0) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed API key
|
|
||||||
let apiKey = providerConfig.apiKey;
|
|
||||||
const envVars = envMap[providerLower];
|
|
||||||
if (envVars) {
|
|
||||||
for (const envVar of envVars) {
|
|
||||||
const value = apiKeys[envVar];
|
|
||||||
if (value?.trim()) {
|
|
||||||
apiKey = value.trim();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.providers[providerId] = { ...providerConfig, models, apiKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readAppModelsConfig(): Promise<PiModelConfig> {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(PI_MODELS_FILE);
|
|
||||||
if (!(await file.exists())) return { providers: {} };
|
|
||||||
return (await file.json()) as PiModelConfig;
|
|
||||||
} catch {
|
|
||||||
return { providers: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncUserPiConfig(email: string): Promise<void> {
|
|
||||||
const [appConfig, policy, apiKeys] = await Promise.all([
|
|
||||||
readAppModelsConfig(),
|
|
||||||
readAccessPolicy(),
|
|
||||||
readApiKeys(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filtered = buildFilteredConfig({
|
|
||||||
appConfig,
|
|
||||||
allowedModels: policy.allowedModels,
|
|
||||||
apiKeys,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userDir = getUserPiConfigDir(email);
|
|
||||||
const sessionsDir = join(userDir, 'sessions');
|
|
||||||
await mkdir(sessionsDir, { recursive: true });
|
|
||||||
await chmod(userDir, 0o777).catch(() => {});
|
|
||||||
await chmod(sessionsDir, 0o777).catch(() => {});
|
|
||||||
await Bun.write(join(userDir, 'models.json'), JSON.stringify(filtered, null, 2));
|
|
||||||
|
|
||||||
// Copy settings.json from app-level Pi config
|
|
||||||
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncAllUserPiConfigs(): Promise<void> {
|
|
||||||
const users = await getUsers();
|
|
||||||
if (users.length === 0) return;
|
|
||||||
|
|
||||||
const [appConfig, policy, apiKeys] = await Promise.all([
|
|
||||||
readAppModelsConfig(),
|
|
||||||
readAccessPolicy(),
|
|
||||||
readApiKeys(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filtered = buildFilteredConfig({
|
|
||||||
appConfig,
|
|
||||||
allowedModels: policy.allowedModels,
|
|
||||||
apiKeys,
|
|
||||||
});
|
|
||||||
|
|
||||||
const configJson = JSON.stringify(filtered, null, 2);
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
try {
|
|
||||||
const userDir = getUserPiConfigDir(user.email);
|
|
||||||
await mkdir(userDir, { recursive: true });
|
|
||||||
await Bun.write(join(userDir, 'models.json'), configJson);
|
|
||||||
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[sync] Failed to sync Pi config for ${user.email}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[bootstrap] Synced Pi config for ${users.length} user(s)`);
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,7 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|||||||
|
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR } from '@@/data-path';
|
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
|
||||||
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
|
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
|
||||||
|
|
||||||
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||||
@@ -191,6 +190,7 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
|||||||
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
|
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
|
||||||
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
||||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||||
|
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
|
||||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||||
...googleMounts,
|
...googleMounts,
|
||||||
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
||||||
@@ -349,9 +349,6 @@ export const initTerminalSidecars = async () => {
|
|||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
mkdirSync(homeDir, { recursive: true });
|
mkdirSync(homeDir, { recursive: true });
|
||||||
try {
|
try {
|
||||||
await syncUserPiConfig(user.email).catch((err) => {
|
|
||||||
console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err);
|
|
||||||
});
|
|
||||||
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
|
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
|
||||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { mkdirSync, existsSync, copyFileSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { DATA_PATH } from './data-path';
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
|
||||||
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
|
||||||
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
|
||||||
import { syncSeedSkills } from './sync-skills';
|
import { syncSeedSkills } from './sync-skills';
|
||||||
import { syncSeedTools } from './sync-tools';
|
import { syncSeedTools } from './sync-tools';
|
||||||
import { syncSeedExtensions } from './sync-extensions';
|
import { syncSeedExtensions } from './sync-extensions';
|
||||||
@@ -13,7 +9,6 @@ import { generateResourceSkill } from './api/pi/pi-bridge';
|
|||||||
import { initQueue } from './queue';
|
import { initQueue } from './queue';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
|
||||||
|
|
||||||
async function ensurePiInstalled(): Promise<boolean> {
|
async function ensurePiInstalled(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -46,22 +41,6 @@ async function installPi(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedPiConfig(): void {
|
|
||||||
const hostPiDir = join(homedir(), '.pi', 'agent');
|
|
||||||
const filesToCopy = ['models.json', 'settings.json'];
|
|
||||||
|
|
||||||
for (const file of filesToCopy) {
|
|
||||||
const target = join(PI_CONFIG_DIR, file);
|
|
||||||
if (existsSync(target)) continue;
|
|
||||||
|
|
||||||
const source = join(hostPiDir, file);
|
|
||||||
if (existsSync(source)) {
|
|
||||||
copyFileSync(source, target);
|
|
||||||
console.log(`[bootstrap] Seeded ${file} from host Pi config`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const installed = await ensurePiInstalled();
|
const installed = await ensurePiInstalled();
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
@@ -72,7 +51,6 @@ function seedPiConfig(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seedPiConfig();
|
|
||||||
syncSeedSkills();
|
syncSeedSkills();
|
||||||
syncSeedTools();
|
syncSeedTools();
|
||||||
syncSeedExtensions();
|
syncSeedExtensions();
|
||||||
@@ -80,14 +58,6 @@ function seedPiConfig(): void {
|
|||||||
await migrateSettingsToResources();
|
await migrateSettingsToResources();
|
||||||
generateResourceSkill(DATA_PATH);
|
generateResourceSkill(DATA_PATH);
|
||||||
|
|
||||||
await syncLocalProvidersToPiConfig().catch(err => {
|
|
||||||
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await syncAllUserPiConfigs().catch(err => {
|
|
||||||
console.error('[bootstrap] Failed to sync user Pi configs:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
await initQueue().catch(err => {
|
await initQueue().catch(err => {
|
||||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
|
|
||||||
export const SERVER_CONFIG_DIR = join(DATA_PATH, 'server-settings');
|
export const SERVER_CONFIG_DIR = join(DATA_PATH, 'server-settings');
|
||||||
|
|
||||||
export const PI_CONFIG_DIR = join(DATA_PATH, 'pi-config');
|
export const PI_CONFIG_DIR = join(homedir(), '.pi', 'agent');
|
||||||
|
|
||||||
export const SEED_PATH = resolve(import.meta.dir, '../../seed');
|
export const SEED_PATH = resolve(import.meta.dir, '../../seed');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user