Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,8 @@
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vitest": "^3.0.0"
},
"dependencies": {
"@dutifuldev/pi-factory": "^0.1.1"
}
}
19 changes: 8 additions & 11 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { runPiApp } from "@dutifuldev/pi-factory";

import { errorMessage, fail, ok, type CommandResult } from "../common/result.js";
import { parseLocalpiArgs, usage } from "../localpi/options.js";
import {
Expand All @@ -8,9 +10,8 @@ import {
stopRuntime
} from "../localpi/runtime.js";
import { applyRememberedSettings } from "../localpi/settings-state.js";
import { writeRuntimeConfig } from "../pi/config.js";
import { createLocalpiAppDefinition } from "../pi/app.js";
import { writeDefaultExtensions } from "../pi/extensions.js";
import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js";

export async function run(args: readonly string[]): Promise<CommandResult> {
try {
Expand All @@ -29,13 +30,13 @@ export async function run(args: readonly string[]): Promise<CommandResult> {
});

const connection = await resolveRuntime(options);
const runtimeConfig = await writeRuntimeConfig(options, connection);
const selectorOptions = startupModelSelectorOptions(options, connection);
const extensions = await writeDefaultExtensions(
options,
selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions }
);
return await launchResolvedRuntime(options, runtimeConfig, connection, extensions);
const app = createLocalpiAppDefinition(options, connection, extensions);
return await launchResolvedRuntime(app, connection);
} catch (error) {
return fail(`localpi: ${errorMessage(error)}`);
}
Expand Down Expand Up @@ -298,14 +299,10 @@ function isPiUnknownLongFlagNextValue(arg: string | undefined): boolean {
}

