diff --git a/docs/architecture.md b/docs/architecture.md index 96cf3b9..6329e1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,6 +59,8 @@ Both web tools declare output schemas for programmatic callers. Structured fetch The OpenCode Free provider registers the OpenCode inference endpoint with the public token and reads no endpoint, API-key, or CLI-version environment overrides. It resolves the request version lazily at runtime from the official installer's fixed `https://opencode.ai/update/api/latest/cli/npm` metadata endpoint, falling back to `@opencode/cli`'s npm `latest` metadata. Both sources use credential-free HTTPS, no redirects, a five-second per-source deadline, a 64 KiB response bound, and identity and stable-version validation. No remote URL is followed and no installer or binary is downloaded or executed. A shared lookup serves independently cancelable callers and aborts when its last waiter cancels. Only successful versions are cached in memory for 24 hours; failures are retryable, and no synthetic or bundled version is substituted. Registration and offline model refresh make no version request. A published version is traceable upstream metadata, not a guarantee of third-party-client support or an exemption from service limits. +OpenCode Free discovery follows native Pi's four-hour refresh interval, requiring both `checkedAt` and `lastModified` before skipping network access. The persisted entry keeps the visible live selection in `models` and the complete validated free-metadata projection in the provider-owned `catalog` field, with Pi's standard `etag` and `lastModified` fields. Conditional pi.dev requests send `If-None-Match` only when a nonempty metadata body backs the validator; a `304` reuses that body while still applying the refreshed live selection. This retains metadata for models that were not previously live. A `200` replaces metadata and validators; `404`/`501` record an empty overlay with `lastModified: 0` and clear the validator. Other HTTP failures preserve the cached body and validators, advance `checkedAt`, and surface a bounded refresh error, matching native Pi; without prior `lastModified`, those failures remain immediately retryable. Transport, parsing, CLI-version resolution, timeout, and cancellation failures do not advance freshness. Forced refresh bypasses freshness, never offline mode or cancellation. Snapshots are endpoint-scoped, and memory retains only the latest snapshot, including when persistence is unavailable. + `pi-gate` reads command-text rules from `gate.json` and never remembers dialog approvals. A bounded, non-executing recognizer applies word boundaries to simple literal commands and excludes inert `echo`/`printf` argument text and comments. Restrictive flag and numeric suffixes retain prefix coverage; allow exceptions require complete words and cannot suppress another simple command. Pipelines, redirects, expansions, opaque script arguments, malformed quoting, oversized inputs, and rules containing shell syntax retain conservative substring matching. This recognizer is not a Bash parser or sandbox. Persistent exceptions are explicit `allow` rules. Approval dialogs are serialized, cancellation-aware, and revalidate policy after the user responds; policy changes and session starts invalidate pending dialogs. ## Data flow invariants diff --git a/packages/pi-gate/CHANGELOG.md b/packages/pi-gate/CHANGELOG.md index af10597..daca959 100644 --- a/packages/pi-gate/CHANGELOG.md +++ b/packages/pi-gate/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.1] - 2026-10-25 + +### Changed + +- Create starter configuration when a session starts rather than when the extension is loaded. + +### Fixed + +- Cancel active approval dialogs and reject queued calls during shutdown, reload, and session replacement. + ## [0.7.0] - 2026-10-24 ### Changed @@ -95,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution - block rather than auto-approve in non-UI modes -[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...HEAD +[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...HEAD +[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1 [0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0 [0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1 [0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...pi-gate-v0.6.0 diff --git a/packages/pi-gate/README.md b/packages/pi-gate/README.md index dcb1386..cd14991 100644 --- a/packages/pi-gate/README.md +++ b/packages/pi-gate/README.md @@ -15,7 +15,7 @@ Use `pi config` to enable or disable this extension interactively. Run `pi list` ## Configure -Edit `~/.pi/agent/gate.json` to choose which commands need approval. The extension creates this file with starter rules on first load. Changes apply before the next gated call, without restarting Pi. Add the optional `$schema` below for editor completion and validation: +Edit `~/.pi/agent/gate.json` to choose which commands need approval. The extension creates this file with starter rules when the first session starts. Changes apply before the next gated call, without restarting Pi. Add the optional `$schema` below for editor completion and validation: ```json { @@ -46,7 +46,7 @@ pi-gate is a guard rail, not a sandbox. Complex shell syntax, pipelines, redirec The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. Prompt dialogs offer `Allow` and `Deny`; approvals are never remembered. To stop repeated prompts, add a specific `allow` rule to `gate.json`. -Approval dialogs are serialized for concurrent tool calls; cancellation, session replacement, or a policy change while a dialog is open denies the pending call. +Approval dialogs are serialized for concurrent tool calls; cancellation, shutdown, reload, session replacement, or a policy change while a dialog is open denies the pending call. Calls queued before shutdown or session replacement are also denied. Long commands are truncated in approval dialogs, but an approved command executes in full. Denied, dismissed, blocked, and timed-out calls stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied. diff --git a/packages/pi-gate/package.json b/packages/pi-gate/package.json index c4194ce..1fc6a7d 100644 --- a/packages/pi-gate/package.json +++ b/packages/pi-gate/package.json @@ -1,6 +1,6 @@ { "name": "@zeldrisho/pi-gate", - "version": "0.7.0", + "version": "0.7.1", "description": "Pi extension that gates bash commands and protects its configuration from tool edits", "keywords": [ "command-blocking", @@ -42,6 +42,7 @@ }, "devDependencies": { "@earendil-works/pi-coding-agent": "catalog:", + "typebox": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/packages/pi-gate/src/index.ts b/packages/pi-gate/src/index.ts index c7edd9f..2f6362a 100644 --- a/packages/pi-gate/src/index.ts +++ b/packages/pi-gate/src/index.ts @@ -19,7 +19,7 @@ * Matching is case-insensitive and whitespace-normalized. Allow occurrences * suppress contained restrictions in the same command; surviving blocks win. * - * On first run, a configuration with the default prompt timeout and starter + * On first session start, a configuration with the default prompt timeout and starter * operation rules is written to `~/.pi/agent/gate.json` when neither file exists. * * Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule @@ -112,14 +112,16 @@ export { /** * Gates `bash` calls against user rules and requires approval for protected - * configuration writes and edits. Attempts to create a starter configuration if absent. + * configuration writes and edits. Attempts to create a starter configuration + * at session start if absent. * Reloads rules before gating and after each prompt; approvals apply per call. - * Dialogs are serialized, and cancellation, session starts, or policy changes - * invalidate an active approval. UI failures reject the affected tool call. + * Dialogs are serialized. Cancellation, shutdown, and session replacement deny + * active and queued gated calls; policy changes invalidate an active approval. + * UI failures reject the affected tool call. */ export default function piGate(pi: ExtensionAPI): void { - ensureConfig(); let loadResult = loadConfigResult(); + let session = new AbortController(); let policy = JSON.stringify(loadResult); let revision = 0; let pending = Promise.resolve(); @@ -135,8 +137,16 @@ export default function piGate(pi: ExtensionAPI): void { } }; + pi.on("session_shutdown", () => { + session.abort(); + revision += 1; + }); + pi.on("session_start", (_event, ctx) => { + session.abort(); + session = new AbortController(); revision += 1; + ensureConfig(); refreshConfig(); const path = configPath(); const ruleCount = Object.keys(loadResult.config.operations).length; @@ -160,7 +170,14 @@ export default function piGate(pi: ExtensionAPI): void { }); pi.on("tool_call", (event, ctx) => { + // Capture ownership before queueing so old calls cannot enter a replacement session. + const signal = ctx.signal ? AbortSignal.any([ctx.signal, session.signal]) : session.signal; + // Pi can dispatch sibling tools concurrently; never overlap approval dialogs. + /** + * Returns a terminating block result for denied calls, or undefined to allow; + * UI errors propagate. + */ const run = async (): Promise => { let command: string; let configChange = false; @@ -181,6 +198,14 @@ export default function piGate(pi: ExtensionAPI): void { return undefined; } + if (signal.aborted) { + return { + block: true, + reason: "pi-gate: approval cancelled; command not run.", + terminate: true, + }; + } + refreshConfig(); const match = configChange @@ -210,14 +235,6 @@ export default function piGate(pi: ExtensionAPI): void { }; } - if (ctx.signal?.aborted) { - return { - block: true, - reason: "pi-gate: approval cancelled; command not run.", - terminate: true, - }; - } - const promptRevision = revision; reportHerdrBlocked(pi, ctx, true); @@ -234,7 +251,7 @@ export default function piGate(pi: ExtensionAPI): void { choice = await ctx.ui.select( `pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`, ["Allow", "Deny"], - { timeout: timeoutMs, signal: ctx.signal }, + { timeout: timeoutMs, signal }, ); // Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case. timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs; @@ -244,7 +261,7 @@ export default function piGate(pi: ExtensionAPI): void { refreshConfig(); - if (ctx.signal?.aborted || revision !== promptRevision) { + if (signal.aborted || revision !== promptRevision) { return { block: true, reason: diff --git a/packages/pi-gate/tests/index.test.ts b/packages/pi-gate/tests/index.test.ts index d941b0a..3a58052 100644 --- a/packages/pi-gate/tests/index.test.ts +++ b/packages/pi-gate/tests/index.test.ts @@ -52,7 +52,7 @@ interface FakeUi { select: ( prompt: string, options: readonly string[], - settings?: { timeout?: number }, + settings?: { timeout?: number; signal?: AbortSignal }, ) => Promise; } @@ -77,6 +77,7 @@ type ToolCallHandler = ( interface RecordedHandlers { sessionStart: SessionStartHandler | undefined; + sessionShutdown: (() => void) | undefined; toolCall: ToolCallHandler | undefined; } @@ -141,7 +142,12 @@ function createExtensionContext( } function makeExtension(): ExtensionFactory { - const handlers: RecordedHandlers = { sessionStart: undefined, toolCall: undefined }; + const handlers: RecordedHandlers = { + sessionStart: undefined, + sessionShutdown: undefined, + toolCall: undefined, + }; + const herdrEvents: HerdrBlockedEvent[] = []; // SAFETY: the test only exercises the `on` method; the rest of ExtensionAPI is unused. @@ -155,6 +161,8 @@ function makeExtension(): ExtensionFactory { if (name === "session_start") { // SAFETY: the extension registers a SessionStartHandler for `session_start`. handlers.sessionStart = handler as SessionStartHandler; + } else if (name === "session_shutdown") { + handlers.sessionShutdown = () => handler({}, undefined); } else if (name === "tool_call") { // SAFETY: the extension registers a ToolCallHandler for `tool_call`. handlers.toolCall = handler as ToolCallHandler; @@ -292,15 +300,23 @@ describe("legacy configuration fallback", () => { }); describe("piGate extension", () => { - it("registers exactly session_start and tool_call", () => { + it("registers session lifecycle and tool_call handlers", () => { const ext = makeExtension(); const { handlers } = ext.install(); // SAFETY: the captured handlers list is keyed by the names the extension registered. expect(handlers.sessionStart).toBeDefined(); + expect(handlers.sessionShutdown).toBeDefined(); expect(handlers.toolCall).toBeDefined(); }); describe("session_start notification", () => { + it("does not create configuration until a session starts", async () => { + const { ctx, handlers } = makeExtension().install(); + expect(existsSync(join(workDir, "gate.json"))).toBe(false); + await handlers.sessionStart!({ reason: "startup" }, ctx); + expect(existsSync(join(workDir, "gate.json"))).toBe(true); + }); + it("allows commands when no config has ever existed and creation fails", async () => { const blockedAgentDir = join(workDir, "not-a-directory"); writeFileSync(blockedAgentDir, "not a directory", "utf-8"); @@ -534,7 +550,7 @@ describe("piGate extension", () => { prompt: 'pi-gate: allow this command?\n\n »rm -rf« node_modules\n\nMatched rule: "rm -rf": "prompt"\nMatched command text is wrapped in »…«', options: ["Allow", "Deny"], - settings: { timeout: DEFAULT_PROMPT_TIMEOUT_MS }, + settings: { timeout: DEFAULT_PROMPT_TIMEOUT_MS, signal: expect.any(AbortSignal) }, }, ]); }); @@ -678,6 +694,51 @@ describe("piGate extension", () => { }, ); + it.each(["shutdown", "replacement"])( + "cancels active and queued approvals on %s", + async (kind) => { + setConfig(JSON.stringify({ operations: { sudo: "prompt" } })); + const { ctx, uiState, handlers } = makeExtension().install(); + let opened!: () => void; + + const opening = new Promise((resolve) => { + opened = resolve; + }); + + ctx.ui.select = async (_prompt, _options, settings) => { + opened(); + const signal = settings?.signal; + + if (!signal) throw new Error("Expected a cancellation signal"); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + + return "Allow"; + }; + + const first = handlers.toolCall!(bashEvent("sudo echo first"), ctx); + const second = handlers.toolCall!(bashEvent("sudo echo second"), ctx); + await opening; + handlers.sessionShutdown!(); + handlers.sessionShutdown!(); // Cleanup is idempotent. + + if (kind === "replacement") await handlers.sessionStart!({ reason: "switch" }, ctx); + expect(await first).toMatchObject({ block: true, terminate: true }); + expect(await second).toMatchObject({ block: true, terminate: true }); + ctx.ui = createUi(uiState, true); + uiState.selectResponse = "Allow"; + + if (kind === "replacement") + expect(await handlers.toolCall!(bashEvent("sudo echo fresh"), ctx)).toBeUndefined(); + else + expect(await handlers.toolCall!(bashEvent("sudo echo stale"), ctx)).toMatchObject({ + block: true, + }); + expect(uiState.selectCalls).toHaveLength(kind === "replacement" ? 1 : 0); + }, + ); + it("does not poison queued approvals when a UI request fails", async () => { setConfig(JSON.stringify({ operations: { sudo: "prompt" } })); const { ctx, uiState, handlers } = makeExtension().install(); @@ -721,7 +782,10 @@ describe("piGate extension", () => { const { ctx, uiState, handlers } = makeExtension().install(); uiState.selectDelayMs = 30; const result = await handlers.toolCall!(bashEvent("rm -rf node_modules"), ctx); - expect(uiState.selectCalls[0]?.settings).toEqual({ timeout: 20 }); + expect(uiState.selectCalls[0]?.settings).toEqual({ + timeout: 20, + signal: expect.any(AbortSignal), + }); expect(result).toEqual({ block: true, reason: diff --git a/packages/pi-gate/tests/nested-calls.test.ts b/packages/pi-gate/tests/nested-calls.test.ts new file mode 100644 index 0000000..870023e --- /dev/null +++ b/packages/pi-gate/tests/nested-calls.test.ts @@ -0,0 +1,136 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createAgentSession, + DefaultResourceLoader, + SessionManager, + SettingsManager, + type ExtensionToolContext, +} from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { expect, it } from "vite-plus/test"; +import piGate from "../src/index"; + +it("gates real SDK nested calls without a model request or shell execution", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-gate-nested-")); + const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = directory; + let toolContext: ExtensionToolContext | undefined; + const calls: string[] = []; + const events: Array<{ toolName: string; parentToolCallId?: string }> = []; + const settingsManager = SettingsManager.inMemory(); + + const resourceLoader = new DefaultResourceLoader({ + cwd: directory, + agentDir: directory, + settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + extensionFactories: [ + piGate, + (pi) => { + pi.on("tool_call", (event) => { + events.push(event); + }); + // Replace bash with an inert recorder; exercise Pi's hooks, never a shell. + pi.registerTool({ + name: "bash", + label: "Bash fixture", + description: "Test fixture", + parameters: Type.Object({ command: Type.String() }), + async execute(_id, params) { + calls.push(params.command); + + return { content: [{ type: "text", text: "recorded" }], details: undefined }; + }, + }); + pi.registerTool({ + name: "nested_probe", + label: "Nested probe", + description: "Test fixture", + parameters: Type.Object({}), + async execute(_id, _params, _signal, _update, ctx) { + toolContext = ctx; + + return { content: [], details: undefined }; + }, + }); + }, + ], + }); + + let session: Awaited>["session"] | undefined; + + try { + await writeFile( + join(directory, "gate.json"), + JSON.stringify({ operations: { forbidden: "block", approval: "prompt" } }), + ); + await resourceLoader.reload(); + // Seed a finalized assistant call, as a model response would, without network access. + const sessionManager = SessionManager.inMemory(directory); + sessionManager.appendMessage({ + role: "assistant", + api: "openai-completions", + provider: "fixture", + model: "fixture", + content: [{ type: "toolCall", id: "parent", name: "nested_probe", arguments: {} }], + stopReason: "toolUse", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }); + ({ session } = await createAgentSession({ + cwd: directory, + agentDir: directory, + resourceLoader, + settingsManager, + sessionManager, + tools: ["bash", "nested_probe"], + })); + await session.bindExtensions({ mode: "print" }); + const probe = session.agent.state.tools.find((tool) => tool.name === "nested_probe"); + expect(probe).toBeDefined(); + await probe!.execute("parent", {}, undefined); + expect(toolContext).toBeDefined(); + const ctx = toolContext!; + + const results = await Promise.all([ + ctx.executeTool("bash", { command: "forbidden" }), + ctx.executeTool("bash", { command: "approval" }), + ctx.executeTool("bash", { command: "safe" }), + ]); + + expect(results.map((result) => result.isError)).toEqual([true, true, false]); + expect(results[0]?.result.content).toEqual([ + { type: "text", text: expect.stringContaining("blocked by rule") }, + ]); + expect(results[1]?.result.content).toEqual([ + { type: "text", text: expect.stringContaining("no UI") }, + ]); + expect(calls).toEqual(["safe"]); + expect(events).toContainEqual( + expect.objectContaining({ toolName: "bash", parentToolCallId: "parent" }), + ); + await expect( + ctx.executeTool("bash", { command: "safe" }, { signal: AbortSignal.abort() }), + ).resolves.toMatchObject({ isError: true }); + expect(calls).toEqual(["safe"]); + } finally { + session?.dispose(); + + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/pi-opencode-free/CHANGELOG.md b/packages/pi-opencode-free/CHANGELOG.md index b45be68..a9eb2f8 100644 --- a/packages/pi-opencode-free/CHANGELOG.md +++ b/packages/pi-opencode-free/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-10-25 + +### Changed + +- Show usage-limit reset times in 24-hour format without seconds while retaining your local date and timezone. + +### Fixed + +- Avoid repeated catalog requests when reopening the model picker by following Pi's native four-hour refresh policy and revalidating unchanged metadata. +- Stop Pi from automatically retrying exhausted free-tier usage limits while preserving server-reported reset times. + ## [0.2.0] - 2026-10-24 ### Added @@ -29,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discover free models through Pi's native OpenCode catalog from pi.dev instead of models.dev, preserving Pi's capabilities and compatibility metadata. -[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-opencode-free-v0.2.0...HEAD +[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-opencode-free-v0.2.1...HEAD +[0.2.1]: https://github.com/zeldrisho/pi-packages/compare/pi-opencode-free-v0.2.0...pi-opencode-free-v0.2.1 [0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-opencode-free-v0.1.0...pi-opencode-free-v0.2.0 [0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-opencode-free-v0.1.0 diff --git a/packages/pi-opencode-free/README.md b/packages/pi-opencode-free/README.md index 5199618..a659e7e 100644 --- a/packages/pi-opencode-free/README.md +++ b/packages/pi-opencode-free/README.md @@ -21,7 +21,7 @@ Restart Pi after installing. Use `pi config` to enable or disable the extension, No configuration or `/login` is required. Your prompts are sent directly to OpenCode using its public token. Endpoint and API-key environment overrides are not supported. -The model list updates automatically as OpenCode's free catalog changes. If discovery is unavailable, the extension uses the last synced catalog or Pi's bundled free models. +The model list refreshes automatically when you open `/model`, following Pi's native four-hour catalog refresh policy. Unchanged metadata is revalidated without downloading it again. If discovery is unavailable, the last synced catalog for the current endpoint or Pi's bundled free models remain available. The provider automatically resolves the current CLI version from OpenCode's official update service, with npm metadata as a fallback, when needed for an online model refresh or inference request. Successful lookups are cached in memory for 24 hours. No OpenCode installation or version configuration is needed. If neither source can provide a valid version and no unexpired cached value exists, inference stops with a retryable connection error rather than using an invented version. diff --git a/packages/pi-opencode-free/package.json b/packages/pi-opencode-free/package.json index 3303b34..db80eb9 100644 --- a/packages/pi-opencode-free/package.json +++ b/packages/pi-opencode-free/package.json @@ -1,6 +1,6 @@ { "name": "@zeldrisho/pi-opencode-free", - "version": "0.2.0", + "version": "0.2.1", "description": "Pi provider extension for OpenCode's free-tier models", "keywords": [ "opencode", diff --git a/packages/pi-opencode-free/src/index.ts b/packages/pi-opencode-free/src/index.ts index 50bba30..3f3764c 100644 --- a/packages/pi-opencode-free/src/index.ts +++ b/packages/pi-opencode-free/src/index.ts @@ -9,12 +9,14 @@ import { } from "./fingerprint.ts"; import { DEFAULT_BASE_URL, + catalogFromStoredEntry, configsFromStoredModels, discoverFreeModelsWithStatus, initialFreeModels, storedModelsFromConfigs, stripTrailingSlashes, type FreeChatModelConfig, + type FreeModelsStoreEntry, } from "./models.ts"; import { createFreeStream } from "./stream.ts"; @@ -23,6 +25,15 @@ export const PROVIDER_ID = "opencode-free"; const DISCOVERY_TIMEOUT_MS = 8_000; +const CATALOG_TTL_MS = 4 * 60 * 60 * 1000; + +interface CatalogRefreshState { + cached?: { + baseUrl: string; + entry: FreeModelsStoreEntry; + }; +} + /** Injectable runtime dependencies used by tests and the default extension export. */ export interface OpencodeFreeDependencies { randomSeed: () => Uint8Array; @@ -48,6 +59,7 @@ export function registerOpencodeFree( const resolveVersion = createCliVersionResolver(dependencies.fetcher); const initialModels = initialFreeModels(); + const refreshState: CatalogRefreshState = {}; pi.on("session_start", () => { sessionId = createCanonicalSessionId(dependencies.randomSeed()); @@ -66,7 +78,7 @@ export function registerOpencodeFree( return { sessionId, cliVersion }; }), refreshModels: async (context) => - refreshModels(context, dependencies, sessionId, resolveVersion), + refreshModels(context, dependencies, sessionId, resolveVersion, refreshState), }; pi.registerProvider(PROVIDER_ID, config); @@ -76,21 +88,68 @@ export function registerOpencodeFree( * Refreshes the catalog, falling back to stored models or bundled models when * offline, canceled, version resolution fails, or discovery returns no usable * models. Version resolution and discovery share an eight-second abort deadline. - * Successful results are published for persistence; publication failures are ignored - * and the discovered models are still returned. + * Like native Pi, catalogs with lastModified (including zero) and a valid checkedAt + * stay fresh for four hours after checkedAt. Force bypasses freshness, never + * offline mode or cancellation. HTTP failures + * retain the body and validators while advancing checkedAt; first failures without + * Last-Modified remain immediately retryable. Transport, parsing, version lookup, + * and cancellation failures do not advance freshness. Snapshots are endpoint-scoped. + * Publication errors are ignored and the snapshot is retained in memory unless + * the caller canceled; a refused publication does not update the in-memory cache. + * + * @throws {Error} If discovery reports an HTTP failure, after attempting to publish the + * updated checkedAt with any existing body and validators */ async function refreshModels( context: RefreshModelsContext, dependencies: OpencodeFreeDependencies, sessionId: string, resolveVersion: (signal?: AbortSignal) => Promise, + state: CatalogRefreshState, ): Promise { const baseUrl = stripTrailingSlashes(context.baseUrl ?? DEFAULT_BASE_URL); - const cachedModels = configsFromStoredModels(context.stored?.models ?? [], PROVIDER_ID); + const memory = state.cached?.baseUrl === baseUrl ? state.cached : undefined; + + const storedModels = configsFromStoredModels( + (context.stored?.models ?? []).filter( + (model) => stripTrailingSlashes(model.baseUrl) === baseUrl, + ), + PROVIDER_ID, + ); + + const stored = memory?.entry ?? (storedModels.length > 0 ? context.stored : undefined); + + const cachedModels = memory + ? configsFromStoredModels(memory.entry.models, PROVIDER_ID) + : storedModels; + const fallbackModels = cachedModels.length > 0 ? cachedModels : initialFreeModels(); if (!context.allowNetwork || context.signal.aborted) return fallbackModels; + const now = Date.now(); + + if ( + !context.force && + stored?.lastModified !== undefined && + isRecent(stored.checkedAt, now, CATALOG_TTL_MS) + ) { + return fallbackModels; + } + + /** Publishes a snapshot and caches it unless refused or canceled; publication errors are ignored. */ + const publish = async (entry: FreeModelsStoreEntry): Promise => { + let accepted = true; + + try { + accepted = await context.publish({ persist: entry }); + } catch { + // Keep a valid in-memory catalog even when persistent storage is unavailable. + } + + if (accepted && !context.signal.aborted) state.cached = { baseUrl, entry }; + }; + const signal = AbortSignal.any([context.signal, AbortSignal.timeout(DISCOVERY_TIMEOUT_MS)]); let cliVersion: string; @@ -106,24 +165,43 @@ async function refreshModels( signal, fetcher: dependencies.fetcher, allowNetwork: context.allowNetwork, + catalog: catalogFromStoredEntry(stored), }); - if (!outcome.models || context.signal.aborted) return fallbackModels; - - const models = outcome.models; + if (signal.aborted) return fallbackModels; - try { - await context.publish({ - persist: { - models: storedModelsFromConfigs(models, PROVIDER_ID, baseUrl), - checkedAt: Date.now(), - }, - }); - } catch { - // Keep a valid in-memory catalog even when persistent storage is unavailable. + if (outcome.httpStatus !== undefined) { + await publish({ ...(stored ?? { models: [] }), checkedAt: Date.now() }); + throw new Error( + `OpenCode Free model discovery failed (${outcome.status}): HTTP ${outcome.httpStatus}`, + ); } - return models; + if (!outcome.models || !outcome.catalog) return fallbackModels; + + await publish({ + models: storedModelsFromConfigs(outcome.models, PROVIDER_ID, baseUrl), + catalog: outcome.catalog.models, + checkedAt: Date.now(), + lastModified: outcome.catalog.lastModified, + etag: outcome.catalog.etag, + }); + + return outcome.models; +} + +/** + * Accepts nonnegative epoch-millisecond timestamps no later than now and less + * than ttl milliseconds old; missing, nonfinite, and future timestamps fail. + */ +function isRecent(timestamp: number | undefined, now: number, ttl: number): boolean { + return ( + timestamp !== undefined && + Number.isFinite(timestamp) && + timestamp >= 0 && + timestamp <= now && + now - timestamp < ttl + ); } const defaultDependencies: OpencodeFreeDependencies = { diff --git a/packages/pi-opencode-free/src/models.ts b/packages/pi-opencode-free/src/models.ts index b27e22c..2de6fed 100644 --- a/packages/pi-opencode-free/src/models.ts +++ b/packages/pi-opencode-free/src/models.ts @@ -1,4 +1,10 @@ -import { isModelType, type AnyModel, type Api, type Model } from "@earendil-works/pi-ai"; +import { + isModelType, + type AnyModel, + type Api, + type Model, + type ModelsStoreEntry, +} from "@earendil-works/pi-ai"; import { getBuiltinModelDataGeneratedAt, getBuiltinModels, @@ -14,6 +20,18 @@ export const MODEL_CATALOG_URL = "https://pi.dev/api/models/providers/opencode?t /** Chat model configuration accepted by `ProviderConfig.models`. */ export type FreeChatModelConfig = Extract; +/** Complete free metadata projection backing a pi.dev catalog validator. */ +export interface ModelCatalogSnapshot { + models: FreeChatModelConfig[]; + lastModified: number; + etag?: string; +} + +/** The visible live selection and the complete metadata backing conditional requests. */ +export interface FreeModelsStoreEntry extends ModelsStoreEntry { + catalog?: readonly FreeChatModelConfig[]; +} + interface RemoteCost { input?: number; output?: number; @@ -62,6 +80,9 @@ export type DiscoveryStatus = export interface DiscoveryOutcome { status: DiscoveryStatus; models?: FreeChatModelConfig[]; + catalog?: ModelCatalogSnapshot; + /** Present only for HTTP failures, not transport or response-shape failures. */ + httpStatus?: number; /** Number of ids reported by the live endpoint, when it responded successfully. */ liveModelCount?: number; } @@ -73,6 +94,7 @@ export interface DiscoverFreeModelsOptions { signal: AbortSignal; fetcher: typeof fetch; allowNetwork: boolean; + catalog?: ModelCatalogSnapshot; } /** @@ -128,6 +150,45 @@ export function configsFromStoredModels( return configs; } +/** + * Restores cached free-model metadata, accepting an empty catalog and lastModified of zero. + * Returns undefined if the catalog or timestamp is missing or invalid, or any entry is unusable. + */ +export function catalogFromStoredEntry( + stored: Readonly | undefined, +): ModelCatalogSnapshot | undefined { + const lastModified = stored?.lastModified; + + if ( + !stored || + lastModified === undefined || + !Number.isFinite(lastModified) || + lastModified < 0 || + !("catalog" in stored) || + !Array.isArray(stored.catalog) + ) { + return undefined; + } + + const models: FreeChatModelConfig[] = []; + + for (const entry of stored.catalog) { + if (!isRemoteCatalogEntry(entry)) return undefined; + + const config = configFromRemote({ ...entry, api: "openai-completions" }); + + if (!config) return undefined; + + models.push(config); + } + + return { + models, + lastModified, + etag: stored.etag, + }; +} + /** * Converts provider configurations into the catalog shape Pi persists. * @@ -172,12 +233,15 @@ export function storedModelsFromConfigs( * * The live endpoint decides which models currently exist. Pi's OpenCode * catalog supplies their capabilities and compatibility flags. A newer remote - * catalog overlays bundled metadata; otherwise bundled metadata is used. Both - * endpoints must respond successfully. Failures are classified without - * exposing response bodies, headers, or credentials. + * catalog overlays bundled metadata; otherwise bundled metadata is used. + * A catalog 304 reuses a nonempty cached body with an ETag; 404/501 uses bundled metadata. + * HTTP, transport, parsing, and cancellation failures are returned as statuses + * without exposing response bodies, headers, or credentials. * - * @param options - Endpoint, headers, abort signal, and fetcher - * @returns Discovered models or a bounded failure status + * @param options - Endpoint, headers, abort signal, fetcher, network permission, + * and optional cached metadata for revalidation + * @returns Discovered models and the complete metadata snapshot, or a bounded + * failure status with an HTTP status when an HTTP request failed */ export async function discoverFreeModelsWithStatus( options: DiscoverFreeModelsOptions, @@ -195,16 +259,17 @@ export async function discoverFreeModelsWithStatus( return { status: options.signal.reason?.name === "TimeoutError" ? "timeout" : "aborted" }; } - if (!live.ok) return { status: live.failure }; + if (!live.ok) return { status: live.failure, httpStatus: live.httpStatus }; - if (!catalog.ok) return { status: catalog.failure }; + if (!catalog.ok) return { status: catalog.failure, httpStatus: catalog.httpStatus }; if (live.ids.length === 0) return { status: "live-empty", liveModelCount: 0 }; - const models = selectFreeModels(live.ids, freeCatalogModels(catalog.entries)); + const metadata = isCatalogNewer(catalog.snapshot.lastModified) ? catalog.snapshot.models : []; + const models = selectFreeModels(live.ids, freeCatalogModels(metadata)); return models.length > 0 - ? { status: "ok", models, liveModelCount: live.ids.length } + ? { status: "ok", models, catalog: catalog.snapshot, liveModelCount: live.ids.length } : { status: "no-known-free-models", liveModelCount: live.ids.length }; } @@ -235,17 +300,13 @@ function freeBuiltinModels(): Map { return models; } -/** Overlays qualifying remote entries on bundled models; rejected or absent entries leave them intact. */ +/** Overlays already validated free metadata on bundled models, replacing matching ids. */ function freeCatalogModels( - catalog: readonly RemoteCatalogEntry[], + catalog: readonly FreeChatModelConfig[], ): Map { const models = freeBuiltinModels(); - for (const entry of catalog) { - const config = configFromRemote(entry); - - if (config) models.set(config.id, config); - } + for (const config of catalog) models.set(config.id, config); return models; } @@ -350,17 +411,22 @@ function selectFreeModels( * Fetches `/models` with the supplied headers and signal, omitting empty ids. * Returns a classified failure on HTTP, fetch, JSON parsing, or response-shape * failure, including an aborted fetch; a valid empty list succeeds. + * HTTP failures include the response status; caught errors do not propagate. */ async function fetchLiveModelIds( options: DiscoverFreeModelsOptions, -): Promise<{ ok: true; ids: readonly string[] } | { ok: false; failure: DiscoveryStatus }> { +): Promise< + | { ok: true; ids: readonly string[] } + | { ok: false; failure: DiscoveryStatus; httpStatus?: number } +> { try { const response = await options.fetcher(`${options.baseUrl}/models`, { headers: { ...options.headers, Accept: "application/json" }, signal: options.signal, }); - if (!response.ok) return { ok: false, failure: "live-request-failed" }; + if (!response.ok) + return { ok: false, failure: "live-request-failed", httpStatus: response.status }; const body: unknown = await response.json().catch(() => undefined); @@ -380,23 +446,39 @@ async function fetchLiveModelIds( /** * Fetches Pi's OpenCode catalog with the supplied signal and without provider headers. - * Returns an empty entry list when the bundled catalog is at least as recent, or a - * classified failure on HTTP, fetch, JSON parsing, or response-shape failure. + * Revalidates nonempty cached metadata with ETag, retaining it on 304. + * Missing catalogs (404/501) produce the native empty overlay marker. Other HTTP, + * fetch, JSON parsing, or response-shape failures are classified. * Invalid ids or names discard individual entries; a nonempty response with no * valid entries is a response-shape failure. + * Successful snapshots contain free-model metadata, an optional ETag, and a + * Last-Modified time in epoch milliseconds (zero when missing or invalid). */ async function fetchModelCatalog( options: DiscoverFreeModelsOptions, ): Promise< - { ok: true; entries: readonly RemoteCatalogEntry[] } | { ok: false; failure: DiscoveryStatus } + | { ok: true; snapshot: ModelCatalogSnapshot } + | { ok: false; failure: DiscoveryStatus; httpStatus?: number } > { try { + const cached = options.catalog; + const validator = cached && cached.models.length > 0 ? cached.etag : undefined; + const headers = new Headers({ Accept: "application/json" }); + + if (validator) headers.set("If-None-Match", validator); + const response = await options.fetcher(MODEL_CATALOG_URL, { - headers: { Accept: "application/json" }, + headers, signal: options.signal, }); - if (!response.ok) return { ok: false, failure: "catalog-request-failed" }; + if (response.status === 304 && validator && cached) return { ok: true, snapshot: cached }; + + if (response.status === 404 || response.status === 501) + return { ok: true, snapshot: { models: [], lastModified: 0 } }; + + if (!response.ok) + return { ok: false, failure: "catalog-request-failed", httpStatus: response.status }; const body: unknown = await response.json().catch(() => undefined); @@ -407,9 +489,23 @@ async function fetchModelCatalog( if (body.length > 0 && entries.length === 0) return { ok: false, failure: "catalog-response-invalid" }; + const models: FreeChatModelConfig[] = []; + + for (const entry of entries) { + const config = configFromRemote(entry); + + if (config) models.push(config); + } + + const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); + return { ok: true, - entries: isCatalogNewer(response.headers.get("last-modified")) ? entries : [], + snapshot: { + models, + lastModified: Number.isNaN(lastModified) ? 0 : lastModified, + etag: response.headers.get("etag") ?? undefined, + }, }; } catch { return { ok: false, failure: "catalog-request-failed" }; @@ -417,16 +513,13 @@ async function fetchModelCatalog( } /** - * Compares the remote Last-Modified date with the bundled catalog timestamp. - * A missing or invalid remote date qualifies only when no bundled timestamp exists. + * Returns whether the remote Last-Modified time in epoch milliseconds is strictly + * newer than the bundled timestamp; any value qualifies if no bundled timestamp exists. */ -function isCatalogNewer(lastModified: string | null): boolean { - const remoteGeneratedAt = Date.parse(lastModified ?? ""); +function isCatalogNewer(lastModified: number): boolean { const localGeneratedAt = getBuiltinModelDataGeneratedAt(); - if (Number.isNaN(remoteGeneratedAt)) return localGeneratedAt === undefined; - - return localGeneratedAt === undefined || remoteGeneratedAt > localGeneratedAt; + return localGeneratedAt === undefined || lastModified > localGeneratedAt; } /** Requires a `data` array whose entries all have string ids; an empty array is valid. */ diff --git a/packages/pi-opencode-free/src/quota.ts b/packages/pi-opencode-free/src/quota.ts index 369abd5..a89c46a 100644 --- a/packages/pi-opencode-free/src/quota.ts +++ b/packages/pi-opencode-free/src/quota.ts @@ -54,19 +54,29 @@ export function quotaResetAt(headers: Headers, now: number): number | undefined /** * Formats an epoch-millisecond reset time using the supplied formatter. - * Defaults to the Pi process's locale and timezone, including its standard TZ override. + * Defaults to 24-hour, minute-precision time in the Pi process's locale and timezone, + * including TZ. * * @throws {RangeError} If resetAt is invalid or outside the supported date range */ export function formatResetTime( resetAt: number, - formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "long" }), + formatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + timeZoneName: "short", + }), ): string { return formatter.format(resetAt); } /** * Formats a quota error with the reported reset time, or an unavailable-time notice. + * Uses "quota exceeded" so Pi recognizes exhausted usage as non-retryable. * * @param now - Current time in milliseconds since the Unix epoch */ @@ -74,9 +84,7 @@ export function usageLimitMessage(headers: Headers, now: number): string { const resetAt = quotaResetAt(headers, now); const reset = - resetAt === undefined - ? "Reset time unavailable." - : `Reported reset: ${formatResetTime(resetAt)}.`; + resetAt === undefined ? "Reset time unavailable." : `Reset time: ${formatResetTime(resetAt)}.`; - return `429: OpenCode Free usage limit reached.\n${reset}`; + return `OpenCode Free quota exceeded.\n${reset}`; } diff --git a/packages/pi-opencode-free/tests/extension.test.ts b/packages/pi-opencode-free/tests/extension.test.ts index 98c982d..ac42444 100644 --- a/packages/pi-opencode-free/tests/extension.test.ts +++ b/packages/pi-opencode-free/tests/extension.test.ts @@ -7,7 +7,7 @@ import type { } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent"; import { normalizeContext } from "@earendil-works/pi-ai/compat"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import piOpencodeFree, { PROVIDER_ID, registerOpencodeFree } from "../src/index"; import { CLI_NPM_URL, CLI_UPDATE_URL } from "../src/cli-version"; @@ -124,6 +124,7 @@ function refreshContext(options: { stored?: ModelsStoreEntry; baseUrl?: string; signal?: AbortSignal; + force?: boolean; }): RefreshHarness { const publications: ModelsPublication[] = []; @@ -141,6 +142,8 @@ function refreshContext(options: { if (options.baseUrl) context.baseUrl = options.baseUrl; + if (options.force !== undefined) context.force = options.force; + return { publications, context }; } @@ -232,6 +235,7 @@ function discoveryFetcher(calls: string[]): typeof fetch { headers: { "content-type": "application/json", "last-modified": "Wed, 21 Oct 2099 07:28:00 GMT", + etag: '"catalog-v1"', }, }); } @@ -490,6 +494,495 @@ describe("opencode-free extension", () => { expect(calls).toEqual([CLI_UPDATE_URL, CLI_NPM_URL]); }); + describe("catalog refresh caching", () => { + const start = Date.UTC(2026, 9, 24); + const ttl = 4 * 60 * 60 * 1000; + let now: number; + + beforeEach(() => { + now = start; + vi.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function provider(fetcher: typeof fetch): ProviderConfig { + const { pi, state } = createFakePi(); + registerOpencodeFree(pi, dependencies({}, fetcher)); + + if (!state.provider) throw new Error("missing provider"); + + return state.provider.config; + } + + it("uses fresh persisted models without network or publication, then refreshes at expiry", async () => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + + const { context, publications } = refreshContext({ + allowNetwork: true, + baseUrl: `${DEFAULT_BASE_URL}/`, + stored: { models: [storedModel], checkedAt: start, lastModified: 0 }, + }); + + now += ttl - 1; + expect((await refreshModels(config, context)).map((model) => model.id)).toEqual([ + "cached-free", + ]); + expect(calls).toEqual([]); + expect(publications).toEqual([]); + + now += 1; + expect((await refreshModels(config, context)).map((model) => model.id)).toEqual([ + "beam-free", + "deepseek-v4.1-flash-free", + ]); + expect(calls).toEqual([CLI_UPDATE_URL, `${DEFAULT_BASE_URL}/models`, MODEL_CATALOG_URL]); + expect(publications[0]?.persist?.checkedAt).toBe(now); + }); + + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, -1, start + 1, start - ttl])( + "refreshes stored models with an absent, invalid, future, or expired timestamp: %s", + async (checkedAt) => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + + const { context } = refreshContext({ + allowNetwork: true, + stored: { models: [storedModel], checkedAt, lastModified: 0 }, + }); + + await refreshModels(config, context); + expect(calls).toHaveLength(3); + }, + ); + + it.each([{ models: [] }, { models: [{ ...storedModel, provider: "other" }] }])( + "does not treat a timestamp without usable models as a fresh catalog: %j", + async ({ models }) => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + + const { context } = refreshContext({ + allowNetwork: true, + stored: { models, checkedAt: now, lastModified: 0 }, + }); + + await refreshModels(config, context); + expect(calls).toHaveLength(3); + }, + ); + + it.each([false, true])( + "caches successful discovery in memory even when persistence throws: %s", + async (publicationFails) => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + const { context, publications } = refreshContext({ allowNetwork: true }); + + if (publicationFails) { + context.publish = async () => { + throw new Error("storage unavailable"); + }; + } + + const models = await refreshModels(config, context); + now += ttl - 1; + expect(await refreshModels(config, context)).toEqual(models); + expect(calls).toHaveLength(3); + expect(publications).toHaveLength(publicationFails ? 0 : 1); + + const offline = refreshContext({ allowNetwork: false }); + expect(await refreshModels(config, offline.context)).toEqual(models); + + now += 1; + await refreshModels(config, context); + expect(calls).toHaveLength(5); + }, + ); + + it("does not cache a refused publication", async () => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + const { context } = refreshContext({ allowNetwork: true }); + context.publish = async () => false; + + await refreshModels(config, context); + await refreshModels(config, context); + expect(calls).toHaveLength(5); + }); + + it("scopes stored and in-memory catalogs to the normalized endpoint", async () => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + const stored = { models: [storedModel], checkedAt: now }; + + const { context } = refreshContext({ + allowNetwork: true, + baseUrl: "https://proxy.test/v1", + stored, + }); + + await refreshModels(config, context); + expect(calls).toContain("https://proxy.test/v1/models"); + + const sameEndpoint = refreshContext({ + allowNetwork: true, + baseUrl: "https://proxy.test/v1///", + }); + + await refreshModels(config, sameEndpoint.context); + expect(calls).toHaveLength(3); + + const otherEndpoint = refreshContext({ + allowNetwork: true, + baseUrl: "https://other.test/v1", + }); + + await refreshModels(config, otherEndpoint.context); + expect(calls).toHaveLength(5); + expect(calls).toContain("https://other.test/v1/models"); + }); + + it("does not use another endpoint's stored catalog as a fallback", async () => { + const config = provider(async () => new Response(null, { status: 503 })); + + const { context } = refreshContext({ + allowNetwork: true, + baseUrl: "https://proxy.test/v1", + stored: { models: [storedModel], checkedAt: now }, + }); + + expect(await refreshModels(config, context)).toEqual(initialFreeModels()); + }); + + it("retains a known catalog and validators on HTTP failure, advancing the four-hour check time", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + let failed = false; + + const config = provider(async (input, init) => { + const response = await successfulFetch(input, init); + + return failed && input !== CLI_UPDATE_URL ? new Response(null, { status: 503 }) : response; + }); + + const { context, publications } = refreshContext({ allowNetwork: true }); + const models = await refreshModels(config, context); + const previous = publications[0]?.persist; + now += ttl; + failed = true; + await expect(refreshModels(config, context)).rejects.toThrow("HTTP 503"); + expect(publications[1]?.persist).toEqual({ ...previous, checkedAt: now }); + expect(calls).toHaveLength(5); + now += ttl - 1; + expect(await refreshModels(config, context)).toEqual(models); + expect(calls).toHaveLength(5); + + failed = false; + now += 1; + await refreshModels(config, context); + expect(calls).toHaveLength(7); + }); + + it("does not throttle an initial HTTP failure without Last-Modified metadata", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + + const config = provider(async (input, init) => { + const response = await successfulFetch(input, init); + + return input === MODEL_CATALOG_URL ? new Response(null, { status: 503 }) : response; + }); + + const { context, publications } = refreshContext({ allowNetwork: true }); + + await expect(refreshModels(config, context)).rejects.toThrow("HTTP 503"); + expect(publications[0]?.persist).toEqual({ models: [], checkedAt: now }); + await expect(refreshModels(config, context)).rejects.toThrow("HTTP 503"); + expect(calls).toHaveLength(5); + }); + + it("revalidates persisted metadata after restart and applies live changes on 304", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + const catalogHeaders: Headers[] = []; + const lastModified = "Wed, 21 Oct 2099 07:28:00 GMT"; + let unchanged = false; + + const fetcher: typeof fetch = async (input, init) => { + const response = await successfulFetch(input, init); + + if (input === MODEL_CATALOG_URL) { + catalogHeaders.push(new Headers(init?.headers)); + + return unchanged + ? new Response(null, { status: 304 }) + : Response.json( + [...discoveryCatalog, { ...discoveryCatalog[0], id: "newly-live-free" }], + { headers: { "last-modified": lastModified, etag: '"catalog-v1"' } }, + ); + } + + return unchanged && input === `${DEFAULT_BASE_URL}/models` + ? Response.json({ data: [{ id: "newly-live-free" }] }) + : response; + }; + + const initial = refreshContext({ allowNetwork: true }); + const models = await refreshModels(provider(fetcher), initial.context); + expect(models.map((model) => model.id)).not.toContain("newly-live-free"); + const stored = initial.publications[0]?.persist; + + if (!stored) throw new Error("missing persisted catalog"); + + unchanged = true; + now += ttl; + const restarted = refreshContext({ allowNetwork: true, stored }); + const refreshed = await refreshModels(provider(fetcher), restarted.context); + expect(refreshed.map((model) => model.id)).toEqual(["newly-live-free"]); + expect(catalogHeaders[0]?.get("if-none-match")).toBeNull(); + expect(catalogHeaders[1]?.get("if-none-match")).toBe('"catalog-v1"'); + expect(catalogHeaders[1]?.get("authorization")).toBeNull(); + expect(catalogHeaders[1]?.get("x-opencode-session")).toBeNull(); + expect(restarted.publications[0]?.persist).toMatchObject({ + checkedAt: now, + lastModified: Date.parse(lastModified), + etag: '"catalog-v1"', + }); + }); + + it("does not send an ETag without a complete cached metadata body", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + + const config = provider(async (input, init) => { + if (input === MODEL_CATALOG_URL) { + expect(new Headers(init?.headers).get("if-none-match")).toBeNull(); + } + + return successfulFetch(input, init); + }); + + const { context } = refreshContext({ + allowNetwork: true, + stored: { + models: [storedModel], + checkedAt: start - ttl, + lastModified: 0, + etag: '"orphaned"', + }, + }); + + await refreshModels(config, context); + expect(calls).toHaveLength(3); + }); + + it.each([404, 501])("persists native missing-catalog markers for HTTP %s", async (status) => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + const builtin = initialFreeModels()[0]; + + if (!builtin) throw new Error("missing bundled model"); + + const config = provider(async (input, init) => { + const response = await successfulFetch(input, init); + + if (input === MODEL_CATALOG_URL) return new Response(null, { status }); + + return input === `${DEFAULT_BASE_URL}/models` + ? Response.json({ data: [{ id: builtin.id }] }) + : response; + }); + + const { context, publications } = refreshContext({ allowNetwork: true }); + expect(await refreshModels(config, context)).toEqual([builtin]); + expect(publications[0]?.persist).toMatchObject({ lastModified: 0, checkedAt: now }); + expect(publications[0]?.persist?.etag).toBeUndefined(); + now += ttl - 1; + await refreshModels(config, context); + expect(calls).toHaveLength(3); + }); + + it.each(["transport", "invalid-json"])( + "does not advance freshness on catalog %s failures", + async (failure) => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + + const config = provider(async (input, init) => { + const response = await successfulFetch(input, init); + + if (input === MODEL_CATALOG_URL) { + if (failure === "transport") throw new Error("private details"); + + return new Response("{"); + } + + return response; + }); + + const { context, publications } = refreshContext({ + allowNetwork: true, + stored: { models: [storedModel], checkedAt: start - ttl, lastModified: 0 }, + }); + + expect((await refreshModels(config, context))[0]?.id).toBe("cached-free"); + await refreshModels(config, context); + expect(calls).toHaveLength(5); + expect(publications).toEqual([]); + }, + ); + + it("rechecks legacy snapshots without Last-Modified instead of trusting checkedAt alone", async () => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + + const { context } = refreshContext({ + allowNetwork: true, + stored: { models: [storedModel], checkedAt: now }, + }); + + await refreshModels(config, context); + expect(calls).toHaveLength(3); + }); + + it("does not cache CLI-version lookup failures or advance freshness", async () => { + const calls: string[] = []; + + const config = provider(async (input) => { + calls.push(new Request(input).url); + + return new Response(null, { status: 503 }); + }); + + const { context, publications } = refreshContext({ allowNetwork: true }); + + await refreshModels(config, context); + await refreshModels(config, context); + expect(calls).toEqual([CLI_UPDATE_URL, CLI_NPM_URL, CLI_UPDATE_URL, CLI_NPM_URL]); + expect(publications).toEqual([]); + }); + + it("allows force to bypass successful and failed HTTP freshness checks", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + let failed = false; + + const config = provider(async (input, init) => { + const response = await successfulFetch(input, init); + + return failed && input !== CLI_UPDATE_URL ? new Response(null, { status: 503 }) : response; + }); + + const { context } = refreshContext({ allowNetwork: true, force: true }); + + await refreshModels(config, context); + failed = true; + await expect(refreshModels(config, context)).rejects.toThrow("HTTP 503"); + expect(calls).toHaveLength(5); + failed = false; + await refreshModels(config, context); + expect(calls).toHaveLength(7); + }); + + it.each(["offline", "aborted"])("never lets force override %s mode", async (mode) => { + const calls: string[] = []; + const config = provider(discoveryFetcher(calls)); + + const { context } = refreshContext({ + allowNetwork: mode !== "offline", + force: true, + signal: mode === "aborted" ? AbortSignal.abort() : undefined, + }); + + expect(await refreshModels(config, context)).toEqual(initialFreeModels()); + expect(calls).toEqual([]); + }); + + it("does not advance freshness after caller cancellation during discovery", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + const controller = new AbortController(); + + const config = provider(async (input, init) => { + if (input === `${DEFAULT_BASE_URL}/models`) controller.abort(); + + return successfulFetch(input, init); + }); + + const canceled = refreshContext({ allowNetwork: true, signal: controller.signal }); + + expect(await refreshModels(config, canceled.context)).toEqual(initialFreeModels()); + expect(canceled.publications).toEqual([]); + const retry = refreshContext({ allowNetwork: true }); + await refreshModels(config, retry.context); + expect(calls).toHaveLength(5); + expect(retry.publications).toHaveLength(1); + }); + + it("does not advance freshness after caller cancellation during version resolution", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + const controller = new AbortController(); + + const config = provider(async (input, init) => { + if (input === CLI_UPDATE_URL) controller.abort(); + + return successfulFetch(input, init); + }); + + const canceled = refreshContext({ allowNetwork: true, signal: controller.signal }); + + expect(await refreshModels(config, canceled.context)).toEqual(initialFreeModels()); + expect(calls).toEqual([CLI_UPDATE_URL]); + const retry = refreshContext({ allowNetwork: true }); + await refreshModels(config, retry.context); + expect(calls).toEqual([ + CLI_UPDATE_URL, + CLI_UPDATE_URL, + `${DEFAULT_BASE_URL}/models`, + MODEL_CATALOG_URL, + ]); + expect(retry.publications).toHaveLength(1); + }); + + it("does not advance freshness after deadline failures", async () => { + const calls: string[] = []; + const successfulFetch = discoveryFetcher(calls); + const deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + + const config = provider(async (input, init) => { + if (input === `${DEFAULT_BASE_URL}/models`) { + deadline.abort(new DOMException("deadline", "TimeoutError")); + } + + return successfulFetch(input, init); + }); + + const { context, publications } = refreshContext({ allowNetwork: true }); + + expect(await refreshModels(config, context)).toEqual(initialFreeModels()); + expect(publications).toEqual([]); + timeout.mockRestore(); + await refreshModels(config, context); + expect(calls).toHaveLength(5); + expect(publications).toHaveLength(1); + + const otherEndpoint = refreshContext({ + allowNetwork: true, + baseUrl: "https://proxy.test/v1", + }); + + await refreshModels(config, otherEndpoint.context); + expect(calls).toHaveLength(7); + expect(otherEndpoint.publications).toHaveLength(1); + }); + }); + it("provides a default export for Pi", () => { const { pi, state } = createFakePi(); diff --git a/packages/pi-opencode-free/tests/models.test.ts b/packages/pi-opencode-free/tests/models.test.ts index 87b5b04..07dc3a1 100644 --- a/packages/pi-opencode-free/tests/models.test.ts +++ b/packages/pi-opencode-free/tests/models.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_URL, MODEL_CATALOG_URL, + catalogFromStoredEntry, configsFromStoredModels, discoverFreeModels, discoverFreeModelsWithStatus, @@ -188,6 +189,27 @@ describe("stored model conversion", () => { }); }); +describe("persisted catalog metadata", () => { + it("restores a complete validated metadata body and its validator", () => { + const models = configsFromStoredModels([storedModel], "opencode-free"); + const stored = { models: [storedModel], catalog: models, lastModified: 123, etag: '"v1"' }; + expect(catalogFromStoredEntry(stored)).toEqual({ models, lastModified: 123, etag: '"v1"' }); + }); + + it.each([ + undefined, + { models: [], lastModified: 0 }, + { models: [], catalog: [], lastModified: Number.NaN }, + { models: [], catalog: [], lastModified: -1 }, + { models: [], catalog: [] }, + { models: [], catalog: "invalid", lastModified: 0 }, + { models: [], catalog: [null], lastModified: 0 }, + { models: [], catalog: [{ id: "bad" }], lastModified: 0 }, + ])("rejects unusable metadata bodies: %j", (stored) => { + expect(catalogFromStoredEntry(stored)).toBeUndefined(); + }); +}); + describe("free model discovery", () => { it("intersects live models with Pi's native free chat catalog", async () => { const { fetcher, calls } = createFetcher((url) => { @@ -338,6 +360,52 @@ describe("free model discovery", () => { expect(models).toEqual(bundledModels); }); + it("does not send a validator for an empty cached body or accept unsolicited 304", async () => { + const { fetcher, calls } = createFetcher((url) => + url === MODEL_CATALOG_URL + ? new Response(null, { status: 304 }) + : Response.json({ data: [{ id: "big-pickle" }] }), + ); + + const outcome = await discoverFreeModelsWithStatus({ + baseUrl: DEFAULT_BASE_URL, + headers: {}, + signal: new AbortController().signal, + fetcher, + allowNetwork: true, + catalog: { models: [], lastModified: 0, etag: '"empty"' }, + }); + + expect(outcome).toMatchObject({ status: "catalog-request-failed", httpStatus: 304 }); + expect( + calls.find((call) => call.url === MODEL_CATALOG_URL)?.headers.get("if-none-match"), + ).toBeNull(); + }); + + it("replaces validators on 200 and stores invalid Last-Modified as zero", async () => { + const { fetcher, calls } = createFetcher((url) => + url === MODEL_CATALOG_URL + ? Response.json(remoteCatalog, { headers: { "last-modified": "invalid" } }) + : Response.json({ data: [{ id: "big-pickle" }] }), + ); + + const outcome = await discoverFreeModelsWithStatus({ + baseUrl: DEFAULT_BASE_URL, + headers: {}, + signal: new AbortController().signal, + fetcher, + allowNetwork: true, + catalog: { models: initialFreeModels(), lastModified: 0, etag: '"old"' }, + }); + + expect(outcome.status).toBe("ok"); + expect(outcome.catalog?.lastModified).toBe(0); + expect(outcome.catalog?.etag).toBeUndefined(); + expect(calls.find((call) => call.url === MODEL_CATALOG_URL)?.headers.get("if-none-match")).toBe( + '"old"', + ); + }); + it("returns undefined when the native catalog is unavailable", async () => { const { fetcher } = createFetcher((url) => { if (url === MODEL_CATALOG_URL) return jsonResponse("unavailable", 503); @@ -587,7 +655,9 @@ describe("free model discovery", () => { ])("classifies %s", async (status, live, catalog) => { const { fetcher } = createFetcher((url) => (url === MODEL_CATALOG_URL ? catalog() : live())); - expect(await discoverFreeModelsWithStatus(options(fetcher))).toEqual({ status }); + const outcome = await discoverFreeModelsWithStatus(options(fetcher)); + expect(outcome.status).toBe(status); + expect(outcome.httpStatus).toBe(status.endsWith("request-failed") ? 503 : undefined); }); it("classifies an empty live catalog and an unknown free intersection", async () => { diff --git a/packages/pi-opencode-free/tests/stream.test.ts b/packages/pi-opencode-free/tests/stream.test.ts index 20cbeef..d8bf794 100644 --- a/packages/pi-opencode-free/tests/stream.test.ts +++ b/packages/pi-opencode-free/tests/stream.test.ts @@ -1,4 +1,5 @@ import { + isRetryableAssistantError, normalizeContext, type Model, type SimpleStreamOptions, @@ -187,12 +188,13 @@ describe("OpenCode provider stream boundary", () => { const reset = retryAfter === "3600" || retryAfter?.startsWith("Fri") - ? `Reported reset: ${formatResetTime(Date.parse("2026-10-23T13:00:00Z"))}.` + ? `Reset time: ${formatResetTime(Date.parse("2026-10-23T13:00:00Z"))}.` : "Reset time unavailable."; - expect(result.errorMessage).toBe(`429: OpenCode Free usage limit reached.\n${reset}`); + expect(result.errorMessage).toBe(`OpenCode Free quota exceeded.\n${reset}`); + expect(isRetryableAssistantError(result)).toBe(false); expect(fetcher).toHaveBeenCalledTimes(1); - expect(result.errorMessage).not.toMatch(/FreeUsageLimitError|Retry-After|https:\/\//u); + expect(result.errorMessage).not.toMatch(/Retry-After|https:\/\//u); } finally { clock.mockRestore(); } @@ -209,7 +211,20 @@ describe("OpenCode provider stream boundary", () => { const result = await native(model, summary, options).result(); expect(result.stopReason).toBe("error"); expect(result.errorMessage).toContain(type); - expect(result.errorMessage).not.toContain("Reported reset:"); + expect(result.errorMessage).not.toContain("Reset time:"); + }); + + it("keeps ordinary rate limits retryable", async () => { + const native = createFreeStream( + async () => refusal(429, "RateLimitError", "3600"), + fingerprint, + ); + + const result = await native(model, summary, options).result(); + expect(result.stopReason).toBe("error"); + expect(isRetryableAssistantError(result)).toBe(true); + expect(result.errorMessage).toContain("RateLimitError"); + expect(result.errorMessage).not.toContain("Reset time:"); }); it("keeps quota timing scoped to each concurrent request", async () => { @@ -228,7 +243,7 @@ describe("OpenCode provider stream boundary", () => { native(model, summary, options).result(), ]); - expect(first.errorMessage).toContain("Reported reset:"); + expect(first.errorMessage).toContain("Reset time:"); expect(second.errorMessage).toContain("Reset time unavailable."); }); @@ -364,23 +379,34 @@ describe("quota reset formatting", () => { it("uses the Pi process's locale and timezone by default", () => { expect(formatResetTime(resetAt)).toBe( - new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "long" }).format( - resetAt, - ), + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + timeZoneName: "short", + }).format(resetAt), ); + expect(formatResetTime(resetAt + 37_000)).toBe(formatResetTime(resetAt)); }); it.each([ - ["de_DE.UTF-8", "de-DE", "Europe/Berlin"], - ["en_US.UTF-8", "en-US", "America/New_York"], + ["de_DE.UTF-8", "de-DE", "Europe/Berlin", "02:00"], + ["en_US.UTF-8", "en-US", "America/New_York", "20:00"], + ["en_US.UTF-8", "en-US", "UTC", "00:00"], ])( "inherits %s (%s) and timezone %s from a fresh Pi host process", - (language, locale, timeZone) => { + (language, locale, timeZone, clock) => { const source = new URL("../src/quota.ts", import.meta.url).href; const code = ` import { formatResetTime } from ${JSON.stringify(source)}; - const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "long" }); + const formatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", hourCycle: "h23", minute: "2-digit", timeZoneName: "short", + }); console.log(formatResetTime(${resetAt})); console.log(formatter.format(${resetAt})); console.log(formatter.resolvedOptions().locale); @@ -395,6 +421,8 @@ describe("quota reset formatting", () => { .split("\n"); expect(lines[0]).toBe(lines[1]); + expect(lines[0]).toContain(clock); + expect(lines[0]).not.toMatch(/AM|PM|\d{2}:\d{2}:\d{2}/u); expect(lines[2]).toBe(locale); expect(lines[3]).toBe(timeZone); }, @@ -461,7 +489,7 @@ describe("quota timing", () => { expect(quotaResetAt(new Headers({ "retry-after": "0" }), now)).toBe(now); expect(quotaResetAt(new Headers({ "retry-after": "0.5" }), now)).toBe(now + 500); expect(usageLimitMessage(new Headers(), now)).toBe( - "429: OpenCode Free usage limit reached.\nReset time unavailable.", + "OpenCode Free quota exceeded.\nReset time unavailable.", ); }); }); diff --git a/packages/pi-web-fetch/CHANGELOG.md b/packages/pi-web-fetch/CHANGELOG.md index c53d8ae..c2da619 100644 --- a/packages/pi-web-fetch/CHANGELOG.md +++ b/packages/pi-web-fetch/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-10-25 + +### Changed + +- Show cache progress, focus queries, offsets, and a compact fetch summary in Pi's tool preview without adding model-facing output. +- Declare read-only network access hints for permission integrations and validate existing input limits in the tool schema. + ## [0.13.0] - 2026-10-24 ### Added @@ -256,7 +263,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d)) -[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...HEAD +[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...HEAD +[0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...pi-web-fetch-v0.14.0 [0.13.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...pi-web-fetch-v0.13.0 [0.12.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.1...pi-web-fetch-v0.12.0 [0.11.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.0...pi-web-fetch-v0.11.1 diff --git a/packages/pi-web-fetch/package.json b/packages/pi-web-fetch/package.json index 72071c8..7f629fe 100644 --- a/packages/pi-web-fetch/package.json +++ b/packages/pi-web-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@zeldrisho/pi-web-fetch", - "version": "0.13.0", + "version": "0.14.0", "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction", "keywords": [ "pi", diff --git a/packages/pi-web-fetch/src/index.ts b/packages/pi-web-fetch/src/index.ts index 69c435d..bf3e4c2 100644 --- a/packages/pi-web-fetch/src/index.ts +++ b/packages/pi-web-fetch/src/index.ts @@ -1,9 +1,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; +import { + FETCH_MAX_OFFSET_CHARACTERS, + FETCH_MAX_QUERY_CHARACTERS, + FETCH_MAX_URL_CHARACTERS, +} from "./limits"; import { redactUrlForDisplay } from "./redact"; import { formatCollapsibleOutput } from "./render"; -import { executeWebFetch, webFetchOutputSchema } from "./service"; +import { executeWebFetch, webFetchOutputSchema, type WebFetchDetails } from "./service"; export { ExpiringLruCache } from "./cache"; @@ -62,9 +67,21 @@ export { } from "./network/network.ts"; export const webFetchParameters = Type.Object({ - url: Type.String(), - query: Type.Optional(Type.String({ description: "Find sections matching this text." })), - offset: Type.Optional(Type.Integer({ description: "Start at this character offset." })), + url: Type.String({ minLength: 1, maxLength: FETCH_MAX_URL_CHARACTERS }), + query: Type.Optional( + Type.String({ + minLength: 1, + maxLength: FETCH_MAX_QUERY_CHARACTERS, + description: "Find sections matching this text.", + }), + ), + offset: Type.Optional( + Type.Integer({ + minimum: 0, + maximum: FETCH_MAX_OFFSET_CHARACTERS, + description: "Start at this character offset.", + }), + ), }); /** @@ -73,16 +90,18 @@ export const webFetchParameters = Type.Object({ * @param pi - The extension API instance */ export default function (pi: ExtensionAPI) { - pi.registerTool({ + pi.registerTool>({ name: "web_fetch", label: "Web Fetch", description: "Read a public webpage as Markdown.", parameters: webFetchParameters, outputSchema: webFetchOutputSchema, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + /** Renders the redacted URL, optional focus query, and character offset in the call preview. */ renderCall(args, theme) { return new Text( - `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}`, + `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}${args.query === undefined ? "" : ` query=${JSON.stringify(args.query)}`}${args.offset === undefined ? "" : ` offset=${args.offset}`}`, 0, 0, ); @@ -92,14 +111,34 @@ export default function (pi: ExtensionAPI) { return executeWebFetch(params, signal, onUpdate); }, + /** + * Renders partial text or "Fetching…" while pending. Completed text gets a + * detail summary when available and a collapsible body; absent text shows "No content". + */ renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Fetching…"), 0, 0); - const content = result.content.find((item) => item.type === "text"); + if (isPartial) return new Text(theme.fg("warning", content?.text || "Fetching…"), 0, 0); + + const details = result.details; + + const summary = + details?.characterCount !== undefined + ? [ + details.extractionDiagnostics?.botWall + ? "Blocked" + : `${details.characterCount} characters`, + details.cacheStatus ?? (details.cached ? "cached" : "miss"), + ...(details.focus + ? [`${details.focus.matchedSections}/${details.focus.totalSections} sections`] + : []), + ...(details.nextOffset === undefined ? [] : [`next offset=${details.nextOffset}`]), + ].join(" · ") + : ""; + return new Text( content?.type === "text" - ? formatCollapsibleOutput(content.text, expanded, theme) + ? `${theme.fg("dim", summary)}${formatCollapsibleOutput(content.text, expanded, theme)}` : theme.fg("dim", "No content"), 0, 0, diff --git a/packages/pi-web-fetch/tests/limits.test.ts b/packages/pi-web-fetch/tests/limits.test.ts index c22cdbd..9ee7ca9 100644 --- a/packages/pi-web-fetch/tests/limits.test.ts +++ b/packages/pi-web-fetch/tests/limits.test.ts @@ -4,11 +4,12 @@ import { executeWebFetch, webFetchParameters } from "../src/index"; import { FETCH_MAX_CHARACTERS, FETCH_MAX_OFFSET_CHARACTERS, + FETCH_MAX_QUERY_CHARACTERS, FETCH_MAX_URL_CHARACTERS, } from "../src/limits"; describe("web_fetch limit contracts", () => { - it("exposes only URL, focus query, and character offset without bounds", () => { + it("exposes only URL, focus query, and character offset with runtime bounds", () => { const schema = JSON.parse(JSON.stringify(webFetchParameters)); expect(Object.keys(schema.properties)).toEqual(["url", "query", "offset"]); expect(schema.properties.url.description).toBeUndefined(); @@ -17,10 +18,22 @@ describe("web_fetch limit contracts", () => { expect(JSON.stringify(schema)).not.toContain("maxCharacters"); expect( Check(webFetchParameters, { - url: "https://example.com", - offset: FETCH_MAX_OFFSET_CHARACTERS + 1, + url: "x".repeat(FETCH_MAX_URL_CHARACTERS), + query: "x".repeat(FETCH_MAX_QUERY_CHARACTERS), + offset: FETCH_MAX_OFFSET_CHARACTERS, }), ).toBe(true); + + for (const params of [ + { url: "" }, + { url: "x".repeat(FETCH_MAX_URL_CHARACTERS + 1) }, + { url: "https://example.com", query: "" }, + { url: "https://example.com", query: "x".repeat(FETCH_MAX_QUERY_CHARACTERS + 1) }, + { url: "https://example.com", offset: -1 }, + { url: "https://example.com", offset: 0.5 }, + { url: "https://example.com", offset: FETCH_MAX_OFFSET_CHARACTERS + 1 }, + ]) + expect(Check(webFetchParameters, params)).toBe(false); }); it.each([ diff --git a/packages/pi-web-fetch/tests/rendering.test.ts b/packages/pi-web-fetch/tests/rendering.test.ts index e54a796..1d92c4b 100644 --- a/packages/pi-web-fetch/tests/rendering.test.ts +++ b/packages/pi-web-fetch/tests/rendering.test.ts @@ -38,7 +38,11 @@ describe("web_fetch rendering", () => { // SAFETY: the captured value is the web_fetch SearchTool registered above; we assert // the render-call/result shape the extension exposes. const tool = registered as { - renderCall(args: { url: string }, theme: RenderTheme): RenderedComponent; + annotations: object; + renderCall( + args: { url: string; query?: string; offset?: number }, + theme: RenderTheme, + ): RenderedComponent; renderResult( result: { content: Array<{ type: "text"; text: string }>; @@ -47,6 +51,10 @@ describe("web_fetch rendering", () => { cached: boolean; truncated: boolean; characterCount: number; + cacheStatus?: string; + nextOffset?: number; + focus?: { matchedSections: number; totalSections: number }; + extractionDiagnostics?: { botWall: boolean }; }; }, options: { expanded: boolean; isPartial: boolean }, @@ -76,6 +84,12 @@ describe("web_fetch rendering", () => { .render(200) .join("\n"); + expect(tool.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }); + expect(collapsed).toContain(`${output.length} characters · miss`); expect(collapsed).toContain("Fetched line 1"); expect(collapsed).toContain("2 more lines, 12 total, Ctrl+O to expand"); expect(collapsed).not.toContain("Fetched line 11"); @@ -85,6 +99,52 @@ describe("web_fetch rendering", () => { tool.renderCall({ url: "https://example.com" }, renderTheme).render(200).join("\n"), ).toContain("web_fetch https://example.com"); + expect( + tool + .renderCall({ url: "https://example.com", query: "cache\npolicy", offset: 0 }, renderTheme) + .render(200) + .join("\n"), + ).toContain('query="cache\\npolicy" offset=0'); + + const focusedResult = tool + .renderResult( + { + ...result, + details: { + ...result.details, + cacheStatus: "hit", + nextOffset: 6000, + focus: { matchedSections: 2, totalSections: 5 }, + }, + }, + { expanded: false, isPartial: false }, + renderTheme, + ) + .render(200) + .join("\n"); + + expect(focusedResult).toContain("hit · 2/5 sections · next offset=6000"); + expect( + tool + .renderResult( + { ...result, details: { ...result.details, extractionDiagnostics: { botWall: true } } }, + { expanded: false, isPartial: false }, + renderTheme, + ) + .render(200) + .join("\n"), + ).toContain("Blocked"); + expect( + tool + .renderResult( + { ...result, content: [{ type: "text", text: "Revalidating cached content…" }] }, + { expanded: false, isPartial: true }, + renderTheme, + ) + .render(200) + .join("\n"), + ).toContain("Revalidating cached content…"); + const credentialedCall = tool .renderCall( { url: "https://user:password@example.com/page?token=secret&page=2" }, @@ -102,7 +162,7 @@ describe("web_fetch rendering", () => { .renderResult(result, { expanded: false, isPartial: true }, renderTheme) .render(200) .join("\n"), - ).toContain("Fetching…"); + ).toContain("Fetched line 1"); expect( tool .renderResult( diff --git a/packages/pi-web-fetch/tests/transport.test.ts b/packages/pi-web-fetch/tests/transport.test.ts index ac5d378..8ac2271 100644 --- a/packages/pi-web-fetch/tests/transport.test.ts +++ b/packages/pi-web-fetch/tests/transport.test.ts @@ -129,65 +129,82 @@ describe("web_fetch transport", () => { response.resume(); }); - it("abandons a hanging address and falls back inside the connect deadline", async () => { - // Accept connections on a second loopback address without sending response - // headers, so the first attempt deterministically hangs until its deadline. - // The URL uses a unique hostname so the keep-alive agent does not reuse a - // socket pooled under the fixture origin; the pinned lookup resolves it. - const fixturePort = new URL(origin).port; - const hanging = createServer(() => {}); + it("abandons a hanging attempt and falls back inside the connect deadline", async () => { + // Repeat the pinned address so both attempts use the standard loopback + // interface: extra 127/8 aliases are not configured on every platform. + // The first request stalls; only the fallback receives response headers. + let attempts = 0; + + const server = createServer((_request, response) => { + attempts += 1; + + if (attempts > 1) response.end("fallback"); + }); + await new Promise((resolve, reject) => { - hanging.once("error", reject); - hanging.listen(Number(fixturePort), "127.0.0.2", resolve); + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); }); try { + // SAFETY: The server has successfully listened on a TCP port above. + const address = server.address() as AddressInfo; const started = Date.now(); const response = await requestPinned( { - url: new URL(`http://hanging-test.invalid:${fixturePort}/html`), - address: "127.0.0.2", + url: new URL(`http://hanging-test.invalid:${address.port}/html`), + address: "127.0.0.1", family: 4, - addresses: ["127.0.0.2", "127.0.0.1"], + addresses: ["127.0.0.1", "127.0.0.1"], }, new AbortController().signal, { attemptTimeoutMs: 300 }, ); expect(response.statusCode).toBe(200); - // The first (hanging) address must have burned its connect deadline. + expect(attempts).toBe(2); expect(Date.now() - started).toBeGreaterThanOrEqual(300); response.resume(); } finally { - hanging.closeAllConnections(); - await new Promise((resolve) => hanging.close(() => resolve())); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); } }); it("reports the connect deadline as an unreachable address", async () => { // A single hanging address must fail with the friendly deadline message // instead of leaking the HTTP client's raw AbortError to the caller. - const fixturePort = new URL(origin).port; - const hanging = createServer(() => {}); + let attempts = 0; + + const hanging = createServer(() => { + attempts += 1; + }); + await new Promise((resolve, reject) => { hanging.once("error", reject); - hanging.listen(Number(fixturePort), "127.0.0.2", resolve); + hanging.listen(0, "127.0.0.1", resolve); }); try { + // SAFETY: The server has successfully listened on a TCP port above. + const address = hanging.address() as AddressInfo; + const started = Date.now(); + await expect( requestPinned( { - url: new URL(`http://hanging-only-test.invalid:${fixturePort}/html`), - address: "127.0.0.2", + url: new URL(`http://hanging-only-test.invalid:${address.port}/html`), + address: "127.0.0.1", family: 4, - addresses: ["127.0.0.2"], + addresses: ["127.0.0.1"], }, new AbortController().signal, { attemptTimeoutMs: 300 }, ), - ).rejects.toThrow("web_fetch could not reach 127.0.0.2 within 300 ms."); + ).rejects.toThrow("web_fetch could not reach 127.0.0.1 within 300 ms."); + expect(attempts).toBe(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(300); } finally { hanging.closeAllConnections(); await new Promise((resolve) => hanging.close(() => resolve())); diff --git a/packages/pi-web-search/CHANGELOG.md b/packages/pi-web-search/CHANGELOG.md index cf7132f..039f69b 100644 --- a/packages/pi-web-search/CHANGELOG.md +++ b/packages/pi-web-search/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-10-25 + +### Changed + +- Show cache progress and a compact search summary in Pi's tool preview without adding model-facing output. +- Declare read-only network access hints for permission integrations. + ## [0.13.0] - 2026-10-24 ### Added @@ -162,7 +169,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add bounded Brave web search ([cbc9491](https://github.com/zeldrisho/pi-packages/commit/cbc9491dce36555ab91b6bef203ec8b380596a89)) -[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.13.0...HEAD +[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.14.0...HEAD +[0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.13.0...pi-web-search-v0.14.0 [0.13.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.12.1...pi-web-search-v0.13.0 [0.12.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.12.0...pi-web-search-v0.12.1 [0.12.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.11.0...pi-web-search-v0.12.0 diff --git a/packages/pi-web-search/package.json b/packages/pi-web-search/package.json index 7a9c8db..e309d83 100644 --- a/packages/pi-web-search/package.json +++ b/packages/pi-web-search/package.json @@ -1,6 +1,6 @@ { "name": "@zeldrisho/pi-web-search", - "version": "0.13.0", + "version": "0.14.0", "description": "Pi extension for bounded Brave web search with generated-answer fallback", "keywords": [ "brave-search", diff --git a/packages/pi-web-search/src/index.ts b/packages/pi-web-search/src/index.ts index 3471f59..4d940ea 100644 --- a/packages/pi-web-search/src/index.ts +++ b/packages/pi-web-search/src/index.ts @@ -3,7 +3,7 @@ import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { SEARCH_WEB_MAX_QUERY_CHARACTERS } from "./limits"; import { formatCollapsibleOutput } from "./render"; -import { SearchRuntime, webSearchOutputSchema } from "./search"; +import { SearchRuntime, webSearchOutputSchema, type SearchDetails } from "./search"; export { ExpiringLruCache } from "./cache"; @@ -43,12 +43,13 @@ export default function (pi: ExtensionAPI) { const runtime = new SearchRuntime(); pi.on("session_shutdown", () => runtime.shutdown()); - pi.registerTool({ + pi.registerTool>({ name: "web_search", label: "Web Search", description: "Search the web. Use web_fetch to read sources.", parameters: webSearchParameters, outputSchema: webSearchOutputSchema, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, renderCall(args, theme) { return new Text( @@ -62,14 +63,25 @@ export default function (pi: ExtensionAPI) { return runtime.execute(params, signal, onUpdate, ctx?.cwd); }, + /** + * Renders partial text or "Searching…" while pending. Completed text gets a + * result and cache summary when available and a collapsible body; absent text shows "No results". + */ renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0); - const content = result.content.find((item) => item.type === "text"); + if (isPartial) return new Text(theme.fg("warning", content?.text || "Searching…"), 0, 0); + + const details = result.details; + + const summary = + details?.resultCount !== undefined + ? `${details.answer ? "Generated answer" : `${details.resultCount} results`} · ${details.cached ? "cached" : "miss"}${details.truncated ? " · truncated" : ""}` + : ""; + return new Text( content?.type === "text" - ? formatCollapsibleOutput(content.text, expanded, theme) + ? `${theme.fg("dim", summary)}${formatCollapsibleOutput(content.text, expanded, theme)}` : theme.fg("dim", "No results"), 0, 0, diff --git a/packages/pi-web-search/tests/harness.ts b/packages/pi-web-search/tests/harness.ts index c1f1e04..2769bd8 100644 --- a/packages/pi-web-search/tests/harness.ts +++ b/packages/pi-web-search/tests/harness.ts @@ -23,6 +23,7 @@ export interface RenderTheme { } export interface SearchTool { + annotations: object; renderCall(args: { query: string }, theme: RenderTheme): RenderedComponent; execute( toolCallId: string, diff --git a/packages/pi-web-search/tests/schema-rendering.test.ts b/packages/pi-web-search/tests/schema-rendering.test.ts index dfc6297..ff93359 100644 --- a/packages/pi-web-search/tests/schema-rendering.test.ts +++ b/packages/pi-web-search/tests/schema-rendering.test.ts @@ -172,6 +172,12 @@ describe("web_search schema rendering", () => { .render(200) .join("\n"); + expect(tool.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }); + expect(collapsed).toContain("3 results · miss"); expect(collapsed).toContain("First title"); expect(collapsed).toContain("more lines"); expect(collapsed).not.toContain("Third title"); @@ -182,10 +188,24 @@ describe("web_search schema rendering", () => { ).toContain("web_search pi extensions"); expect( tool - .renderResult(result, { expanded: false, isPartial: true }, renderTheme) + .renderResult( + { ...result, content: [{ type: "text", text: "Using cached results…" }] }, + { expanded: false, isPartial: true }, + renderTheme, + ) + .render(200) + .join("\n"), + ).toContain("Using cached results…"); + expect( + tool + .renderResult( + { ...result, details: { ...result.details, cached: true, truncated: true } }, + { expanded: false, isPartial: false }, + renderTheme, + ) .render(200) .join("\n"), - ).toContain("Searching…"); + ).toContain("3 results · cached · truncated"); expect( tool .renderResult( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3055f7..d990cee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: 'catalog:' version: 1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.3)(supports-color@7.2.0)(ws@8.22.0) + typebox: + specifier: 'catalog:' + version: 1.4.2 typescript: specifier: 'catalog:' version: 7.0.2