@pwguler/pi-pengepul-provider 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pwguler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @pwguler/pi-pengepul-provider
2
+
3
+ A custom provider for [pi](https://github.com/earendil-works/pi) that connects to
4
+ [pengepul](https://github.com/pwguler/pengepul), a local relay that pools your
5
+ Claude / Codex subscription accounts and serves them over native wire protocols.
6
+
7
+ pengepul pools several subscription accounts per provider and spreads requests
8
+ across them, so pi runs on your subscription instead of a per-token API key.
9
+ This extension registers pengepul as a provider so `/model` shows the models
10
+ your relay serves.
11
+
12
+ ## Install
13
+
14
+ From npm:
15
+
16
+ ```sh
17
+ pi install npm:@pwguler/pi-pengepul-provider
18
+ ```
19
+
20
+ Or straight from GitHub (no npm account needed):
21
+
22
+ ```sh
23
+ pi install git:github.com/pwguler/pi-pengepul-provider
24
+ ```
25
+
26
+ Pin a release so updates don't move under you:
27
+
28
+ ```sh
29
+ pi install git:github.com/pwguler/pi-pengepul-provider@v0.1.0
30
+ ```
31
+
32
+ To update a git-installed package later:
33
+
34
+ ```sh
35
+ pi install git:github.com/pwguler/pi-pengepul-provider@v0.2.0
36
+ ```
37
+
38
+ Start or reload pi, then select a model with `/model`. Pengepul models are
39
+ prefixed `pengepul/<id>`. To try it without installing, use
40
+ `pi -e git:github.com/pwguler/pi-pengepul-provider`.
41
+
42
+ ## What it does
43
+
44
+ - Registers the `pengepul` provider against your relay's base URL
45
+ (`http://127.0.0.1:8317` by default).
46
+ - Discovers models from `GET /v1/models`, maps each to the right wire:
47
+ - `claude-*` / `anthropic/*` and `owned_by: anthropic` → Anthropic Messages
48
+ (`POST /v1/messages`),
49
+ - `gpt-*` / `o<N>` / `codex-*` and `<provider>/<model>` → OpenAI Chat
50
+ Completions (`POST /v1/chat/completions`).
51
+ - Takes context window, max output, pricing, and image input from what the
52
+ relay advertises (pengepul >= 0.6.0 sends `context_window`,
53
+ `max_output_tokens`, `input_modalities`, `pricing`). Fields the relay omits
54
+ fall back to pi's builtin catalog for the same id, then to family
55
+ heuristics.
56
+ - Caches the last successful catalog at `<agent-dir>/pengepul-models.json`, so
57
+ startup does not wait on the network and a briefly absent relay is covered.
58
+ - Reuses pi's built-in stream functions for both wires — no custom transport.
59
+ - Registers no commands: the catalog refreshes on every startup.
60
+
61
+ ## Configuration
62
+
63
+ | Setting | Env var | Default |
64
+ |---|---|---|
65
+ | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317` |
66
+ | API key | `PENGEPUL_API_KEY` | read from `~/.pengepul/config.yaml` |
67
+ | Config path | `PENGEPUL_CONFIG` | `~/.pengepul/config.yaml` |
68
+ | Model cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
69
+ | Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
70
+
71
+ The API key is read from `~/.pengepul/config.yaml` (`api-keys[0]`, the
72
+ `sk-local-...` key pengepul generates on first run) unless `PENGEPUL_API_KEY`
73
+ is set.
74
+
75
+ ## Notes
76
+
77
+ - pengepul >= 0.6.0 advertises per-model context windows, output caps,
78
+ modalities, and pricing on `/v1/models`, and that is what the provider
79
+ registers; older relays (or ids the metadata has not reached) fall back to
80
+ pi's builtin catalog. Your subscription, not a per-token meter, is what
81
+ pengepul bills against — displayed costs are upstream list prices.
82
+ - The relay must be running and reachable for discovery to succeed. Without a
83
+ cached catalog on a first start, pengepul models stay unavailable until a
84
+ start with the relay up.
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ bun test
90
+ npx tsc --noEmit
91
+ bun scripts/e2e-live.ts # live e2e against a running relay; sends one tiny completion
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@pwguler/pi-pengepul-provider",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "pi custom provider for pengepul, a local relay that pools your Claude/Codex subscriptions. Connects pi to http://127.0.0.1:8317 over the native Anthropic Messages and OpenAI Chat Completions wires.",
6
+ "license": "MIT",
7
+ "author": "pwguler",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pwguler/pi-pengepul-provider.git"
11
+ },
12
+ "main": "./src/index.ts",
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "pi",
20
+ "pi-extension",
21
+ "pi-package",
22
+ "pi-pengepul-provider",
23
+ "pengepul",
24
+ "provider"
25
+ ],
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-agent-core": "*",
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "@earendil-works/pi-tui": "*",
31
+ "typebox": "*"
32
+ },
33
+ "scripts": {
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "bun test"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./src/index.ts"
43
+ ]
44
+ },
45
+ "devDependencies": {
46
+ "@types/bun": "^1",
47
+ "typescript": "^5.9.3"
48
+ }
49
+ }
package/src/api-key.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Resolve pengepul's local API key.
3
+ *
4
+ * pengepul authenticates every request with a static key from its config:
5
+ * `~/.pengepul/config.yaml`, under `api-keys:` (the first is generated on first
6
+ * run and is `sk-local-...`). Clients send it as `Authorization: Bearer <key>`
7
+ * or `x-api-key: <key>`.
8
+ *
9
+ * Precedence: an explicit env override wins, then the config file. The config
10
+ * read is injected so this module stays io-free and testable.
11
+ */
12
+
13
+ const DEFAULT_CONFIG_PATH = "~/.pengepul/config.yaml"
14
+
15
+ export type ApiKeySource = "env" | "config" | "none"
16
+
17
+ export interface ApiKeyResolution {
18
+ /** The resolved key, or undefined when none could be found. */
19
+ key?: string
20
+ source: ApiKeySource
21
+ }
22
+
23
+ export const API_KEY_ENV = "PENGEPUL_API_KEY"
24
+ export const CONFIG_PATH_ENV = "PENGEPUL_CONFIG"
25
+
26
+ /**
27
+ * Resolve the key from an env map and a config-file reader.
28
+ *
29
+ * @param env the environment (or a test substitution for it).
30
+ * @param readConfig reads a config file's text by path, or undefined when the
31
+ * path is unwritable/absent. Injected to keep this pure.
32
+ */
33
+ export function resolveApiKey(
34
+ env: Record<string, string | undefined>,
35
+ readConfig: (path: string) => string | undefined,
36
+ ): ApiKeyResolution {
37
+ const envKey = env[API_KEY_ENV]
38
+ if (envKey && envKey.trim() !== "") return { key: envKey, source: "env" }
39
+
40
+ const configPath = env[CONFIG_PATH_ENV] ?? DEFAULT_CONFIG_PATH
41
+ const configText = readConfig(configPath)
42
+ if (configText === undefined) return { source: "none" }
43
+
44
+ const keys = extractApiKeys(configText)
45
+ const first = keys[0]
46
+ if (first) return { key: first, source: "config" }
47
+
48
+ return { source: "none" }
49
+ }
50
+
51
+ /**
52
+ * Extract `api-keys:` entries from pengepul's YAML config, without a YAML
53
+ * dependency. Handles both the inline-flow form and the block-sequence form:
54
+ *
55
+ * api-keys: [sk-local-a, sk-local-b]
56
+ * api-keys:
57
+ * - sk-local-a
58
+ * - sk-local-b
59
+ */
60
+ export function extractApiKeys(configText: string): string[] {
61
+ const lines = configText.split(/\r?\n/)
62
+ const keys: string[] = []
63
+
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i]
66
+ if (line === undefined) continue
67
+
68
+ const match = /^\s*api-keys:\s*(.*)$/.exec(line)
69
+ if (!match) continue
70
+
71
+ const rest = (match[1] ?? "").trim()
72
+ if (rest.startsWith("[")) {
73
+ // Inline flow sequence: [a, b, c]
74
+ for (const token of rest.slice(1, -1).split(",")) {
75
+ const key = token.trim().replace(/^["']|["']$/g, "")
76
+ if (key) keys.push(key)
77
+ }
78
+ } else if (rest === "" || rest === "|" || rest === ">") {
79
+ // Block sequence follows. Sequence entries may sit at any indent —
80
+ // pengepul itself writes them flush with the key (`- sk-local-…`) —
81
+ // so scan forward through blanks, comments, and `- ` entries and
82
+ // stop at the first line that starts another key.
83
+ for (let j = i + 1; j < lines.length; j++) {
84
+ const item = lines[j]
85
+ if (item === undefined) continue
86
+ const token = item.trim()
87
+ if (token === "" || token.startsWith("#")) continue
88
+ if (!token.startsWith("-")) break
89
+ const value = token.slice(1).trim().replace(/^["']|["']$/g, "")
90
+ if (value) keys.push(value)
91
+ }
92
+ } else {
93
+ // Single inline scalar: api-keys: sk-local-a
94
+ const value = rest.replace(/^["']|["']$/g, "")
95
+ if (value) keys.push(value)
96
+ }
97
+ break
98
+ }
99
+
100
+ return keys
101
+ }
package/src/config.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Resolve pengepul connection and cache settings from the environment.
3
+ *
4
+ * Kept small and pure: takes an env map and the host's agent dir, returns the
5
+ * connection defaults a test can pin down.
6
+ */
7
+
8
+ import { join } from "node:path"
9
+
10
+ import { DEFAULT_RELAY_BASE } from "./models.ts"
11
+
12
+ export const RELAY_BASE_ENV = "PENGEPUL_BASE_URL"
13
+ export const MODELS_CACHE_ENV = "PENGEPUL_MODELS_CACHE"
14
+ export const MODELS_TIMEOUT_MS_ENV = "PENGEPUL_MODELS_TIMEOUT_MS"
15
+
16
+ export interface PengepulSettings {
17
+ /** The relay base URL (may or may not end in /v1). */
18
+ relayBase: string
19
+ /** Where the model catalog is cached; defaults to `<agent-dir>/pengepul-models.json`. */
20
+ modelsCachePath: string
21
+ /** Discovery timeout in milliseconds. */
22
+ modelsTimeoutMs: number
23
+ }
24
+
25
+ export function resolveSettings(
26
+ env: Record<string, string | undefined>,
27
+ agentDir: string,
28
+ ): PengepulSettings {
29
+ const relayBase = env[RELAY_BASE_ENV] ?? DEFAULT_RELAY_BASE
30
+ const modelsCachePath =
31
+ env[MODELS_CACHE_ENV] ?? join(agentDir, "pengepul-models.json")
32
+ const rawTimeout = env[MODELS_TIMEOUT_MS_ENV]
33
+ const parsedTimeout = rawTimeout ? Number(rawTimeout) : NaN
34
+ const modelsTimeoutMs =
35
+ Number.isFinite(parsedTimeout) && parsedTimeout > 0
36
+ ? parsedTimeout
37
+ : 10_000
38
+
39
+ return { relayBase, modelsCachePath, modelsTimeoutMs }
40
+ }
package/src/dialect.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure pengepul dialect mapping.
3
+ *
4
+ * pengepul is a local relay that speaks *both* native wires and routes each
5
+ * request by model id. This module maps a model id (and the owned_by value
6
+ * pengepul advertises on `/v1/models`) to the pi-ai API dialect pi should use,
7
+ * and to the base URL pi's built-in stream functions should point at.
8
+ *
9
+ * No pi imports, no io: this is unit-testable with a table of inputs.
10
+ *
11
+ * pengepul routing (from its `resolve_id` / heuristic):
12
+ * - `anthropic/...` and bare `claude-*` / `anthropic` -> Anthropic Messages
13
+ * (`POST /v1/messages`). pi's Anthropic SDK appends `/v1/messages`, so the
14
+ * base URL is the relay root (no trailing `/v1`).
15
+ * - `codex/...`, bare `gpt-*` / `o<N>` / `codex-*`, and any other
16
+ * `<provider>/<model>` prefix -> OpenAI Chat Completions
17
+ * (`POST /v1/chat/completions`). pi's OpenAI SDK appends `/chat/completions`,
18
+ * so the base URL must end in `/v1`.
19
+ */
20
+
21
+ export type PengepulDialect = "anthropic-messages" | "openai-completions"
22
+
23
+ /** Which wire pengepul serves a given model id on. */
24
+ export function dialectForModelId(id: string): PengepulDialect {
25
+ const slash = id.indexOf("/")
26
+ if (slash !== -1) {
27
+ const prefix = id.slice(0, slash).toLowerCase()
28
+ return prefix === "anthropic" ? "anthropic-messages" : "openai-completions"
29
+ }
30
+
31
+ const lower = id.toLowerCase()
32
+ if (lower.startsWith("claude-") || lower.startsWith("anthropic")) {
33
+ return "anthropic-messages"
34
+ }
35
+ if (lower.startsWith("gpt-") || lower.startsWith("codex-") || isOpenAIModelPattern(lower)) {
36
+ return "openai-completions"
37
+ }
38
+
39
+ // Unknown bare id: prefer Messages only when the upstream explicitly says so;
40
+ // otherwise assume Chat Completions (the relay's generic-namespace default).
41
+ return "openai-completions"
42
+ }
43
+
44
+ function isOpenAIModelPattern(lower: string): boolean {
45
+ // o1, o3-mini, o4, ... — the OpenAI reasoning-family names pengepul routes to codex.
46
+ const first = lower[0]
47
+ const second = lower[1]
48
+ if (first === undefined || second === undefined) return false
49
+ if (lower.length >= 2 && first === "o" && /^[1-9]/.test(second)) return true
50
+ return false
51
+ }
52
+
53
+ /**
54
+ * The base URL pi's built-in stream should use for a dialect.
55
+ *
56
+ * Accepts a relay base that may or may not end in `/v1` (pengepul's own config
57
+ * notes "the base url may end in /v1"); we normalize so the Anthropic Messages
58
+ * SDK gets the root and the OpenAI Chat Completions SDK gets `/v1`, and neither
59
+ * produces a doubled `/v1/v1`.
60
+ */
61
+ export function baseUrlForDialect(baseUrl: string, dialect: PengepulDialect): string {
62
+ const root = normalizeRootBaseUrl(baseUrl)
63
+ return dialect === "anthropic-messages" ? root : `${root}/v1`
64
+ }
65
+
66
+ /** Strip a trailing `/v1` (and any trailing slashes) so we can re-add it per dialect. */
67
+ export function normalizeRootBaseUrl(baseUrl: string): string {
68
+ let out = baseUrl.replace(/\/+$/g, "")
69
+ if (out.endsWith("/v1")) out = out.slice(0, -3)
70
+ return out.replace(/\/+$/g, "")
71
+ }
72
+
73
+ /** The discovery endpoint for a relay base. */
74
+ export function modelsUrl(baseUrl: string): string {
75
+ return `${normalizeRootBaseUrl(baseUrl)}/v1/models`
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @pwguler/pi-pengepul-provider entry point - the real edge adapter.
3
+ *
4
+ * Registers pengepul as a pi custom provider. pengepul is a local relay
5
+ * (`http://127.0.0.1:8317`) that pools your Claude/Codex subscriptions and
6
+ * speaks both native wires. The pure core lives in `./dialect.ts`, `./models.ts`
7
+ * and `./runtime.ts`; this file adapts them to the pi ExtensionAPI seam.
8
+ */
9
+
10
+ import {
11
+ getAgentDir,
12
+ type ExtensionAPI,
13
+ type ProviderConfig,
14
+ } from "@earendil-works/pi-coding-agent"
15
+ import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"
16
+ import { readFileSync } from "node:fs"
17
+
18
+ import { resolveApiKey } from "./api-key.ts"
19
+ import { resolveSettings } from "./config.ts"
20
+ import { modelsUrl } from "./dialect.ts"
21
+ import {
22
+ loadCachedPengepulModels,
23
+ loadPengepulModels,
24
+ toProviderModelConfigs,
25
+ type PengepulModel,
26
+ } from "./models.ts"
27
+ import { createPengepulRuntime } from "./runtime.ts"
28
+
29
+ function expandHome(path: string): string {
30
+ if (path === "~") return process.env.HOME ?? path
31
+ if (path.startsWith("~/")) return `${process.env.HOME ?? ""}${path.slice(1)}`
32
+ return path
33
+ }
34
+
35
+ function readConfigText(path: string): string | undefined {
36
+ try {
37
+ return readFileSync(expandHome(path), "utf-8")
38
+ } catch {
39
+ return undefined
40
+ }
41
+ }
42
+
43
+ /** The metadata fields the lookup extracts from a pi catalog entry. */
44
+ function metaFromModel(model: NonNullable<ReturnType<typeof getBuiltinModel>>) {
45
+ return {
46
+ reasoning: model.reasoning,
47
+ contextWindow: model.contextWindow,
48
+ maxTokens: model.maxTokens,
49
+ input: model.input,
50
+ cost: model.cost,
51
+ ...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Multi-catalog lookup over pi's builtin models. A commandcode id can live
57
+ * in several catalogs: verbatim under an aggregator (`openrouter`, `baseten`,
58
+ * `together`, ...), bare under a vendor catalog (`deepseek`, `google`,
59
+ * `xai`, ...), or bare lowercased. Try those shapes in that order and take
60
+ * the first hit; reasoning metadata and the thinkingLevelMap flow from it.
61
+ */
62
+ function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<typeof metaFromModel> | undefined {
63
+ type Entry = { provider: string; id: string };
64
+ const exact = new Map<string, Entry>()
65
+ const lower = new Map<string, Entry>()
66
+ for (const provider of getBuiltinProviders()) {
67
+ for (const model of getBuiltinModels(provider) ?? []) {
68
+ if (!exact.has(model.id)) exact.set(model.id, { provider, id: model.id })
69
+ if (!lower.has(model.id.toLowerCase())) lower.set(model.id.toLowerCase(), { provider, id: model.id })
70
+ }
71
+ }
72
+ const segments = new Map<string, Entry>()
73
+ for (const [key, entry] of exact) {
74
+ const slash = key.lastIndexOf("/")
75
+ const segment = slash === -1 ? key : key.slice(slash + 1)
76
+ if (!segments.has(segment)) segments.set(segment, entry)
77
+ }
78
+
79
+ return (id, dialect) => {
80
+ const candidates: Array<Entry | undefined> = [
81
+ exact.get(id),
82
+ exact.get(bareOf(id)),
83
+ segments.get(bareOf(id)),
84
+ lower.get(id.toLowerCase()),
85
+ lower.get(bareOf(id).toLowerCase()),
86
+ ]
87
+ for (const candidate of candidates) {
88
+ if (candidate === undefined) continue
89
+ const model = getBuiltinModel(candidate.provider as never, candidate.id as never)
90
+ if (model) return metaFromModel(model)
91
+ }
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ function bareOf(id: string): string {
97
+ const slash = id.lastIndexOf("/")
98
+ return slash === -1 ? id : id.slice(slash + 1)
99
+ }
100
+
101
+ function createProviderConfigFactory(relayBase: string, apiKey: string | undefined) {
102
+ return (models: readonly PengepulModel[]): ProviderConfig => ({
103
+ name: "Pengepul",
104
+ baseUrl: relayBase,
105
+ apiKey: apiKey ?? "$PENGEPUL_API_KEY",
106
+ api: "anthropic-messages",
107
+ models: toProviderModelConfigs(models, relayBase),
108
+ })
109
+ }
110
+
111
+ /**
112
+ * Model discovery and provider registration are async: the relay's catalog is
113
+ * fetched live (and cached), so the runtime handles the cache-first, then
114
+ * live-refresh dance. The config factory pins the base URL and key once.
115
+ */
116
+ export default async function (pi: ExtensionAPI) {
117
+ const settings = resolveSettings(process.env, getAgentDir())
118
+ const apiKey = resolveApiKey(process.env, readConfigText).key
119
+
120
+ // The relay advertises only ids; context/pricing/modality numbers come from
121
+ // pi's builtin catalogs, searched across providers until one knows the id
122
+ // (aggregator, vendor, and last-segment shapes). The lookup is injected so
123
+ // the catalog logic stays free of pi-ai imports.
124
+ const lookupBuiltin = createBuiltinLookup()
125
+
126
+ const runtime = createPengepulRuntime(pi, {
127
+ loadModels: (signal) =>
128
+ loadPengepulModels({
129
+ url: modelsUrl(settings.relayBase),
130
+ apiKey,
131
+ cachePath: settings.modelsCachePath,
132
+ relayBase: settings.relayBase,
133
+ timeoutMs: settings.modelsTimeoutMs,
134
+ lookupBuiltin,
135
+ signal,
136
+ }),
137
+ loadCachedModels: () => loadCachedPengepulModels(settings.modelsCachePath),
138
+ createProviderConfig: createProviderConfigFactory(settings.relayBase, apiKey),
139
+ })
140
+
141
+ pi.on("session_shutdown", () => {
142
+ runtime.dispose()
143
+ })
144
+
145
+ await runtime.initialize()
146
+ }
package/src/models.ts ADDED
@@ -0,0 +1,596 @@
1
+ /**
2
+ * pengepul model discovery.
3
+ *
4
+ * Fetches the relay's model catalog (`GET /v1/models`) and maps it into the
5
+ * pi-ai `ProviderModelConfig` shape, mirroring the commandcode provider's
6
+ * cached-catalog design: a fresh fetch wins, a valid cache covers a briefly
7
+ * absent relay, and an empty result leaves pengepul models unavailable until
8
+ * the next successful startup refresh.
9
+ *
10
+ * The relay advertises id/owned_by and, since pengepul 0.6.0, optional
11
+ * per-model metadata: `context_window`, `max_output_tokens`,
12
+ * `input_modalities`, and `pricing`. That is the first-party truth for what
13
+ * this relay actually serves, so it wins. The rollout is partial (some ids
14
+ * still come back with ids only), so two fallbacks remain: pi's builtin
15
+ * catalog - pengepul forwards the same ids upstream, so pi's numbers are the
16
+ * next best source - and then family heuristics. The catalog lookup is
17
+ * injected, so this module imports nothing from pi-ai and tests pin it.
18
+ *
19
+ * The network/cache are injected so the catalog logic stays testable.
20
+ */
21
+
22
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
23
+ import { randomUUID } from "node:crypto"
24
+ import { dirname } from "node:path"
25
+
26
+ import { baseUrlForDialect, dialectForModelId } from "./dialect.ts"
27
+ import type { PengepulDialect } from "./dialect.ts"
28
+
29
+ export const DEFAULT_RELAY_BASE = "http://127.0.0.1:8317"
30
+ export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
31
+
32
+ const DEFAULT_CONTEXT_WINDOW = 200_000
33
+ const DEFAULT_MAX_TOKENS = 64_000
34
+ const ZERO_COST: ModelCostRates = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
35
+ /** v2 cached pre-multi-catalog lookups (commandcode ids missed reasoning); reject it. */
36
+ const MODEL_CACHE_VERSION = 3
37
+
38
+ export type ModelInput = ("text" | "image")[]
39
+
40
+ export interface ModelCostRates {
41
+ input: number
42
+ output: number
43
+ cacheRead: number
44
+ cacheWrite: number
45
+ }
46
+
47
+ /** The metadata pi's builtin catalog carries for a model the relay advertises. */
48
+ export interface BuiltinModelMeta {
49
+ reasoning: boolean
50
+ contextWindow: number
51
+ maxTokens: number
52
+ input: ModelInput
53
+ cost: ModelCostRates
54
+ /** Level-to-wire mapping for reasoning params; undefined = pi's default. */
55
+ thinkingLevelMap?: Record<string, string | null>
56
+ }
57
+
58
+ /**
59
+ * Resolves a model's metadata from pi's builtin catalogs. The core passes the
60
+ * relay id and its dialect; the edge decides which catalogs to search
61
+ * (commandcode ids carry a routing prefix, so one id may exist verbatim under
62
+ * `openrouter`/`baseten`/... and bare under a vendor catalog like `deepseek`).
63
+ * Undefined when no pi catalog knows the id.
64
+ */
65
+ export type BuiltinModelLookup = (id: string, dialect: PengepulDialect) => BuiltinModelMeta | undefined
66
+
67
+ /** A pengepul model ready to become a pi `ProviderModelConfig`. */
68
+ export interface PengepulModel {
69
+ id: string
70
+ name: string
71
+ dialect: PengepulDialect
72
+ reasoning: boolean
73
+ input: ModelInput
74
+ cost: ModelCostRates
75
+ contextWindow: number
76
+ maxTokens: number
77
+ /** Level-to-wire mapping inherited from the catalog; undefined = pi's default. */
78
+ thinkingLevelMap?: Record<string, string | null>
79
+ }
80
+
81
+ export interface PengepulModelSource {
82
+ models: readonly PengepulModel[]
83
+ /** "live" = fetched from the relay; "cache" = read from disk; "empty" = none. */
84
+ source: "live" | "cache" | "empty"
85
+ warning?: string
86
+ }
87
+
88
+ /** `anthropic/claude-opus-5` -> `claude-opus-5` (the id upstream actually serves). */
89
+ export function bareId(id: string): string {
90
+ const slash = id.indexOf("/")
91
+ return slash === -1 ? id : id.slice(slash + 1)
92
+ }
93
+
94
+ /**
95
+ * Fallback for models pi's catalog does not know. Family-shaped but
96
+ * conservative; the builtin lookup wins whenever it has the id. The final
97
+ * branch treats unknown ids as non-reasoning, so an unrecognized id never
98
+ * gets reasoning params the upstream may reject.
99
+ */
100
+ function heuristicMeta(id: string): BuiltinModelMeta {
101
+ const lower = bareId(id).toLowerCase()
102
+ if (lower.startsWith("claude-")) {
103
+ return { reasoning: true, contextWindow: 200_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
104
+ }
105
+ if (lower.startsWith("gpt-") || lower.startsWith("codex-") || /^o[1-9]/.test(lower)) {
106
+ return { reasoning: true, contextWindow: 272_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
107
+ }
108
+ return {
109
+ reasoning: false,
110
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
111
+ maxTokens: DEFAULT_MAX_TOKENS,
112
+ input: ["text"],
113
+ cost: ZERO_COST,
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Metadata pengepul itself advertises for a model (pengepul >= 0.6.0).
119
+ * Every field is optional: the rollout is partial and older relays send none.
120
+ * Returns undefined when the entry carries no usable metadata at all.
121
+ */
122
+ export function metaFromRelayEntry(
123
+ entry: Record<string, unknown>,
124
+ ): Partial<BuiltinModelMeta> | undefined {
125
+ const contextWindow = optionalPositiveNumber(entry["context_window"])
126
+ const maxTokens = optionalPositiveNumber(entry["max_output_tokens"])
127
+ const input = optionalInputModalities(entry["input_modalities"])
128
+ const cost = optionalPricing(entry["pricing"])
129
+
130
+ if (
131
+ contextWindow === undefined &&
132
+ maxTokens === undefined &&
133
+ input === undefined &&
134
+ cost === undefined
135
+ ) {
136
+ return undefined
137
+ }
138
+ return {
139
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
140
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
141
+ ...(input !== undefined ? { input } : {}),
142
+ ...(cost !== undefined ? { cost } : {}),
143
+ }
144
+ }
145
+
146
+ function optionalPositiveNumber(value: unknown): number | undefined {
147
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
148
+ }
149
+
150
+ function optionalInputModalities(value: unknown): ModelInput | undefined {
151
+ if (!Array.isArray(value)) return undefined
152
+ const input = value.filter(
153
+ (entry): entry is "text" | "image" => entry === "text" || entry === "image",
154
+ )
155
+ return input.length > 0 ? input : undefined
156
+ }
157
+
158
+ function optionalPricing(value: unknown): ModelCostRates | undefined {
159
+ if (!isRecord(value)) return undefined
160
+ const input = optionalRate(value["input_per_million"])
161
+ const output = optionalRate(value["output_per_million"])
162
+ const cacheRead = optionalRate(value["cache_read_per_million"])
163
+ const cacheWrite = optionalRate(value["cache_write_per_million"])
164
+ if (input === undefined && output === undefined) return undefined
165
+ return {
166
+ input: input ?? 0,
167
+ output: output ?? 0,
168
+ cacheRead: cacheRead ?? 0,
169
+ cacheWrite: cacheWrite ?? 0,
170
+ }
171
+ }
172
+
173
+ function optionalRate(value: unknown): number | undefined {
174
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined
175
+ }
176
+
177
+ /**
178
+ * Resolve a model's metadata, most trustworthy source first:
179
+ * 1. what pengepul advertises (first-party for this relay),
180
+ * 2. pi's builtin catalogs for the id (the same id upstream),
181
+ * 3. family heuristics.
182
+ * Sources merge per field, so a relay that sends only `context_window` still
183
+ * picks up pricing and modalities from the catalog.
184
+ */
185
+ function metaFor(
186
+ entry: Record<string, unknown>,
187
+ id: string,
188
+ dialect: PengepulDialect,
189
+ lookup: BuiltinModelLookup | undefined,
190
+ ): BuiltinModelMeta {
191
+ const base = lookup?.(id, dialect) ?? heuristicMeta(id)
192
+ const relay = metaFromRelayEntry(entry)
193
+ return relay ? { ...base, ...relay } : base
194
+ }
195
+
196
+ function toPengepulModel(
197
+ entry: Record<string, unknown>,
198
+ lookup: BuiltinModelLookup | undefined,
199
+ ): PengepulModel {
200
+ const id = stringField(entry, "id")
201
+ const dialect = dialectForModelId(id)
202
+ const meta = metaFor(entry, id, dialect, lookup)
203
+ const ownedBy = entry["owned_by"]
204
+
205
+ // A model the relay tags `anthropic` is Claude-family, hence reasoning-
206
+ // capable, even when pi's catalog does not know its exact id yet.
207
+ const reasoning = meta.reasoning || ownedBy === "anthropic"
208
+
209
+ return {
210
+ id,
211
+ name: displayName(id),
212
+ dialect,
213
+ reasoning,
214
+ input: [...meta.input],
215
+ cost: { ...meta.cost },
216
+ contextWindow: meta.contextWindow,
217
+ maxTokens: meta.maxTokens,
218
+ ...(meta.thinkingLevelMap ? { thinkingLevelMap: { ...meta.thinkingLevelMap } } : {}),
219
+ }
220
+ }
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value)
224
+ }
225
+
226
+ function stringField(record: Record<string, unknown>, key: string): string {
227
+ const value = record[key]
228
+ if (typeof value !== "string" || value.length === 0) {
229
+ throw new Error(`Expected ${key} to be a non-empty string`)
230
+ }
231
+ return value
232
+ }
233
+
234
+ /** Parse the raw `/v1/models` body into models. Throws on a malformed body. */
235
+ export function modelsFromApiResponse(
236
+ value: unknown,
237
+ lookupBuiltin?: BuiltinModelLookup,
238
+ ): readonly PengepulModel[] {
239
+ if (!isRecord(value)) throw new Error("Expected models response to be an object")
240
+ if (value["object"] !== "list") throw new Error("Expected models response object to be 'list'")
241
+
242
+ const data = value["data"]
243
+ if (!Array.isArray(data)) throw new Error("Expected models response data to be an array")
244
+ if (data.length === 0) throw new Error("pengepul returned an empty model catalog")
245
+
246
+ return data.map((entry) => {
247
+ if (!isRecord(entry)) throw new Error("Expected model entry to be an object")
248
+ return toPengepulModel(entry, lookupBuiltin)
249
+ })
250
+ }
251
+
252
+ /** Map models to pi `ProviderModelConfig` entries. Pure. */
253
+ export function toProviderModelConfigs(
254
+ models: readonly PengepulModel[],
255
+ relayBase: string,
256
+ ): Array<{
257
+ id: string
258
+ name: string
259
+ api: PengepulDialect
260
+ baseUrl: string
261
+ reasoning: boolean
262
+ input: ("text" | "image")[]
263
+ cost: {
264
+ input: number
265
+ output: number
266
+ cacheRead: number
267
+ cacheWrite: number
268
+ }
269
+ contextWindow: number
270
+ maxTokens: number
271
+ thinkingLevelMap?: Record<string, string | null>
272
+ compat?: { forceAdaptiveThinking?: boolean }
273
+ }> {
274
+ return models.map((model) => {
275
+ const adaptive = model.dialect === "anthropic-messages" && model.reasoning
276
+ return {
277
+ id: model.id,
278
+ name: model.name,
279
+ api: model.dialect,
280
+ baseUrl: baseUrlForDialect(relayBase, model.dialect),
281
+ reasoning: model.reasoning,
282
+ input: model.input,
283
+ cost: model.cost,
284
+ contextWindow: model.contextWindow,
285
+ maxTokens: model.maxTokens,
286
+ // Inherited level mapping (e.g. deepseek {high:"high"}) flows through;
287
+ // adaptive Claude models additionally mark "off" unsupported so the
288
+ // stream omits thinking:{type:"disabled"} (upstream rejects it).
289
+ ...(model.thinkingLevelMap || adaptive
290
+ ? { thinkingLevelMap: { ...(model.thinkingLevelMap ?? {}), ...(adaptive ? { off: null } : {}) } }
291
+ : {}),
292
+ // Reasoning-capable Claude models run on the adaptive-thinking wire:
293
+ // pi's streamSimple always passes thinkingEnabled:false when no level is
294
+ // selected, and the stream would send thinking:{type:"disabled"}, which
295
+ // the upstream rejects (400: "thinking.type.disabled is not supported
296
+ // for this model"). thinkingLevelMap.off = null marks "off" as
297
+ // unsupported so pi omits the thinking param entirely (server default
298
+ // = adaptive), and forceAdaptiveThinking routes an explicit level to
299
+ // {type:"adaptive"} + effort instead of budget_tokens.
300
+ ...(adaptive ? { compat: { forceAdaptiveThinking: true as const } } : {}),
301
+ }
302
+ })
303
+ }
304
+
305
+ /** Picker label: the bare model part of a relay id, suffixed. `anthropic/claude-opus-5` -> `claude-opus-5 (pengepul)`. */
306
+ function displayName(id: string): string {
307
+ const slash = id.indexOf("/")
308
+ const bare = slash === -1 ? id : id.slice(slash + 1)
309
+ return `${bare} (pengepul)`
310
+ }
311
+
312
+ interface FetchModelsOptions {
313
+ url?: string
314
+ apiKey?: string
315
+ fetchImpl?: typeof fetch
316
+ signal?: AbortSignal
317
+ timeoutMs?: number
318
+ lookupBuiltin?: BuiltinModelLookup
319
+ }
320
+
321
+ interface LoadModelsOptions extends FetchModelsOptions {
322
+ cachePath: string
323
+ relayBase: string
324
+ }
325
+
326
+ function errorMessage(error: unknown): string {
327
+ return error instanceof Error ? error.message : String(error)
328
+ }
329
+
330
+ function abortError(reason: unknown): Error {
331
+ if (reason instanceof Error) return reason
332
+ return new DOMException("The operation was aborted", "AbortError")
333
+ }
334
+
335
+ function configuredTimeoutMs(timeoutMs: number | undefined): number {
336
+ return timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0
337
+ ? timeoutMs
338
+ : DEFAULT_MODELS_TIMEOUT_MS
339
+ }
340
+
341
+ export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
342
+ const raw = env["PENGEPUL_MODELS_TIMEOUT_MS"]
343
+ if (!raw) return DEFAULT_MODELS_TIMEOUT_MS
344
+ const parsed = Number(raw)
345
+ return configuredTimeoutMs(parsed)
346
+ }
347
+
348
+ class ModelDiscoveryTimeoutError extends Error {
349
+ constructor(timeoutMs: number) {
350
+ super(`pengepul model discovery timed out after ${timeoutMs}ms`)
351
+ this.name = "ModelDiscoveryTimeoutError"
352
+ }
353
+ }
354
+
355
+ function runWithTimeout<T>(
356
+ operation: (signal: AbortSignal) => Promise<T>,
357
+ timeoutMs: number,
358
+ externalSignal: AbortSignal | undefined,
359
+ ): Promise<T> {
360
+ const controller = new AbortController()
361
+ let timer: ReturnType<typeof setTimeout> | undefined
362
+ let settled = false
363
+ let onExternalAbort: (() => void) | undefined
364
+
365
+ return new Promise<T>((resolve, reject) => {
366
+ const cleanup = () => {
367
+ if (timer !== undefined) clearTimeout(timer)
368
+ if (onExternalAbort && externalSignal) {
369
+ externalSignal.removeEventListener("abort", onExternalAbort)
370
+ }
371
+ }
372
+
373
+ const resolveOnce = (value: T) => {
374
+ if (settled) return
375
+ settled = true
376
+ cleanup()
377
+ resolve(value)
378
+ }
379
+
380
+ const rejectOnce = (error: unknown) => {
381
+ if (settled) return
382
+ settled = true
383
+ cleanup()
384
+ reject(error)
385
+ }
386
+
387
+ const abort = (reason: unknown) => {
388
+ const error = abortError(reason)
389
+ controller.abort(error)
390
+ rejectOnce(error)
391
+ }
392
+
393
+ if (externalSignal?.aborted) {
394
+ abort(externalSignal.reason)
395
+ return
396
+ }
397
+
398
+ onExternalAbort = () => abort(externalSignal?.reason)
399
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true })
400
+ timer = setTimeout(() => abort(new ModelDiscoveryTimeoutError(timeoutMs)), timeoutMs)
401
+
402
+ Promise.resolve()
403
+ .then(() => operation(controller.signal))
404
+ .then(resolveOnce, rejectOnce)
405
+ })
406
+ }
407
+
408
+ export async function fetchPengepulModels(
409
+ options: FetchModelsOptions = {},
410
+ ): Promise<readonly PengepulModel[]> {
411
+ const url = options.url ?? `${DEFAULT_RELAY_BASE}/v1/models`
412
+ const fetchImpl = options.fetchImpl ?? fetch
413
+ const apiKey = options.apiKey
414
+
415
+ const headers: Record<string, string> = {
416
+ accept: "application/json",
417
+ }
418
+ if (apiKey) headers["x-api-key"] = apiKey
419
+
420
+ const body: unknown = await runWithTimeout(
421
+ async (signal) => {
422
+ const response = await fetchImpl(url, { headers, signal })
423
+
424
+ if (response.status === 401 || response.status === 403) {
425
+ throw new Error(
426
+ `pengepul rejected the API key (${
427
+ response.status
428
+ }). Set PENGEPUL_API_KEY or check ~/.pengepul/config.yaml.`,
429
+ )
430
+ }
431
+ if (!response.ok) {
432
+ throw new Error(
433
+ `Failed to fetch pengepul models: ${response.status} ${response.statusText}`,
434
+ )
435
+ }
436
+
437
+ return await response.json()
438
+ },
439
+ configuredTimeoutMs(options.timeoutMs),
440
+ options.signal,
441
+ )
442
+
443
+ return modelsFromApiResponse(body, options.lookupBuiltin)
444
+ }
445
+
446
+ function numberField(record: Record<string, unknown>, key: string): number {
447
+ const value = record[key]
448
+ if (typeof value !== "number" || !Number.isFinite(value)) {
449
+ throw new Error(`Expected ${key} to be a finite number`)
450
+ }
451
+ return value
452
+ }
453
+
454
+ function inputField(record: Record<string, unknown>, key: string): ModelInput {
455
+ const value = record[key]
456
+ if (!Array.isArray(value)) throw new Error(`Expected ${key} to be an array`)
457
+ return value.map((entry) => {
458
+ if (entry !== "text" && entry !== "image") {
459
+ throw new Error(`Expected ${key} entries to be "text" or "image"`)
460
+ }
461
+ return entry
462
+ })
463
+ }
464
+
465
+ function costField(record: Record<string, unknown>, key: string): ModelCostRates {
466
+ const value = record[key]
467
+ if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
468
+ return {
469
+ input: numberField(value, "input"),
470
+ output: numberField(value, "output"),
471
+ cacheRead: numberField(value, "cacheRead"),
472
+ cacheWrite: numberField(value, "cacheWrite"),
473
+ }
474
+ }
475
+
476
+ function levelMapField(
477
+ record: Record<string, unknown>,
478
+ key: string,
479
+ ): Record<string, string | null> {
480
+ const value = record[key]
481
+ if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
482
+ const map: Record<string, string | null> = {}
483
+ for (const [level, mapped] of Object.entries(value)) {
484
+ if (mapped !== null && typeof mapped !== "string") {
485
+ throw new Error(`Expected ${key} values to be strings or null`)
486
+ }
487
+ map[level] = mapped
488
+ }
489
+ return map
490
+ }
491
+
492
+ export function modelsFromCache(value: unknown): readonly PengepulModel[] {
493
+ if (!isRecord(value)) throw new Error("Expected model cache to be an object")
494
+ if (value["version"] !== MODEL_CACHE_VERSION) {
495
+ throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
496
+ }
497
+ if (!Array.isArray(value["models"])) throw new Error("Expected cached models to be an array")
498
+
499
+ const parsed: PengepulModel[] = value["models"].map((entry) => {
500
+ if (!isRecord(entry)) throw new Error("Expected cached model entry to be an object")
501
+ return {
502
+ id: stringField(entry, "id"),
503
+ name: stringField(entry, "name"),
504
+ dialect: stringField(entry, "dialect") as PengepulDialect,
505
+ reasoning: entry["reasoning"] === true,
506
+ input: inputField(entry, "input"),
507
+ cost: costField(entry, "cost"),
508
+ contextWindow: numberField(entry, "contextWindow"),
509
+ maxTokens: numberField(entry, "maxTokens"),
510
+ ...(entry["thinkingLevelMap"] !== undefined
511
+ ? { thinkingLevelMap: levelMapField(entry, "thinkingLevelMap") }
512
+ : {}),
513
+ }
514
+ })
515
+ if (parsed.length === 0) throw new Error("pengepul cache holds no valid models")
516
+ return parsed
517
+ }
518
+
519
+ async function readCache(cachePath: string): Promise<readonly PengepulModel[]> {
520
+ const contents = await readFile(cachePath, "utf-8")
521
+ return modelsFromCache(JSON.parse(contents))
522
+ }
523
+
524
+ /** Reads the cached catalog without touching the network; empty when missing/invalid. */
525
+ export async function loadCachedPengepulModels(
526
+ cachePath: string,
527
+ ): Promise<readonly PengepulModel[]> {
528
+ try {
529
+ return await readCache(cachePath)
530
+ } catch {
531
+ return []
532
+ }
533
+ }
534
+
535
+ async function writeCache(cachePath: string, models: readonly PengepulModel[]): Promise<void> {
536
+ await mkdir(dirname(cachePath), { recursive: true })
537
+ // Unique per write: the runtime can issue two overlapping writes in one
538
+ // process (cache-first + background refresh), and a shared pid-keyed name
539
+ // would let the first rename remove the second's source mid-flight.
540
+ const temporaryPath = `${cachePath}.${process.pid}.${randomUUID()}.tmp`
541
+
542
+ try {
543
+ await writeFile(
544
+ temporaryPath,
545
+ `${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
546
+ { encoding: "utf-8", mode: 0o600 },
547
+ )
548
+ await rename(temporaryPath, cachePath)
549
+ } finally {
550
+ try {
551
+ await rm(temporaryPath, { force: true })
552
+ } catch {
553
+ // Best-effort cleanup must not hide the original cache write error.
554
+ }
555
+ }
556
+ }
557
+
558
+ export async function loadPengepulModels(
559
+ options: LoadModelsOptions,
560
+ ): Promise<PengepulModelSource> {
561
+ const cachePath = options.cachePath
562
+
563
+ try {
564
+ const models = await fetchPengepulModels(options)
565
+
566
+ try {
567
+ await writeCache(cachePath, models)
568
+ return { models, source: "live" }
569
+ } catch (error) {
570
+ return {
571
+ models,
572
+ source: "live",
573
+ warning: `Loaded the live pengepul model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
574
+ }
575
+ }
576
+ } catch (liveError) {
577
+ if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError)
578
+
579
+ try {
580
+ const models = await readCache(cachePath)
581
+ return {
582
+ models,
583
+ source: "cache",
584
+ warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
585
+ }
586
+ } catch (cacheError) {
587
+ return {
588
+ models: [],
589
+ source: "empty",
590
+ warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). pengepul models will remain unavailable until the next startup refresh succeeds.`,
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+
package/src/runtime.ts ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Cached-model runtime for the pengepul provider.
3
+ *
4
+ * Registers the provider immediately from the cache (so startup never waits on
5
+ * the network), refreshes it in the background, and disposes cleanly. There
6
+ * are no user-facing commands: the startup refresh is the only refresh.
7
+ *
8
+ * The pi seam is an interface, so the whole thing is testable with a fake host.
9
+ */
10
+
11
+ import type { PengepulModel, PengepulModelSource } from "./models.ts"
12
+
13
+ export interface PengepulRuntimeApi {
14
+ registerProvider(name: string, config: unknown): void
15
+ }
16
+
17
+ export interface PengepulRuntimeOptions {
18
+ /** A provider config built from a model list. */
19
+ createProviderConfig: (models: readonly PengepulModel[]) => unknown
20
+ /** Live fetch; resolves to the catalog plus a source marker. */
21
+ loadModels: (signal: AbortSignal) => Promise<PengepulModelSource>
22
+ /** Cached catalog only; empty when no valid cache exists. */
23
+ loadCachedModels: () => Promise<readonly PengepulModel[]>
24
+ now?: () => number
25
+ logWarning?: (message: string) => void
26
+ }
27
+
28
+ export interface PengepulRefreshResult {
29
+ refreshed: boolean
30
+ source: PengepulModelSource["source"]
31
+ modelCount: number
32
+ warning?: string
33
+ }
34
+
35
+ interface RuntimeStatus {
36
+ source: PengepulModelSource["source"]
37
+ modelCount: number
38
+ providerRegistered: boolean
39
+ lastSuccess?: number
40
+ lastAttempt?: number
41
+ warning?: string
42
+ refreshing: boolean
43
+ }
44
+
45
+ const REDACTED = "[redacted]"
46
+
47
+ function errorMessage(error: unknown): string {
48
+ return error instanceof Error ? error.message : String(error)
49
+ }
50
+
51
+ function redactDiagnosticText(value: string): string {
52
+ return value
53
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`)
54
+ .replace(/\b(?:sk-local|sk-|api[-_ ]?key|token|secret|password)[_-]?[\w.~+/=-]{8,}\b/gi, REDACTED)
55
+ .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => {
56
+ const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "="
57
+ return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}`
58
+ })
59
+ }
60
+
61
+ export class PengepulRuntime {
62
+ private readonly now: () => number
63
+ private readonly logWarning: (message: string) => void
64
+ private status: RuntimeStatus
65
+ private providerRegistered = false
66
+ private refreshPromise: Promise<PengepulRefreshResult> | undefined
67
+ private readonly shutdown = new AbortController()
68
+
69
+ constructor(
70
+ private readonly pi: PengepulRuntimeApi,
71
+ private readonly options: PengepulRuntimeOptions,
72
+ ) {
73
+ this.now = options.now ?? Date.now
74
+ this.logWarning = options.logWarning ?? ((message) => console.warn(`[pengepul] ${message}`))
75
+ this.status = {
76
+ source: "empty",
77
+ modelCount: 0,
78
+ providerRegistered: false,
79
+ refreshing: false,
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Registers the cached catalog immediately (so host startup does not wait on
85
+ * the network) and refreshes it in the background. Without a valid cache the
86
+ * live refresh is awaited so models are available right away.
87
+ */
88
+ async initialize(): Promise<void> {
89
+ const cached = await this.options.loadCachedModels()
90
+ if (cached.length === 0) {
91
+ await this.refresh()
92
+ return
93
+ }
94
+
95
+ this.pi.registerProvider("pengepul", this.options.createProviderConfig(cached))
96
+ this.providerRegistered = true
97
+ this.status = {
98
+ ...this.status,
99
+ source: "cache",
100
+ modelCount: cached.length,
101
+ providerRegistered: true,
102
+ lastSuccess: this.now(),
103
+ }
104
+ void this.refresh()
105
+ }
106
+
107
+ /** Aborts any background refresh so a stopping host does not wait on the network. */
108
+ dispose(): void {
109
+ this.shutdown.abort(new Error("pengepul provider shut down"))
110
+ }
111
+
112
+ refresh(): Promise<PengepulRefreshResult> {
113
+ if (this.refreshPromise) return this.refreshPromise
114
+
115
+ const refreshPromise = this.refreshCatalog().finally(() => {
116
+ if (this.refreshPromise === refreshPromise) this.refreshPromise = undefined
117
+ })
118
+ this.refreshPromise = refreshPromise
119
+ return refreshPromise
120
+ }
121
+
122
+ private async refreshCatalog(): Promise<PengepulRefreshResult> {
123
+ this.status = { ...this.status, lastAttempt: this.now(), refreshing: true }
124
+
125
+ try {
126
+ const loaded = await this.options.loadModels(this.shutdown.signal)
127
+ const warning = loaded.warning ? redactDiagnosticText(loaded.warning) : undefined
128
+
129
+ const shouldRegister =
130
+ !this.providerRegistered ||
131
+ loaded.source === "live" ||
132
+ (this.status.modelCount === 0 && loaded.models.length > 0)
133
+
134
+ if (shouldRegister) {
135
+ this.pi.registerProvider("pengepul", this.options.createProviderConfig(loaded.models))
136
+ this.providerRegistered = true
137
+
138
+ if (loaded.models.length === 0) {
139
+ const preservedWarning = warning ?? "pengepul model discovery returned no models"
140
+ this.status = {
141
+ ...this.status,
142
+ source: loaded.source,
143
+ modelCount: 0,
144
+ providerRegistered: true,
145
+ warning: preservedWarning,
146
+ refreshing: false,
147
+ }
148
+ this.warn(preservedWarning)
149
+ return { refreshed: false, source: loaded.source, modelCount: 0, warning: preservedWarning }
150
+ }
151
+
152
+ this.status = {
153
+ ...this.status,
154
+ source: loaded.source,
155
+ modelCount: loaded.models.length,
156
+ providerRegistered: true,
157
+ lastSuccess: this.now(),
158
+ warning,
159
+ refreshing: false,
160
+ }
161
+ if (warning) this.warn(warning)
162
+ return { refreshed: true, source: loaded.source, modelCount: loaded.models.length, warning }
163
+ }
164
+
165
+ const preservedWarning = warning ?? "pengepul model discovery returned no models"
166
+ this.status = { ...this.status, warning: preservedWarning, refreshing: false }
167
+ this.warn(preservedWarning)
168
+ return {
169
+ refreshed: false,
170
+ source: this.status.source,
171
+ modelCount: this.status.modelCount,
172
+ warning: preservedWarning,
173
+ }
174
+ } catch (error) {
175
+ if (this.shutdown.signal.aborted) {
176
+ this.status = { ...this.status, refreshing: false }
177
+ return {
178
+ refreshed: false,
179
+ source: this.status.source,
180
+ modelCount: this.status.modelCount,
181
+ }
182
+ }
183
+ const warning = redactDiagnosticText(
184
+ `Could not refresh the pengepul model catalog: ${errorMessage(error)}`,
185
+ )
186
+ this.status = { ...this.status, warning, refreshing: false }
187
+ this.warn(warning)
188
+ return {
189
+ refreshed: false,
190
+ source: this.status.source,
191
+ modelCount: this.status.modelCount,
192
+ warning,
193
+ }
194
+ }
195
+ }
196
+
197
+ private warn(message: string): void {
198
+ try {
199
+ this.logWarning(redactDiagnosticText(message))
200
+ } catch {
201
+ // Diagnostics must never make a catalog refresh fail.
202
+ }
203
+ }
204
+ }
205
+
206
+ export function createPengepulRuntime(
207
+ pi: PengepulRuntimeApi,
208
+ options: PengepulRuntimeOptions,
209
+ ): PengepulRuntime {
210
+ return new PengepulRuntime(pi, options)
211
+ }