async function launchResolvedRuntime(
options: ParsedOptions,
runtimeConfig: Awaited<ReturnType<typeof writeRuntimeConfig>>,
connection: Awaited<ReturnType<typeof resolveRuntime>>,
extensions: Awaited<ReturnType<typeof writeDefaultExtensions>>
app: ReturnType<typeof createLocalpiAppDefinition>,
connection: Awaited<ReturnType<typeof resolveRuntime>>
): Promise<CommandResult> {
const code = await execLaunchPlan(
await createLaunchPlan(options, runtimeConfig, connection, extensions)
);
const code = await runPiApp(app);
if (code !== 0) {
return { code, stdout: "", stderr: "" };
}
Expand Down
4 changes: 2 additions & 2 deletions src/localpi/settings-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type LocalpiSettings = {
readonly thinking?: LocalpiOptions["thinking"];
};

export function settingsStatePath(options: Pick<LocalpiOptions, "stateDir">): string {
export function localpiSettingsPath(options: Pick<LocalpiOptions, "stateDir">): string {
return path.join(options.stateDir, "settings.json");
}

Expand All @@ -29,7 +29,7 @@ async function readLocalpiSettings(
): Promise<LocalpiSettings> {
let raw: string;
try {
raw = await readFile(settingsStatePath(options), "utf8");
raw = await readFile(localpiSettingsPath(options), "utf8");
} catch (error) {
if (isMissingFile(error)) {
return {};
Expand Down
159 changes: 159 additions & 0 deletions src/pi/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type {
PiAppDefinition,
PiModelDefinition,
PiProviderDefinition
} from "@dutifuldev/pi-factory";

import type { CatalogModel } from "../localpi/catalog.js";
import type { LocalpiOptions } from "../localpi/options.js";
import type { RuntimeConnection } from "../localpi/runtime.js";
import type { ExtensionBundle } from "./extensions.js";
import { localpiVersion } from "./version.js";

type LocalpiAppIdentity = Pick<PiAppDefinition, "id" | "name" | "version">;
type LocalpiAppDirectories = Pick<PiAppDefinition, "stateDir" | "sessionDir">;
type LocalpiPiCommand = Pick<PiAppDefinition, "piCommand" | "forwardedArgs">;
type LocalpiRuntimeSelection = Pick<
PiAppDefinition,
"providers" | "defaultProvider" | "defaultModel" | "thinking"
> &
Partial<Pick<PiAppDefinition, "tools">>;
type LocalpiExtensionConfig = Partial<Pick<PiAppDefinition, "extensions" | "appendSystemPrompts">>;

const localpiAppIdentity: LocalpiAppIdentity = {
id: "localpi",
name: "localpi",
version: localpiVersion
};

export function createLocalpiAppDefinition(
options: LocalpiOptions,
connection: RuntimeConnection,
extensions?: ExtensionBundle
): PiAppDefinition {
return {
...localpiAppIdentity,
...appDirectories(options),
...piCommand(options),
...runtimeSelection(options, connection),
...extensionConfig(extensions)
};
}

function appDirectories(options: LocalpiOptions): LocalpiAppDirectories {
return {
stateDir: options.stateDir,
sessionDir: options.sessionDir
};
}

function piCommand(options: LocalpiOptions): LocalpiPiCommand {
return {
piCommand: options.piCommand,
forwardedArgs: options.forwardedArgs
};
}

function runtimeSelection(
options: LocalpiOptions,
connection: RuntimeConnection
): LocalpiRuntimeSelection {
const selection = {
providers: providersForConnection(options, connection),
defaultProvider: connection.providerId,
defaultModel: connection.model,
thinking: options.thinking
};
return options.tools === undefined ? selection : { ...selection, tools: options.tools };
}

function providersForConnection(
options: LocalpiOptions,
connection: RuntimeConnection
): readonly PiProviderDefinition[] {
const models =
connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels;
return groupedByProvider(models).map((group) => ({
id: group.providerId,
baseUrl: group.baseUrl,
api: "openai-completions",
apiKey: "local",
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false
},
models: group.models.map((model) => modelDefinition(options, model))
}));
}

type ProviderGroup = {
readonly providerId: string;
readonly baseUrl: string;
readonly models: readonly CatalogModel[];
};

function groupedByProvider(models: readonly CatalogModel[]): readonly ProviderGroup[] {
const groups = new Map<string, ProviderGroup>();
for (const model of models) {
const existing = groups.get(model.providerId);
groups.set(
model.providerId,
existing === undefined
? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] }
: { ...existing, models: [...existing.models, model] }
);
}
return [...groups.values()];
}

function modelDefinition(options: LocalpiOptions, model: CatalogModel): PiModelDefinition {
const contextWindow = modelContextWindow(options, model);
return {
id: model.modelId,
name: model.displayName,
reasoning: model.reasoning ?? false,
...(model.thinkingFormat === undefined ? {} : { thinkingFormat: model.thinkingFormat }),
input: ["text"],
...(contextWindow === undefined ? {} : { contextWindow }),
...(model.maxTokens === undefined
? { maxTokens: options.maxTokens }
: { maxTokens: model.maxTokens })
};
}

function modelContextWindow(options: LocalpiOptions, model: CatalogModel): number | undefined {
if (options.contextWindow !== undefined) {
return options.contextWindow;
}
return model.contextWindow;
}

function fallbackCatalog(connection: RuntimeConnection): readonly CatalogModel[] {
return [
{
providerId: connection.providerId,
providerName: connection.providerName,
runtime: connection.runtime.startsWith("llama-server")
? "managed-llama-server"
: "openai-compatible",
baseUrl: connection.baseUrl,
modelId: connection.model,
aliases: [],
displayName: `Local model (${connection.model})`,
reasoning: false,
capabilities: ["text"],
availability: "loaded",
...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow })
}
];
}

function extensionConfig(extensions: ExtensionBundle | undefined): LocalpiExtensionConfig {
if (extensions === undefined) {
return {};
}
return {
extensions: extensions.paths.map((extensionPath) => ({ path: extensionPath })),
appendSystemPrompts: [extensions.systemPrompt]
};
}
Loading