feat: add codex fast mode and harden subagent launch

This commit is contained in:
luckyyzh
2026-08-05 03:24:17 +08:00
parent a196c3a6b1
commit 836042b97c
12 changed files with 5950 additions and 3893 deletions
+16
View File
@@ -80,6 +80,12 @@ npm run dev
所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。 所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。
#### GPT Codex 快速模式
选择 `openai-codex` 的 GPT-5.5 或 GPT-5.6 模型后,输入框底部会显示闪电按钮。开启后按钮显示 `Fast mode ON`,并将该模型的 `serviceTier` 设为 `priority`,请求会使用 Codex 的快速档位;关闭后恢复普通档位。该设置写入 `data/agent/models.json`,下次请求立即生效,无需重启;非 GPT Codex 模型不会显示此按钮。
快速档位会增加费用:GPT-5.6 通常约为 2 倍,GPT-5.5 的 priority 费率约为 2.5 倍。Web UI 会在按钮提示中明确显示 `service_tier: priority` 和费用倍率。
### 可选环境变量 ### 可选环境变量
需要可选服务时,复制模板: 需要可选服务时,复制模板:
@@ -147,6 +153,8 @@ data/workspaces/default/ 默认工作目录
Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。 Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。
Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `pi-web/node_modules/.bin` 加入子进程 PATH,并把 Pi Web 使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。
#### 视觉子代理(vision) #### 视觉子代理(vision)
DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。 DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。
@@ -326,6 +334,12 @@ The repository contains no author model endpoint, API key, OAuth token, or defau
Device-specific model configuration remains in ignored `data/` or `.env` files. Device-specific model configuration remains in ignored `data/` or `.env` files.
#### GPT Codex fast mode
When an `openai-codex` GPT-5.5 or GPT-5.6 model is selected, the lightning button appears beside the model selector. Enabling it shows `Fast mode ON` and sets that model's `serviceTier` to `priority`, using Codex's faster service tier; disabling it restores the default tier. The setting is written to `data/agent/models.json` and takes effect on the next request without a restart. Non-Codex models do not show the button.
Fast mode costs more: GPT-5.6 is typically about 2x, while GPT-5.5 priority pricing is about 2.5x. The Web UI tooltip displays `service_tier: priority` and the applicable credit multiplier.
### Optional environment configuration ### Optional environment configuration
macOS/Linux: macOS/Linux:
@@ -391,6 +405,8 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp.
On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed. On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed.
On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `pi-web/node_modules/.bin` directory to the child PATH and links the Pi Web `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch.
#### Vision subagent #### Vision subagent
Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable (local Ollama or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use. Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable (local Ollama or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use.
+29 -2
View File
@@ -10,15 +10,16 @@ $supervisorLog = Join-Path $logsDirectory 'pi-web-supervisor.log'
$restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json' $restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json'
$webUrl = 'http://127.0.0.1:30141/' $webUrl = 'http://127.0.0.1:30141/'
$restartRequestVersion = 1 $restartRequestVersion = 1
$maxResumeAttempts = 3
New-Item -ItemType Directory -Path $logsDirectory -Force | Out-Null New-Item -ItemType Directory -Path $logsDirectory -Force | Out-Null
New-Item -ItemType Directory -Path (Split-Path -Parent $restartRequestPath) -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path -Parent $restartRequestPath) -Force | Out-Null
Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started" Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started" -Encoding UTF8
function Write-SupervisorLog { function Write-SupervisorLog {
param([string]$Message) param([string]$Message)
Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" -Encoding UTF8
} }
function Get-RestartRequest { function Get-RestartRequest {
@@ -72,6 +73,20 @@ function Remove-RestartRequest {
Remove-Item -LiteralPath $restartRequestPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $restartRequestPath -Force -ErrorAction SilentlyContinue
} }
function Archive-RestartRequest {
param([object]$Request)
if (-not (Test-Path -LiteralPath $restartRequestPath -PathType Leaf)) {
return
}
$requestId = (Get-RequestField $Request 'requestId') -replace '[^A-Za-z0-9-]', '_'
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archivePath = Join-Path $logsDirectory "failed-restart-request-$timestamp-$requestId.json"
Move-Item -LiteralPath $restartRequestPath -Destination $archivePath -Force
Write-SupervisorLog "archived failed restart request at $archivePath"
}
function Stop-ProcessTree { function Stop-ProcessTree {
param([System.Diagnostics.Process]$Process) param([System.Diagnostics.Process]$Process)
@@ -149,6 +164,7 @@ $testInstructions
} }
$pendingRequest = Get-RestartRequest $pendingRequest = Get-RestartRequest
$resumeAttemptCount = 0
while ($true) { while ($true) {
$process = $null $process = $null
@@ -176,18 +192,29 @@ while ($true) {
try { try {
Resume-AgentSession $pendingRequest Resume-AgentSession $pendingRequest
$pendingRequest = $null $pendingRequest = $null
$resumeAttemptCount = 0
} }
catch { catch {
$resumeAttemptCount += 1
Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)" Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)"
if ($resumeAttemptCount -ge $maxResumeAttempts) {
Write-SupervisorLog "Agent resume abandoned after $resumeAttemptCount attempts"
Archive-RestartRequest $pendingRequest
$pendingRequest = $null
$resumeAttemptCount = 0
}
else {
$nextResumeAttempt = (Get-Date).AddSeconds(3) $nextResumeAttempt = (Get-Date).AddSeconds(3)
} }
} }
} }
}
if (-not $pendingRequest) { if (-not $pendingRequest) {
$candidate = Get-RestartRequest $candidate = Get-RestartRequest
if ($candidate) { if ($candidate) {
$pendingRequest = $candidate $pendingRequest = $candidate
$resumeAttemptCount = 0
Write-SupervisorLog "restart request $($candidate.requestId) detected" Write-SupervisorLog "restart request $($candidate.requestId) detected"
Stop-ProcessTree $process Stop-ProcessTree $process
break break
+76 -19
View File
@@ -1,33 +1,62 @@
import { stat } from "fs/promises"; import { stat } from "fs/promises";
import { resolve } from "path"; import { resolve } from "path";
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent"; import {
createAgentSessionServices,
getAgentDir,
type SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache"; import {
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; loadModelsWithCache,
withModelRuntimeError,
type ModelsData,
} from "@/lib/models-cache";
import {
getAllowedFileRoots,
isExistingFilePathAllowed,
} from "@/lib/file-access";
import { projectTrustReloadOptions } from "@/lib/project-trust"; import { projectTrustReloadOptions } from "@/lib/project-trust";
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime"; import {
createAppSettingsManager,
getAppResourceLoaderOptions,
} from "@/lib/app-runtime";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); const modelNameCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
function compareModelEntries( function compareModelEntries(
a: { id: string; name: string; provider: string }, a: { id: string; name: string; provider: string },
b: { id: string; name: string; provider: string } b: { id: string; name: string; provider: string },
): number { ): number {
return modelNameCollator.compare(a.name || a.id, b.name || b.id) return (
|| modelNameCollator.compare(a.provider, b.provider) modelNameCollator.compare(a.name || a.id, b.name || b.id) ||
|| modelNameCollator.compare(a.id, b.id); modelNameCollator.compare(a.provider, b.provider) ||
modelNameCollator.compare(a.id, b.id)
);
} }
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); const THINKING_SUFFIXES = new Set([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
function stripThinkingSuffix(modelRef: string): string { function stripThinkingSuffix(modelRef: string): string {
const trimmed = modelRef.trim(); const trimmed = modelRef.trim();
const colonIndex = trimmed.lastIndexOf(":"); const colonIndex = trimmed.lastIndexOf(":");
if (colonIndex === -1) return trimmed; if (colonIndex === -1) return trimmed;
const suffix = trimmed.substring(colonIndex + 1); const suffix = trimmed.substring(colonIndex + 1);
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed; return THINKING_SUFFIXES.has(suffix)
? trimmed.substring(0, colonIndex)
: trimmed;
} }
function filterByExactEnabledModels<T extends { id: string; provider: string }>( function filterByExactEnabledModels<T extends { id: string; provider: string }>(
@@ -37,7 +66,9 @@ function filterByExactEnabledModels<T extends { id: string; provider: string }>(
if (!enabledModels || enabledModels.length === 0) return available; if (!enabledModels || enabledModels.length === 0) return available;
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean)); const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id)); const visible = available.filter(
(m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id),
);
return visible.length > 0 ? visible : available; return visible.length > 0 ? visible : available;
} }
@@ -58,18 +89,30 @@ async function loadModels(cwd: string): Promise<ModelsData> {
agentDir, agentDir,
settingsManager: createAppSettingsManager(cwd, agentDir), settingsManager: createAppSettingsManager(cwd, agentDir),
resourceLoaderOptions: getAppResourceLoaderOptions(), resourceLoaderOptions: getAppResourceLoaderOptions(),
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}), ...(trustReloadOptions
? { resourceLoaderReloadOptions: trustReloadOptions }
: {}),
}); });
const available = await services.modelRuntime.getAvailable(); const available = await services.modelRuntime.getAvailable();
const modelError = services.modelRuntime.getError(); const modelError = services.modelRuntime.getError();
const settings: SettingsManager = services.settingsManager; const settings: SettingsManager = services.settingsManager;
const enabledModels = settings.getEnabledModels(); const enabledModels = settings.getEnabledModels();
const visible = filterByExactEnabledModels(available, enabledModels); const visible = filterByExactEnabledModels(available, enabledModels);
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({ modelList = visible
.map(
(m: {
id: string;
name: string;
provider: string;
serviceTier?: string;
}) => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
provider: m.provider, provider: m.provider,
})).sort(compareModelEntries); serviceTier: m.serviceTier,
}),
)
.sort(compareModelEntries);
for (const m of visible) { for (const m of visible) {
const key = `${m.provider}:${m.id}`; const key = `${m.provider}:${m.id}`;
nameMap.set(key, m.name); nameMap.set(key, m.name);
@@ -79,12 +122,22 @@ async function loadModels(cwd: string): Promise<ModelsData> {
const provider = settings.getDefaultProvider(); const provider = settings.getDefaultProvider();
const modelId = settings.getDefaultModel(); const modelId = settings.getDefaultModel();
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) { if (
provider &&
modelId &&
visible.some((m) => m.provider === provider && m.id === modelId)
) {
defaultModel = { provider, modelId }; defaultModel = { provider, modelId };
} }
return withModelRuntimeError( return withModelRuntimeError(
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps }, {
models: Object.fromEntries(nameMap),
modelList,
defaultModel,
thinkingLevels,
thinkingLevelMaps,
},
modelError, modelError,
); );
} }
@@ -98,14 +151,18 @@ const EMPTY_MODELS: ModelsData = {
}; };
export async function GET(req: Request) { export async function GET(req: Request) {
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd(); const requestedCwd =
new URL(req.url).searchParams.get("cwd") || process.cwd();
const cwd = resolve(requestedCwd); const cwd = resolve(requestedCwd);
let cwdStat; let cwdStat;
try { try {
cwdStat = await stat(cwd); cwdStat = await stat(cwd);
} catch { } catch {
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 }); return Response.json(
{ error: `Directory does not exist: ${cwd}` },
{ status: 400 },
);
} }
if (!cwdStat.isDirectory()) { if (!cwdStat.isDirectory()) {
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 }); return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -1,6 +1,11 @@
export interface ModelsData { export interface ModelsData {
models: Record<string, string>; models: Record<string, string>;
modelList: { id: string; name: string; provider: string }[]; modelList: {
id: string;
name: string;
provider: string;
serviceTier?: string;
}[];
defaultModel: { provider: string; modelId: string } | null; defaultModel: { provider: string; modelId: string } | null;
thinkingLevels: Record<string, string[]>; thinkingLevels: Record<string, string[]>;
thinkingLevelMaps: Record<string, Record<string, string | null>>; thinkingLevelMaps: Record<string, Record<string, string | null>>;
@@ -38,11 +43,17 @@ export function invalidateModelsCache(): void {
state.inFlight.clear(); state.inFlight.clear();
} }
export function withModelRuntimeError(data: ModelsData, modelError: string | undefined): ModelsData { export function withModelRuntimeError(
data: ModelsData,
modelError: string | undefined,
): ModelsData {
return modelError ? { ...data, modelError } : data; return modelError ? { ...data, modelError } : data;
} }
export function loadModelsWithCache(cwd: string, loader: () => Promise<ModelsData>): Promise<ModelsData> { export function loadModelsWithCache(
cwd: string,
loader: () => Promise<ModelsData>,
): Promise<ModelsData> {
const state = getModelsCacheState(); const state = getModelsCacheState();
const cached = state.entries.get(cwd); const cached = state.entries.get(cwd);
if (cached) { if (cached) {
@@ -57,7 +68,10 @@ export function loadModelsWithCache(cwd: string, loader: () => Promise<ModelsDat
const loadPromise: Promise<ModelsData> = Promise.resolve() const loadPromise: Promise<ModelsData> = Promise.resolve()
.then(loader) .then(loader)
.then((data) => { .then((data) => {
if (state.generation === generation && state.inFlight.get(cwd) === loadPromise) { if (
state.generation === generation &&
state.inFlight.get(cwd) === loadPromise
) {
const now = Date.now(); const now = Date.now();
for (const [key, entry] of state.entries) { for (const [key, entry] of state.entries) {
if (entry.expiresAt <= now) state.entries.delete(key); if (entry.expiresAt <= now) state.entries.delete(key);
+1
View File
@@ -41,6 +41,7 @@ export function buildBaseOptions(
maxRetries: options?.maxRetries, maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs, maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata, metadata: options?.metadata,
serviceTier: options?.serviceTier,
env: options?.env, env: options?.env,
}; };
} }
+19
View File
@@ -113,6 +113,13 @@ export interface ProviderResponse {
headers: Record<string, string>; headers: Record<string, string>;
} }
/**
* Request-level service tier for providers that support it.
* OpenAI Codex / Responses: "priority" is fast mode (higher cost, lower latency), "flex" is the cheaper tier.
* See https://developers.openai.com/codex/speed for Codex fast mode semantics.
*/
export type ServiceTier = "auto" | "default" | "flex" | "priority" | "scale";
export interface StreamOptions { export interface StreamOptions {
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
@@ -189,6 +196,12 @@ export interface StreamOptions {
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting. * For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/ */
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
/**
* Request-level service tier. Providers that understand it (OpenAI Codex,
* OpenAI Responses) send it as `service_tier`; other providers ignore it.
* "priority" is Codex fast mode (1.5x speed, ~2x cost).
*/
serviceTier?: ServiceTier | null;
/** /**
* Provider-scoped environment values. These take precedence over process.env for * Provider-scoped environment values. These take precedence over process.env for
* provider configuration such as regional settings, endpoint placeholders, and * provider configuration such as regional settings, endpoint placeholders, and
@@ -773,6 +786,12 @@ export interface Model<TApi extends Api> {
contextWindow: number; contextWindow: number;
maxTokens: number; maxTokens: number;
headers?: Record<string, string>; headers?: Record<string, string>;
/**
* Optional default request-level service tier for this model (e.g. "priority"
* for Codex fast mode). Configured via models.json; forwarded to providers
* that support `service_tier`.
*/
serviceTier?: ServiceTier;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions" compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat ? OpenAICompletionsCompat
@@ -151,6 +151,13 @@ const ModelCostSchema = Type.Object({
tiers: Type.Optional(Type.Array(ModelCostTierSchema)), tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
}); });
const ServiceTierSchema = Type.Union([
Type.Literal("auto"),
Type.Literal("default"),
Type.Literal("priority"),
Type.Literal("flex"),
]);
const ModelDefinitionSchema = Type.Object({ const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }), id: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.String({ minLength: 1 })),
@@ -163,6 +170,7 @@ const ModelDefinitionSchema = Type.Object({
contextWindow: Type.Optional(Type.Number()), contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())), headers: Type.Optional(Type.Record(Type.String(), Type.String())),
serviceTier: Type.Optional(ServiceTierSchema),
compat: Type.Optional(ProviderCompatSchema), compat: Type.Optional(ProviderCompatSchema),
}); });
@@ -183,6 +191,7 @@ const ModelOverrideSchema = Type.Object({
contextWindow: Type.Optional(Type.Number()), contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()), maxTokens: Type.Optional(Type.Number()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())), headers: Type.Optional(Type.Record(Type.String(), Type.String())),
serviceTier: Type.Optional(ServiceTierSchema),
compat: Type.Optional(ProviderCompatSchema), compat: Type.Optional(ProviderCompatSchema),
}); });
@@ -117,6 +117,7 @@ function applyModelOverride(model: Model<Api>, override: ModelsJsonModelOverride
: model.cost, : model.cost,
contextWindow: override.contextWindow ?? model.contextWindow, contextWindow: override.contextWindow ?? model.contextWindow,
maxTokens: override.maxTokens ?? model.maxTokens, maxTokens: override.maxTokens ?? model.maxTokens,
serviceTier: override.serviceTier ?? model.serviceTier,
compat: mergeCompat(model.compat, override.compat), compat: mergeCompat(model.compat, override.compat),
}; };
} }
@@ -154,6 +155,7 @@ function modelFromJson(
contextWindow: definition.contextWindow ?? 128000, contextWindow: definition.contextWindow ?? 128000,
maxTokens: definition.maxTokens ?? 16384, maxTokens: definition.maxTokens ?? 16384,
headers: undefined, headers: undefined,
serviceTier: definition.serviceTier,
compat: mergeCompat(providerConfig.compat, definition.compat), compat: mergeCompat(providerConfig.compat, definition.compat),
}; };
} }
+5
View File
@@ -309,8 +309,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const websocketConnectTimeoutMs = const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
const headerRunner = extensionRunnerRef.current; const headerRunner = extensionRunnerRef.current;
// Model-level service tier (models.json `serviceTier`, e.g. "priority" for
// Codex fast mode) is forwarded as request `service_tier`. Providers that
// don't understand it ignore the option.
const serviceTier = model.serviceTier;
return modelRuntime.streamSimple(model, context, { return modelRuntime.streamSimple(model, context, {
...options, ...options,
...(serviceTier ? { serviceTier } : {}),
timeoutMs, timeoutMs,
websocketConnectTimeoutMs, websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
+54 -2
View File
@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { copyFileSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import { platform } from "node:os"; import { platform } from "node:os";
import { dirname, join, resolve } from "node:path"; import { delimiter, dirname, join, resolve } from "node:path";
import { loadEnvFile } from "node:process"; import { loadEnvFile } from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -55,6 +55,56 @@ const seedFiles = [
]; ];
const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"]; const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"];
const piWebBinDir = join(rootDir, "pi-web", "node_modules", ".bin");
const piCodingAgentPackageDir = join(
rootDir,
"pi-web",
"node_modules",
"@earendil-works",
"pi-coding-agent",
);
const managedPeerPackageDir = join(
agentDir,
"npm",
"node_modules",
"@earendil-works",
"pi-coding-agent",
);
function ensurePiSubagentRuntime() {
// pi-subagents resolves the host CLI through its optional coding-agent peer.
// The managed profile is installed separately from pi-web, so expose the
// existing local package instead of installing a second coding-agent copy.
if (!existsSync(piCodingAgentPackageDir)) return;
mkdirSync(dirname(managedPeerPackageDir), { recursive: true });
if (existsSync(managedPeerPackageDir)) return;
try {
if (lstatSync(managedPeerPackageDir)) return;
} catch {
// The peer link is absent; create it below.
}
try {
symlinkSync(
piCodingAgentPackageDir,
managedPeerPackageDir,
platform() === "win32" ? "junction" : "dir",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[subagents] could not link the local Pi CLI package: ${message}`);
}
}
function prependPiWebBinToPath(baseEnv) {
if (!existsSync(piWebBinDir)) return baseEnv.PATH ?? baseEnv.Path;
const pathKey = baseEnv.PATH !== undefined || baseEnv.Path === undefined ? "PATH" : "Path";
const currentPath = baseEnv[pathKey] ?? "";
const entries = currentPath.split(delimiter).filter(Boolean);
if (!entries.includes(piWebBinDir)) entries.unshift(piWebBinDir);
return entries.join(delimiter);
}
function readPersistedWindowsEnvironment(baseEnv) { function readPersistedWindowsEnvironment(baseEnv) {
if (platform() !== "win32") return {}; if (platform() !== "win32") return {};
@@ -112,10 +162,12 @@ export function ensureProfile({ quiet = false } = {}) {
export function managedEnvironment(baseEnv = process.env) { export function managedEnvironment(baseEnv = process.env) {
ensureProfile({ quiet: true }); ensureProfile({ quiet: true });
ensurePiSubagentRuntime();
const persistedEnvironment = readPersistedWindowsEnvironment(baseEnv); const persistedEnvironment = readPersistedWindowsEnvironment(baseEnv);
return { return {
...baseEnv, ...baseEnv,
...persistedEnvironment, ...persistedEnvironment,
PATH: prependPiWebBinToPath({ ...baseEnv, ...persistedEnvironment }),
PI_AGENT_MANAGED_RUNTIME: "1", PI_AGENT_MANAGED_RUNTIME: "1",
PI_AGENT_APP_ROOT: rootDir, PI_AGENT_APP_ROOT: rootDir,
PI_AGENT_DATA_DIR: dataDir, PI_AGENT_DATA_DIR: dataDir,