feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -165,6 +165,7 @@ export async function createAgentSessionServices(
|
||||
}
|
||||
}
|
||||
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||
await modelRuntime.refresh({ allowNetwork: false });
|
||||
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
|
||||
|
||||
return {
|
||||
|
||||
@@ -419,7 +419,7 @@ export class AgentSession {
|
||||
throw new Error(formatNoApiKeyFoundMessage(model.provider));
|
||||
}
|
||||
|
||||
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
|
||||
private async _getSummarizationRequestAuth(model: Model<any>): Promise<{
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
@@ -1779,7 +1779,7 @@ export class AgentSession {
|
||||
throw new Error(formatNoModelSelectedMessage());
|
||||
}
|
||||
|
||||
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
|
||||
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
const settings = this.settingsManager.getCompactionSettings();
|
||||
@@ -2045,7 +2045,7 @@ export class AgentSession {
|
||||
headers = withoutDeletedHeaders(authResult.auth.headers);
|
||||
env = authResult.env;
|
||||
} else {
|
||||
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
|
||||
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
|
||||
}
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
@@ -2914,7 +2914,7 @@ export class AgentSession {
|
||||
let summaryDetails: unknown;
|
||||
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
||||
const model = this.model!;
|
||||
const { apiKey, headers, env } = await this._getRequiredRequestAuth(model);
|
||||
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
|
||||
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
||||
const result = await generateBranchSummary(entriesToSummarize, {
|
||||
model,
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface GenerateBranchSummaryOptions {
|
||||
/** Model to use for summarization */
|
||||
model: Model<any>;
|
||||
/** API key for the model */
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
/** Request headers for the model */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider-scoped environment values for the model */
|
||||
|
||||
@@ -78,6 +78,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [
|
||||
"app.tools.expand",
|
||||
"app.thinking.toggle",
|
||||
"app.editor.external",
|
||||
"app.message.copy",
|
||||
"app.message.followUp",
|
||||
"tui.input.submit",
|
||||
"tui.select.confirm",
|
||||
@@ -627,6 +628,11 @@ export class ExtensionRunner {
|
||||
this.shutdownHandler();
|
||||
}
|
||||
|
||||
getActiveTools(): string[] {
|
||||
this.assertActive();
|
||||
return this.runtime.getActiveTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtensionContext for use in event handlers and tool execution.
|
||||
* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
|
||||
|
||||
@@ -1432,6 +1432,8 @@ export interface ProviderConfig {
|
||||
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
||||
/** Convert credentials to API key string for the provider. */
|
||||
getApiKey(credentials: OAuthCredentials): string;
|
||||
/** Legacy synchronous credential-dependent model projection. */
|
||||
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts";
|
||||
import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts";
|
||||
import type { ExtensionRunner } from "./runner.ts";
|
||||
import type { RegisteredTool } from "./types.ts";
|
||||
|
||||
@@ -15,7 +15,25 @@ import type { RegisteredTool } from "./types.ts";
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
|
||||
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
const tool = wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
const execute = tool.execute;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const activeBefore = runner.getActiveTools();
|
||||
const result = await execute(toolCallId, params, signal, onUpdate);
|
||||
const activeAfter = runner.getActiveTools();
|
||||
if (!activeBefore.every((name) => activeAfter.includes(name))) return result;
|
||||
|
||||
const beforeNames = new Set(activeBefore);
|
||||
const addedToolNames = activeAfter.filter((name) => !beforeNames.has(name));
|
||||
if (addedToolNames.length === 0) return result;
|
||||
return {
|
||||
...result,
|
||||
addedToolNames: [...new Set([...(result.addedToolNames ?? []), ...addedToolNames])],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,8 +41,5 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: Exten
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] {
|
||||
return wrapToolDefinitions(
|
||||
registeredTools.map((registeredTool) => registeredTool.definition),
|
||||
() => runner.createContext(),
|
||||
);
|
||||
return registeredTools.map((tool) => wrapRegisteredTool(tool, runner));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AppKeybindings {
|
||||
"app.thinking.toggle": true;
|
||||
"app.session.toggleNamedFilter": true;
|
||||
"app.editor.external": true;
|
||||
"app.message.copy": true;
|
||||
"app.message.followUp": true;
|
||||
"app.message.dequeue": true;
|
||||
"app.clipboard.pasteImage": true;
|
||||
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
|
||||
defaultKeys: "ctrl+g",
|
||||
description: "Open external editor",
|
||||
},
|
||||
"app.message.copy": {
|
||||
defaultKeys: "ctrl+x",
|
||||
description: "Copy message to clipboard",
|
||||
},
|
||||
"app.message.followUp": {
|
||||
defaultKeys: "alt+enter",
|
||||
description: "Queue follow-up message",
|
||||
@@ -105,18 +110,18 @@ export const KEYBINDINGS = {
|
||||
},
|
||||
"app.clipboard.pasteImage": {
|
||||
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
||||
description: "Paste image from clipboard",
|
||||
description: "Paste image from clipboard (text fallback)",
|
||||
},
|
||||
"app.session.new": { defaultKeys: [], description: "Start a new session" },
|
||||
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
||||
"app.session.fork": { defaultKeys: [], description: "Fork current session" },
|
||||
"app.session.resume": { defaultKeys: [], description: "Resume a session" },
|
||||
"app.tree.foldOrUp": {
|
||||
defaultKeys: ["ctrl+left", "alt+left"],
|
||||
defaultKeys: process.platform === "darwin" ? ["alt+left", "ctrl+left"] : ["ctrl+left", "alt+left"],
|
||||
description: "Fold tree branch or move up",
|
||||
},
|
||||
"app.tree.unfoldOrDown": {
|
||||
defaultKeys: ["ctrl+right", "alt+right"],
|
||||
defaultKeys: process.platform === "darwin" ? ["alt+right", "ctrl+right"] : ["ctrl+right", "alt+right"],
|
||||
description: "Unfold tree branch or move down",
|
||||
},
|
||||
"app.tree.editLabel": {
|
||||
|
||||
@@ -98,13 +98,20 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
sessionAffinityFormat: Type.Optional(
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const OpenAIResponsesCompatSchema = Type.Object({
|
||||
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
||||
sendSessionIdHeader: Type.Optional(Type.Boolean()),
|
||||
sessionAffinityFormat: Type.Optional(
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
supportsToolSearch: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const AnthropicMessagesCompatSchema = Type.Object({
|
||||
@@ -113,6 +120,7 @@ const AnthropicMessagesCompatSchema = Type.Object({
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
|
||||
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
|
||||
supportsToolReferences: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const ProviderCompatSchema = Type.Union([
|
||||
@@ -176,6 +184,7 @@ const ProviderConfigSchema = Type.Object({
|
||||
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
||||
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
||||
api: Type.Optional(Type.String({ minLength: 1 })),
|
||||
oauth: Type.Optional(Type.Literal("radius")),
|
||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
compat: Type.Optional(ProviderCompatSchema),
|
||||
authHeader: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
openai: "gpt-5.5",
|
||||
"azure-openai-responses": "gpt-5.4",
|
||||
"openai-codex": "gpt-5.5",
|
||||
radius: "auto",
|
||||
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
||||
deepseek: "deepseek-v4-pro",
|
||||
google: "gemini-3.1-pro-preview",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
type Api,
|
||||
type ApiStreamOptions,
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type Models,
|
||||
type ModelsApiStreamOptions,
|
||||
ModelsError,
|
||||
type ModelsRefreshOptions,
|
||||
type ModelsRefreshResult,
|
||||
type ModelsSimpleStreamOptions,
|
||||
type ModelsStore,
|
||||
type ModelsStreamTransforms,
|
||||
type MutableModels,
|
||||
type Provider,
|
||||
@@ -26,10 +29,11 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
type StreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
||||
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
|
||||
import { ModelConfig } from "./model-config.ts";
|
||||
import { FileModelsStore, InMemoryCodingAgentModelsStore } from "./models-store.ts";
|
||||
import {
|
||||
type AuthStatus,
|
||||
type CompatibilityRequestConfig,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
resolveConfiguredModelHeaders,
|
||||
validateExtensionProvider,
|
||||
} from "./provider-composer.ts";
|
||||
import { withRemoteCatalog } from "./remote-catalog-provider.ts";
|
||||
import { RuntimeCredentials } from "./runtime-credentials.ts";
|
||||
|
||||
interface ModelRuntimeSnapshot {
|
||||
@@ -55,6 +60,11 @@ export interface CreateModelRuntimeOptions {
|
||||
credentials?: CredentialStore;
|
||||
authPath?: string;
|
||||
modelsPath?: string | null;
|
||||
modelsStore?: ModelsStore;
|
||||
modelsStorePath?: string;
|
||||
allowModelNetwork?: boolean;
|
||||
modelRefreshTimeoutMs?: number;
|
||||
catalogBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ModelRuntimeAuthOverrides {
|
||||
@@ -82,10 +92,12 @@ function mergeHeaders(
|
||||
export class ModelRuntime implements Models {
|
||||
private readonly models: MutableModels;
|
||||
private readonly credentials: RuntimeCredentials;
|
||||
private readonly builtins: ReadonlyMap<string, Provider>;
|
||||
private readonly defaultBuiltins: ReadonlyMap<string, Provider>;
|
||||
private readonly builtins = new Map<string, Provider>();
|
||||
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
|
||||
private readonly compositionErrors = new Map<string, string>();
|
||||
private readonly modelsPath: string | undefined;
|
||||
private readonly allowModelNetwork: boolean;
|
||||
private config: ModelConfig;
|
||||
private snapshot: ModelRuntimeSnapshot = {
|
||||
all: [],
|
||||
@@ -101,13 +113,17 @@ export class ModelRuntime implements Models {
|
||||
credentials: RuntimeCredentials,
|
||||
config: ModelConfig,
|
||||
modelsPath: string | undefined,
|
||||
modelsStore: ModelsStore,
|
||||
providers: readonly Provider[],
|
||||
allowModelNetwork: boolean,
|
||||
) {
|
||||
this.credentials = credentials;
|
||||
this.config = config;
|
||||
this.modelsPath = modelsPath;
|
||||
this.builtins = new Map(providers.map((provider) => [provider.id, provider]));
|
||||
this.models = createModels({ credentials });
|
||||
this.allowModelNetwork = allowModelNetwork;
|
||||
this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
|
||||
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
||||
this.models = createModels({ credentials, modelsStore });
|
||||
this.rebuildProviders();
|
||||
}
|
||||
|
||||
@@ -116,11 +132,55 @@ export class ModelRuntime implements Models {
|
||||
const modelsPath =
|
||||
options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
|
||||
const config = await ModelConfig.load(modelsPath);
|
||||
const runtime = new ModelRuntime(credentials, config, modelsPath, builtinProviders());
|
||||
await runtime.refreshAvailability();
|
||||
const modelsStore =
|
||||
options.modelsStore ??
|
||||
(modelsPath
|
||||
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
|
||||
: new InMemoryCodingAgentModelsStore());
|
||||
const providers = builtinProviderCatalog
|
||||
.builtinProviders()
|
||||
.map((provider) =>
|
||||
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
|
||||
);
|
||||
const runtime = new ModelRuntime(
|
||||
credentials,
|
||||
config,
|
||||
modelsPath,
|
||||
modelsStore,
|
||||
providers,
|
||||
options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined,
|
||||
);
|
||||
runtime.configureRadiusProviders();
|
||||
runtime.rebuildProviders();
|
||||
const controller = new AbortController();
|
||||
const timeout = runtime.allowModelNetwork
|
||||
? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000)
|
||||
: undefined;
|
||||
try {
|
||||
await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal });
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private configureRadiusProviders(): void {
|
||||
this.builtins.clear();
|
||||
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
||||
for (const providerId of this.config.getProviderIds()) {
|
||||
const config = this.config.getProvider(providerId);
|
||||
if (config?.oauth !== "radius" || !config.baseUrl) continue;
|
||||
this.builtins.set(
|
||||
providerId,
|
||||
builtinProviderCatalog.radiusProvider({
|
||||
id: providerId,
|
||||
name: config.name ?? providerId,
|
||||
gateway: config.baseUrl.replace(/\/v1\/?$/u, ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private providerIds(): Set<string> {
|
||||
return new Set([...this.builtins.keys(), ...this.config.getProviderIds(), ...this.extensionProviders.keys()]);
|
||||
}
|
||||
@@ -319,7 +379,7 @@ export class ModelRuntime implements Models {
|
||||
};
|
||||
}
|
||||
|
||||
setRuntimeApiKey(providerId: string, apiKey: string): void {
|
||||
async setRuntimeApiKey(providerId: string, apiKey: string): Promise<void> {
|
||||
this.credentials.setRuntimeApiKey(providerId, apiKey);
|
||||
const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" });
|
||||
const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
|
||||
@@ -331,12 +391,12 @@ export class ModelRuntime implements Models {
|
||||
storedProviders,
|
||||
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
|
||||
};
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
removeRuntimeApiKey(providerId: string): void {
|
||||
async removeRuntimeApiKey(providerId: string): Promise<void> {
|
||||
this.credentials.removeRuntimeApiKey(providerId);
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
listCredentials(): Promise<readonly CredentialInfo[]> {
|
||||
@@ -422,25 +482,38 @@ export class ModelRuntime implements Models {
|
||||
|
||||
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
|
||||
const credential = await this.models.login(providerId, type, interaction);
|
||||
await this.forceRefreshAvailability();
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
return credential;
|
||||
}
|
||||
|
||||
async logout(providerId: string): Promise<void> {
|
||||
await this.models.logout(providerId);
|
||||
await this.forceRefreshAvailability();
|
||||
// Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh.
|
||||
this.recomposeProvider(providerId);
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
async reloadConfig(): Promise<void> {
|
||||
this.config = await ModelConfig.load(this.modelsPath);
|
||||
this.configureRadiusProviders();
|
||||
this.rebuildProviders();
|
||||
await this.forceRefreshAvailability();
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
async refresh(providerId?: string): Promise<void> {
|
||||
await this.models.refresh(providerId);
|
||||
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
||||
// Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
|
||||
// The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
|
||||
const result = ((await this.models.refresh(options)) as ModelsRefreshResult | undefined) ?? {
|
||||
aborted: options.signal?.aborted ?? false,
|
||||
errors: new Map(),
|
||||
};
|
||||
this.updateModelSnapshot();
|
||||
await this.forceRefreshAvailability();
|
||||
try {
|
||||
await this.forceRefreshAvailability();
|
||||
} catch {
|
||||
// Availability errors are recorded by forceRefreshAvailability; refreshed models remain usable.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
registerProvider(providerId: string, config: ProviderConfigInput): void {
|
||||
@@ -477,13 +550,13 @@ export class ModelRuntime implements Models {
|
||||
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
|
||||
};
|
||||
}
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
void this.refresh({ allowNetwork: false });
|
||||
}
|
||||
|
||||
unregisterProvider(providerId: string): void {
|
||||
this.extensionProviders.delete(providerId);
|
||||
this.recomposeProvider(providerId);
|
||||
this.updateModelSnapshot();
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
void this.refresh({ allowNetwork: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join } from "node:path";
|
||||
import type { Api, Model, ModelsStore } from "@earendil-works/pi-ai";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
|
||||
|
||||
type StoredModels = Record<string, Model<Api>[]>;
|
||||
|
||||
export class InMemoryCodingAgentModelsStore implements ModelsStore {
|
||||
private readonly models = new Map<string, readonly Model<Api>[]>();
|
||||
|
||||
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
|
||||
return this.models.get(providerId);
|
||||
}
|
||||
|
||||
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
|
||||
this.models.set(providerId, models);
|
||||
}
|
||||
|
||||
async delete(providerId: string): Promise<void> {
|
||||
this.models.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locked JSON-backed storage for dynamically refreshed provider catalogs. */
|
||||
export class FileModelsStore implements ModelsStore {
|
||||
private readonly storage: AuthStorageBackend;
|
||||
|
||||
constructor(path: string = join(getAgentDir(), "models-store.json")) {
|
||||
this.storage = new FileAuthStorageBackend(path);
|
||||
}
|
||||
|
||||
private parse(content: string | undefined): StoredModels {
|
||||
return content ? (JSON.parse(content) as StoredModels) : {};
|
||||
}
|
||||
|
||||
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
|
||||
return this.storage.withLock((content) => ({
|
||||
result: this.parse(content)[providerId]?.map((model) => structuredClone(model)),
|
||||
}));
|
||||
}
|
||||
|
||||
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
|
||||
await this.storage.withLockAsync(async (content) => {
|
||||
const current = this.parse(content);
|
||||
current[providerId] = models.map((model) => structuredClone(model));
|
||||
return { result: undefined, next: JSON.stringify(current, null, 2) };
|
||||
});
|
||||
}
|
||||
|
||||
async delete(providerId: string): Promise<void> {
|
||||
await this.storage.withLockAsync(async (content) => {
|
||||
const current = this.parse(content);
|
||||
delete current[providerId];
|
||||
return { result: undefined, next: JSON.stringify(current, null, 2) };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1805,11 +1805,16 @@ export class DefaultPackageManager implements PackageManager {
|
||||
if (!existsSync(installRoot)) {
|
||||
return;
|
||||
}
|
||||
if (this.getPackageManagerName() === "bun") {
|
||||
const packageManagerName = this.getPackageManagerName();
|
||||
if (packageManagerName === "bun") {
|
||||
await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);
|
||||
return;
|
||||
}
|
||||
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
|
||||
const args = ["uninstall", source.name, "--prefix", installRoot];
|
||||
if (packageManagerName !== "pnpm") {
|
||||
args.push("--legacy-peer-deps");
|
||||
}
|
||||
await this.runNpmCommand(args);
|
||||
}
|
||||
|
||||
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ExtensionOAuthConfig {
|
||||
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
|
||||
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
||||
getApiKey(credentials: OAuthCredentials): string;
|
||||
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
|
||||
}
|
||||
|
||||
/** Input type for the extension registerProvider API. */
|
||||
@@ -161,6 +162,9 @@ function applyModelsJson(
|
||||
config: ModelsJsonProvider | undefined,
|
||||
): Model<Api>[] {
|
||||
if (!config) return [...baseModels];
|
||||
if (config.oauth && !config.baseUrl) {
|
||||
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
|
||||
}
|
||||
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
|
||||
if (
|
||||
!config.models?.length &&
|
||||
@@ -169,6 +173,7 @@ function applyModelsJson(
|
||||
!config.compat &&
|
||||
!hasOverrides &&
|
||||
!config.apiKey &&
|
||||
!config.oauth &&
|
||||
config.authHeader === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -178,7 +183,7 @@ function applyModelsJson(
|
||||
|
||||
const models: Model<Api>[] = baseModels.map((model) => ({
|
||||
...model,
|
||||
baseUrl: config.baseUrl ?? model.baseUrl,
|
||||
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
|
||||
compat: mergeCompat(model.compat, config.compat),
|
||||
}));
|
||||
for (const definition of config.models ?? []) {
|
||||
@@ -409,15 +414,19 @@ export function composeModelProvider(
|
||||
extension: ProviderConfigInput | undefined,
|
||||
): Provider {
|
||||
const config = modelConfig.getProvider(providerId);
|
||||
let extensionOAuthCredential: OAuthCredentials | undefined;
|
||||
// models.json modelOverrides are the topmost user-config layer: they apply once,
|
||||
// after custom-model upserts and extension model replacement.
|
||||
const getModels = () =>
|
||||
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension).map(
|
||||
(model) => {
|
||||
const override = config?.modelOverrides?.[model.id];
|
||||
return override ? applyModelOverride(model, override) : model;
|
||||
},
|
||||
);
|
||||
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
|
||||
const getModels = () => {
|
||||
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension);
|
||||
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
|
||||
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
|
||||
}
|
||||
return models.map((model) => {
|
||||
const override = config?.modelOverrides?.[model.id];
|
||||
return override ? applyModelOverride(model, override) : model;
|
||||
});
|
||||
};
|
||||
// Validate eagerly so registration/reload reports structural errors immediately.
|
||||
getModels();
|
||||
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
|
||||
@@ -454,7 +463,13 @@ export function composeModelProvider(
|
||||
headers: base?.headers,
|
||||
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
|
||||
getModels,
|
||||
refreshModels: base?.refreshModels ? () => base.refreshModels!() : undefined,
|
||||
refreshModels:
|
||||
base?.refreshModels || extension?.oauth?.modifyModels
|
||||
? async (context) => {
|
||||
await base?.refreshModels?.(context);
|
||||
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
|
||||
}
|
||||
: undefined,
|
||||
filterModels: base?.filterModels
|
||||
? (models, credential: Credential | undefined) => base.filterModels!(models, credential)
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const RADIUS_PROVIDER_ID = "radius";
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
|
||||
|
||||
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
|
||||
|
||||
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
|
||||
const merged = [...baseline];
|
||||
for (const model of dynamic) {
|
||||
const index = merged.findIndex((entry) => entry.id === model.id);
|
||||
if (index >= 0) merged[index] = model;
|
||||
else merged.push(model);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
|
||||
const entries = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
|
||||
? value.models
|
||||
: undefined;
|
||||
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
|
||||
return entries
|
||||
.filter((entry): entry is Model<Api> => typeof entry === "object" && entry !== null && "id" in entry)
|
||||
.map((model) => ({ ...model, provider: providerId }));
|
||||
}
|
||||
|
||||
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
|
||||
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
|
||||
let dynamicModels: readonly Model<Api>[] = [];
|
||||
let inflightRefresh: Promise<void> | undefined;
|
||||
|
||||
return {
|
||||
...provider,
|
||||
getModels: () => mergeModels(provider.getModels(), dynamicModels),
|
||||
refreshModels: (context) => {
|
||||
inflightRefresh ??= (async () => {
|
||||
try {
|
||||
const stored = await context.store.read();
|
||||
if (stored) dynamicModels = stored.filter((model) => model.provider === provider.id);
|
||||
if (!context.allowNetwork || context.signal?.aborted) return;
|
||||
|
||||
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: context.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
|
||||
}
|
||||
const refreshed = parseCatalog(provider.id, await response.json());
|
||||
if (context.signal?.aborted) return;
|
||||
dynamicModels = refreshed;
|
||||
await context.store.write(refreshed);
|
||||
} finally {
|
||||
inflightRefresh = undefined;
|
||||
}
|
||||
})();
|
||||
return inflightRefresh;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,14 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
contextFiles: providedContextFiles,
|
||||
skills: providedSkills,
|
||||
} = options;
|
||||
const resolvedCwd = cwd;
|
||||
const promptCwd = resolvedCwd.replace(/\\/g, "/");
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const date = `${year}-${month}-${day}`;
|
||||
const promptCwd = cwd.replace(/\\/g, "/");
|
||||
|
||||
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
|
||||
|
||||
@@ -73,8 +66,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date and working directory last
|
||||
prompt += `\nCurrent date: ${date}`;
|
||||
prompt += `\nCurrent working directory: ${promptCwd}`;
|
||||
|
||||
return prompt;
|
||||
@@ -165,8 +156,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date and working directory last
|
||||
prompt += `\nCurrent date: ${date}`;
|
||||
prompt += `\nCurrent working directory: ${promptCwd}`;
|
||||
|
||||
return prompt;
|
||||
|
||||
Reference in New Issue
Block a user