@pwguler/pi-pengepul-provider 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -58,6 +58,10 @@ prefixed `pengepul/<id>`. To try it without installing, use
58
58
  relay already serves — takes them from the same family's previous minor, so
59
59
  it does not sit at `high` until the next pi catalog update. Nothing to
60
60
  configure: `thinkingLevelMap` is resolved per id here.
61
+ - Publishes Anthropic's cache lifetimes (5 minutes, 1 hour) on the Messages
62
+ wire, so pi's cache warmer can keep a Claude conversation's entry alive
63
+ instead of re-billing its whole prefix after five idle minutes. See
64
+ [Prompt cache](#prompt-cache).
61
65
  - Caches the catalog in pi's model store, so startup does not wait on the
62
66
  network and a briefly absent relay is covered. The cached models are
63
67
  re-pointed at the relay base configured now, so moving the relay does not
@@ -89,25 +93,86 @@ To reach a relay on another machine, put that machine's address in `baseUrl`
89
93
  `~/.pengepul/config.yaml` on the relay, or forward the port over SSH). The key
90
94
  is the relay's own key — `pengepul config api-key` prints it.
91
95
 
92
- Optional overrides, for CI or a one-off shell. Each is a fallback: the
93
- credential wins when it carries a value.
96
+ Optional overrides, for CI or a one-off shell. The first two outrank the
97
+ credential: set either and it wins, whatever `auth.json` holds.
94
98
 
95
99
  | Setting | Env var | Default |
96
100
  |---|---|---|
97
- | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the relay's own `config.yaml` |
98
- | API key | `PENGEPUL_API_KEY` | `api-keys[0]` in `~/.pengepul/config.yaml` |
99
- | Config path | `PENGEPUL_CONFIG` | `~/.pengepul/config.yaml` |
100
- | Legacy cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
101
+ | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the credential's `baseUrl` |
102
+ | API key | `PENGEPUL_API_KEY` | the credential's `key` |
101
103
  | Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
102
104
 
103
- `PENGEPUL_MODELS_CACHE` names the pre-0.3 cache file. It is read once, when pi's
104
- model store has no pengepul catalog yet, and never written again.
105
+ ```sh
106
+ PENGEPUL_BASE_URL=http://10.0.0.9:8317 PENGEPUL_API_KEY=sk-local-... pi
107
+ ```
105
108
 
106
109
  Do not set `providers.pengepul.baseUrl` in `models.json`. pi applies that value
107
110
  to every model of the provider, which collapses the two wires onto one URL —
108
111
  Anthropic Messages traffic would be sent to `/v1` and Chat Completions traffic
109
112
  to `/`.
110
113
 
114
+ ## Prompt cache
115
+
116
+ The upstream prompt cache is one entry per account and lives five minutes by
117
+ default, so a conversation that waits longer than that pays for its whole prefix
118
+ again — on a 700k-token Opus session, dollars per return, logged by the relay as
119
+ a `cache_write` with a near-zero `cache_read`.
120
+
121
+ This provider declares Anthropic's lifetimes (5 minutes, 1 hour) on every
122
+ Messages-wire model, which is what lets pi keep an entry alive at all: pi
123
+ refreshes an entry by re-sending the prompt with a one-token cap before it
124
+ expires, and it only warms a model whose lifetime it can resolve. Two pi
125
+ settings decide when that happens, and neither covers an idle gap out of the
126
+ box:
127
+
128
+ | Setting | Where | Effect |
129
+ |---|---|---|
130
+ | `cacheWarming: "idle"` | `~/.pi/agent/settings.json` | Warms between agent runs. The default, `"streaming"`, stops as soon as a run settles — exactly when a five-minute entry is about to expire. |
131
+ | `env: { "PI_CACHE_RETENTION": "long" }` | the `pengepul` credential in `auth.json` | Asks for the 1-hour tier instead of the 5-minute one. The relay forwards the extended TTL to Anthropic. |
132
+
133
+ ```json
134
+ {
135
+ "pengepul": {
136
+ "type": "api_key",
137
+ "key": "sk-local-...",
138
+ "baseUrl": "http://127.0.0.1:8317",
139
+ "env": { "PI_CACHE_RETENTION": "long" }
140
+ }
141
+ }
142
+ ```
143
+
144
+ Idle warming covers gaps up to 30 minutes, pi's own safety limit, and pi only
145
+ triggers it when the expected saving clears a threshold of its own — a small
146
+ prompt is left to expire rather than refreshed. The long tier is what carries a
147
+ longer break. Both are cheap next to a miss: a refresh is one cache read plus a
148
+ one-token completion, while the long tier only raises the write premium, on the
149
+ few hundred tokens a turn actually adds.
150
+
151
+ `/session` reports the mode, the refresh cost, and the miss penalty it is
152
+ weighing, and `showCacheMissNotices` in `settings.json` prints each miss with
153
+ its re-billed token count.
154
+
155
+ ## Upgrading from 0.4.0 or earlier
156
+
157
+ This release is breaking: the relay's own `~/.pengepul/config.yaml` is no longer
158
+ a source. That file belongs to the machine running the relay, and reading it let
159
+ a key on that box configure every client sharing it.
160
+
161
+ - A key that came from `api-keys:` in it is no longer picked up. Run
162
+ `/login pengepul`, or put it in `auth.json` as `"pengepul".key`, or export
163
+ `PENGEPUL_API_KEY`. `pengepul config api-key` prints it.
164
+ - The relay base built from its `host` and `port` is no longer picked up
165
+ either. Set `baseUrl` in `auth.json`, or `PENGEPUL_BASE_URL`, if the relay is
166
+ not on `http://127.0.0.1:8317`.
167
+ - A pre-0.3 `<agent-dir>/pengepul-models.json` is no longer imported. The
168
+ catalog now comes from the relay on the next start, so the relay has to be
169
+ reachable once; pi's model store covers the rest.
170
+
171
+ Resolution order changed as well, and that is the part to check after an
172
+ upgrade: `PENGEPUL_BASE_URL` and `PENGEPUL_API_KEY` now win over `auth.json`,
173
+ where they previously lost to it. An exported key from an old shell now
174
+ overrides the one in the credential.
175
+
111
176
  ## Notes
112
177
 
113
178
  - pengepul >= 0.6.0 advertises per-model context windows, output caps,
@@ -1,34 +1,17 @@
1
1
  /**
2
- * Where the pengepul provider's fallback sources live.
2
+ * The environment overrides this provider reads.
3
3
  *
4
- * `auth.json` is the configuration surface. These are the escape hatches kept
5
- * for tests, CI, and a machine that happens to run the relay itself: env vars
6
- * for the relay base and key, and pengepul's own config on disk.
4
+ * `~/.pi/agent/auth.json` is the configuration surface: the pengepul entry
5
+ * carries the relay API key and the relay base it applies to, and
6
+ * `/login pengepul` writes both. These variables outrank it, so a shell or a CI
7
+ * run can re-point the provider without editing the file.
7
8
  */
8
9
 
9
- import { join } from "node:path"
10
-
11
- import { CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH } from "./api-key.ts"
12
-
13
- export { CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH }
14
-
10
+ /** Relay base URL. Outranks the credential's `baseUrl`. */
15
11
  export const RELAY_BASE_ENV = "PENGEPUL_BASE_URL"
16
- export const MODELS_CACHE_ENV = "PENGEPUL_MODELS_CACHE"
17
- export const MODELS_TIMEOUT_MS_ENV = "PENGEPUL_MODELS_TIMEOUT_MS"
18
12
 
19
- export interface PengepulSettings {
20
- /** Where pengepul's own config lives, when this machine runs the relay. */
21
- configPath: string
22
- /** Where the pre-0.3 catalog cache lives; read once to seed pi's store. */
23
- legacyCachePath: string
24
- }
13
+ /** Relay API key. Outranks the credential's `key`. */
14
+ export const API_KEY_ENV = "PENGEPUL_API_KEY"
25
15
 
26
- export function resolveSettings(
27
- env: Record<string, string | undefined>,
28
- agentDir: string,
29
- ): PengepulSettings {
30
- return {
31
- configPath: env[CONFIG_PATH_ENV] ?? DEFAULT_CONFIG_PATH,
32
- legacyCachePath: env[MODELS_CACHE_ENV] ?? join(agentDir, "pengepul-models.json"),
33
- }
34
- }
16
+ /** Milliseconds before model discovery gives up. */
17
+ export const MODELS_TIMEOUT_MS_ENV = "PENGEPUL_MODELS_TIMEOUT_MS"
@@ -4,13 +4,13 @@
4
4
  * The credential is the pengepul entry in pi's `auth.json`: an API key for the
5
5
  * relay plus the relay base it applies to. pi core resolves the key itself but
6
6
  * knows nothing about `baseUrl`, so the extension reads it here. Everything in
7
- * this module is pure: an env map and config text in, a value out.
7
+ * this module is pure: an env map and credential fields in, a value out.
8
8
  *
9
- * Precedence, highest first: credential, then environment, then the relay's own
10
- * config file (which only exists on the machine running the relay), then the
11
- * loopback default pengepul binds to.
9
+ * Precedence is the same for both, highest first: the `PENGEPUL_*` environment
10
+ * override, then the credential, then the loopback default pengepul binds to.
12
11
  */
13
12
 
13
+ import { API_KEY_ENV } from "./config.ts"
14
14
  import { normalizeRootBaseUrl } from "./dialect.ts"
15
15
  import { DEFAULT_RELAY_BASE } from "./models.ts"
16
16
 
@@ -38,76 +38,52 @@ export function credentialApiKey(credential: PengepulCredential | undefined): st
38
38
  return nonEmptyString(credential?.key)
39
39
  }
40
40
 
41
- /**
42
- * Build a relay base from pengepul's own `config.yaml`. Only the machine running
43
- * the relay has this file; clients reach a relay elsewhere and skip this source.
44
- *
45
- * pengepul writes `host: ''` when it binds loopback ("empty binds 127.0.0.1, not
46
- * every interface"), so an empty host means loopback rather than "unset". A
47
- * bind-all address is not a connect address either: `0.0.0.0`, `::`, and `*` mean
48
- * "every interface", and a client reaches that listener on loopback.
49
- */
50
- export function relayBaseFromConfigText(configText: string | undefined): string | undefined {
51
- if (configText === undefined) return undefined
52
-
53
- let host = "127.0.0.1"
54
- let port: number | undefined
55
-
56
- for (const line of configText.split(/\r?\n/)) {
57
- const hostMatch = /^\s*host:\s*(.*)$/.exec(line)
58
- if (hostMatch) {
59
- const value = (hostMatch[1] ?? "").trim().replace(/^["']|["']$/g, "")
60
- if (value !== "" && !isBindAll(value)) host = value
61
- continue
62
- }
63
-
64
- const portMatch = /^\s*port:\s*(.*)$/.exec(line)
65
- if (portMatch) {
66
- const value = (portMatch[1] ?? "").trim().replace(/^["']|["']$/g, "")
67
- const parsed = Number(value)
68
- if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) port = parsed
69
- }
70
- }
71
-
72
- return port === undefined ? undefined : `http://${host}:${port}`
73
- }
74
-
75
- /** Whether a configured host is a bind-all address rather than a destination. */
76
- function isBindAll(host: string): boolean {
77
- return host === "0.0.0.0" || host === "::" || host === "[::]" || host === "*"
78
- }
79
-
80
41
  export interface RelayBaseSources {
81
- /** The relay base stored on the pengepul credential. */
82
- credential?: string | undefined
83
42
  /** The `PENGEPUL_BASE_URL` environment value. */
84
43
  environment?: string | undefined
85
- /** The relay base derived from pengepul's own config file. */
86
- config?: string | undefined
44
+ /** The relay base stored on the pengepul credential. */
45
+ credential?: string | undefined
87
46
  }
88
47
 
89
48
  /**
90
- * The relay root every wire appends to: highest-precedence non-blank source, or
91
- * loopback. A trailing `/v1` is accepted and stripped, so a base copied from a
92
- * client config cannot produce `/v1/v1` downstream.
49
+ * The relay root every wire appends to: the environment override, else the
50
+ * credential, else loopback. A trailing `/v1` is accepted and stripped, so a
51
+ * base copied from a client config cannot produce `/v1/v1` downstream.
93
52
  */
94
53
  export function resolveRelayBase(sources: RelayBaseSources): string {
95
54
  const chosen =
96
- nonEmptyString(sources.credential) ??
97
55
  nonEmptyString(sources.environment) ??
98
- nonEmptyString(sources.config) ??
56
+ nonEmptyString(sources.credential) ??
99
57
  DEFAULT_RELAY_BASE
100
58
  return normalizeRootBaseUrl(chosen)
101
59
  }
102
60
 
103
61
  export interface ApiKeySources {
62
+ /** The `PENGEPUL_API_KEY` environment value. */
63
+ environment?: string | undefined
104
64
  /** The key stored on the pengepul credential. */
105
65
  credential?: string | undefined
106
- /** The key resolved from `PENGEPUL_API_KEY` or pengepul's own config file. */
107
- ambient?: string | undefined
108
66
  }
109
67
 
110
- /** The key requests and catalog fetches use, or undefined when none is configured. */
111
- export function resolveApiKey(sources: ApiKeySources): string | undefined {
112
- return nonEmptyString(sources.credential) ?? nonEmptyString(sources.ambient)
68
+ /** Which source answered, as the auth status labels it. */
69
+ export type ApiKeySource = "PENGEPUL_API_KEY" | "stored credential"
70
+
71
+ export interface ResolvedApiKey {
72
+ key: string
73
+ source: ApiKeySource
74
+ }
75
+
76
+ /**
77
+ * The key requests and catalog fetches use, or undefined when none is
78
+ * configured. The value and the label it reports come from one walk, so the
79
+ * status UI cannot name a source the request did not use.
80
+ */
81
+ export function resolveApiKey(sources: ApiKeySources): ResolvedApiKey | undefined {
82
+ const environment = nonEmptyString(sources.environment)
83
+ if (environment) return { key: environment, source: API_KEY_ENV }
84
+
85
+ const credential = nonEmptyString(sources.credential)
86
+ if (credential) return { key: credential, source: "stored credential" }
87
+
88
+ return undefined
113
89
  }
@@ -4,36 +4,18 @@
4
4
  * Registers pengepul as a pi provider. pengepul is a relay that pools your
5
5
  * Claude/Codex subscriptions and speaks both native wires. The provider itself
6
6
  * lives in `./provider.ts`: pi resolves auth through it and hands the credential
7
- * back on every refresh, so `auth.json` carries the key and the relay base. The
8
- * pure core is `./credential.ts`, `./dialect.ts`, and `./models.ts`; this file
9
- * adapts them to the pi ExtensionAPI seam and reads the one file pi cannot.
7
+ * back on every refresh, so `auth.json` carries the key and the relay base, and
8
+ * the `PENGEPUL_*` environment variables override both. The pure core is
9
+ * `./credential.ts`, `./dialect.ts`, and `./models.ts`; this file adapts them to
10
+ * the pi ExtensionAPI seam and reads nothing of its own.
10
11
  */
11
12
 
12
- import {
13
- getAgentDir,
14
- type ExtensionAPI,
15
- } from "@earendil-works/pi-coding-agent"
13
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent"
16
14
  import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"
17
- import { readFileSync } from "node:fs"
18
15
 
19
- import { resolveSettings } from "./config.ts"
20
16
  import { catalogIdForms, type PengepulModel } from "./models.ts"
21
17
  import { createPengepulProvider } from "./provider.ts"
22
18
 
23
- function expandHome(path: string): string {
24
- if (path === "~") return process.env.HOME ?? path
25
- if (path.startsWith("~/")) return `${process.env.HOME ?? ""}${path.slice(1)}`
26
- return path
27
- }
28
-
29
- function readConfigText(path: string): string | undefined {
30
- try {
31
- return readFileSync(expandHome(path), "utf-8")
32
- } catch {
33
- return undefined
34
- }
35
- }
36
-
37
19
  /** The metadata fields the lookup extracts from a pi catalog entry. */
38
20
  function metaFromModel(model: NonNullable<ReturnType<typeof getBuiltinModel>>) {
39
21
  return {
@@ -88,17 +70,13 @@ function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<type
88
70
 
89
71
  /**
90
72
  * Model discovery belongs to pi: it calls `refreshModels` with the resolved
91
- * credential, first against pi's cached catalog and then, when the network is
73
+ * credential, first against pi's model store and then, when the network is
92
74
  * allowed, against the relay. Registration is synchronous; nothing here waits
93
75
  * on the relay, and the catalog survives a restart through pi's model store.
94
76
  */
95
77
  export default function (pi: ExtensionAPI) {
96
- const settings = resolveSettings(process.env, getAgentDir())
97
-
98
78
  const provider = createPengepulProvider({
99
79
  env: process.env,
100
- configText: readConfigText(settings.configPath),
101
- legacyCachePath: settings.legacyCachePath,
102
80
  lookupBuiltin: createBuiltinLookup(),
103
81
  })
104
82
 
@@ -2,10 +2,11 @@
2
2
  * pengepul model discovery.
3
3
  *
4
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.
5
+ * pi-ai `ProviderModelConfig` shape. Discovery is pi's to drive: the catalog
6
+ * lives in pi's model store between runs, so a briefly absent relay is covered
7
+ * by the store the provider registered rather than by a file this module owns.
8
+ * An empty result leaves pengepul models unavailable until the next successful
9
+ * startup refresh.
9
10
  *
10
11
  * The relay advertises id/owned_by and, since pengepul 0.6.0, optional
11
12
  * per-model metadata: `context_window`, `max_output_tokens`,
@@ -17,11 +18,10 @@
17
18
  * next best source - and then family heuristics. The catalog lookup is
18
19
  * injected, so this module imports nothing from pi-ai and tests pin it.
19
20
  *
20
- * The network/cache are injected so the catalog logic stays testable.
21
+ * The catalog fetch is injected as well, so this module holds no hidden
22
+ * network access and tests pin the transport.
21
23
  */
22
24
 
23
- import { readFile } from "node:fs/promises"
24
-
25
25
  import { MODELS_TIMEOUT_MS_ENV } from "./config.ts"
26
26
  import { baseUrlForDialect, dialectForModelId } from "./dialect.ts"
27
27
  import type { PengepulDialect } from "./dialect.ts"
@@ -32,8 +32,6 @@ export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
32
32
  const DEFAULT_CONTEXT_WINDOW = 200_000
33
33
  const DEFAULT_MAX_TOKENS = 64_000
34
34
  const ZERO_COST: ModelCostRates = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
35
- /** v5 cached entries predate the relay-uniform off/minimal overlay (foreign catalog maps offered levels the relay 400s); reject it. */
36
- const MODEL_CACHE_VERSION = 6
37
35
 
38
36
  export type ModelInput = ("text" | "image")[]
39
37
 
@@ -491,10 +489,10 @@ function isBatchRoute(id: string): boolean {
491
489
  }
492
490
 
493
491
  /**
494
- * Drop the ids this relay cannot serve on either wire. Applied on the way in
495
- * from the relay and on the way in from the cache: the cache is what covers a
496
- * briefly absent relay, so it must not be the path that resurrects a route
497
- * the live catalog would have dropped.
492
+ * Drop the ids this relay cannot serve on either wire, on the way in from the
493
+ * relay. The store replays what pi persisted, so a route this filter drops
494
+ * survives in a store written before the filter existed only until the next
495
+ * successful fetch replaces it.
498
496
  */
499
497
  function servableModels(models: readonly PengepulModel[]): PengepulModel[] {
500
498
  return models.filter((model) => !isBatchRoute(model.id))
@@ -524,8 +522,22 @@ export function modelsFromApiResponse(
524
522
 
525
523
  import type { Api, Model } from "@earendil-works/pi-ai"
526
524
 
525
+ /**
526
+ * Prompt cache lifetime in seconds for each retention tier a request can ask
527
+ * for, the shape pi reads off a model to decide when to refresh its cache
528
+ * entry. Declared here because pi-ai 0.85.1's `Model` has no `promptCache`;
529
+ * a host that predates the field ignores it.
530
+ */
531
+ export interface PromptCacheTiers {
532
+ short: number
533
+ long: number
534
+ }
535
+
527
536
  /** The pi-ai model shapes this provider serves: one per dialect the relay speaks. */
528
- export type PengepulModelEntry = Model<"anthropic-messages"> | Model<"openai-completions">
537
+ export type PengepulModelEntry = (
538
+ | Model<"anthropic-messages">
539
+ | Model<"openai-completions">
540
+ ) & { promptCache?: PromptCacheTiers }
529
541
 
530
542
  /** The provider id every pengepul model is stamped with. */
531
543
  export const PENGEPUL_PROVIDER_ID = "pengepul"
@@ -564,18 +576,17 @@ function sharedModelFields(model: PengepulModel, relayBase: string) {
564
576
  * openai-completions carries `prompt_cache_key: <sessionId>` (and
565
577
  * `prompt_cache_retention: "24h"`) under PI_CACHE_RETENTION=long, so the body
566
578
  * field alone would name the conversation; anthropic-messages carries no
567
- * `prompt_cache_key` at all, and pi-ai hardcodes `x-session-affinity` there,
568
- * which this relay does not read. Messages traffic therefore rests entirely on
569
- * the relay's prefix fallback until a pi release honours
570
- * `sessionAffinityFormat` on that dialect.
579
+ * `prompt_cache_key` at all, and pi-ai hardcodes `x-session-affinity` there up
580
+ * to 0.85.1, which this relay does not read. Messages traffic therefore rests
581
+ * on the relay's prefix fallback until 0.87, where `sessionAffinityFormat`
582
+ * starts being read on that dialect too and the same `openrouter` value lands
583
+ * as `x-session-id`.
571
584
  *
572
- * The two dialects do not land at the same time. openai-completions honors
573
- * `sessionAffinityFormat` in every released pi. anthropic-messages only reads it
574
- * from the unreleased change on pi main (commit bbb61e34a), which is why the
575
- * field is re-declared below rather than taken from `AnthropicMessagesCompat`:
576
- * against pi-ai 0.85.1 the pin is inert, and the cost of an ignored header is
577
- * zero. Pin now rather than later — the cost of forgetting is silently
578
- * re-billed Claude prefixes.
585
+ * The field is re-declared below rather than taken from
586
+ * `AnthropicMessagesCompat` so the pin does not depend on the host's pi-ai
587
+ * version: 0.85.1 ignores a value it never types, and the cost of an ignored
588
+ * header is zero. Pin now rather than later — the cost of forgetting is
589
+ * silently re-billed Claude prefixes.
579
590
  */
580
591
  function affinityPin() {
581
592
  return {
@@ -584,6 +595,32 @@ function affinityPin() {
584
595
  }
585
596
  }
586
597
 
598
+ /**
599
+ * Anthropic's prompt cache lifetimes: five minutes by default, one hour when
600
+ * the request asks for the extended TTL. Copied per model rather than shared,
601
+ * so nothing downstream can mutate one model's lifetimes into another's.
602
+ *
603
+ * pi reads `model.promptCache[tier]` to know when the entry a request wrote
604
+ * expires, and skips warming a model whose lifetime it cannot resolve
605
+ * (`cache-warmer.js` `getPromptCacheTtlMs`). No pi catalog answers for a relay
606
+ * id, so a Claude model served here would otherwise never be warmed: any idle
607
+ * past the TTL — five minutes on the default tier — re-bills the whole prefix
608
+ * at the write rate, which for a 700k-token Opus conversation is dollars per
609
+ * miss.
610
+ *
611
+ * Only the Messages wire gets them. Those ids are the ones the relay forwards
612
+ * to Anthropic's own API, whose lifetimes these are; the Chat Completions wire
613
+ * serves aggregated vendors (commandcode, openrouter) through upstreams whose
614
+ * cache lifetimes this provider has no measurement for, and pi does not warm a
615
+ * lifetime it cannot resolve.
616
+ *
617
+ * Measured, not assumed: a `cache_control` ttl of `1h` on this wire comes back
618
+ * with the whole prefix in `usage.cache_creation.ephemeral_1h_input_tokens`,
619
+ * so the relay passes the extended TTL through and `PI_CACHE_RETENTION=long`
620
+ * buys the hour declared here.
621
+ */
622
+ const ANTHROPIC_PROMPT_CACHE: PromptCacheTiers = { short: 300, long: 3600 }
623
+
587
624
  /**
588
625
  * Anthropic compat as pi-ai 0.85.1 types it, plus the affinity format a later
589
626
  * pi reads. Declared here so the pin does not depend on the host's pi-ai
@@ -635,6 +672,10 @@ export function toPengepulModels(
635
672
  // The 1h cache TTL is a Messages-dialect feature too: `cache_control.ttl`
636
673
  // has nowhere to go on the Chat Completions wire. Reasoning is not part
637
674
  // of it — a non-reasoning Claude model caches the same way.
675
+ //
676
+ // The lifetimes pi's cache warmer schedules its refreshes against, on
677
+ // this wire only, for the reason just given.
678
+ promptCache: { ...ANTHROPIC_PROMPT_CACHE },
638
679
  compat: {
639
680
  ...affinityPin(),
640
681
  ...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
@@ -692,11 +733,6 @@ interface FetchModelsOptions {
692
733
  lookupBuiltin?: BuiltinModelLookup
693
734
  }
694
735
 
695
- interface LoadModelsOptions extends FetchModelsOptions {
696
- cachePath: string
697
- relayBase: string
698
- }
699
-
700
736
  function errorMessage(error: unknown): string {
701
737
  return error instanceof Error ? error.message : String(error)
702
738
  }
@@ -816,95 +852,3 @@ export async function fetchPengepulModels(
816
852
 
817
853
  return modelsFromApiResponse(body, options.lookupBuiltin)
818
854
  }
819
-
820
- function numberField(record: Record<string, unknown>, key: string): number {
821
- const value = record[key]
822
- if (typeof value !== "number" || !Number.isFinite(value)) {
823
- throw new Error(`Expected ${key} to be a finite number`)
824
- }
825
- return value
826
- }
827
-
828
- function inputField(record: Record<string, unknown>, key: string): ModelInput {
829
- const value = record[key]
830
- if (!Array.isArray(value)) throw new Error(`Expected ${key} to be an array`)
831
- return value.map((entry) => {
832
- if (entry !== "text" && entry !== "image") {
833
- throw new Error(`Expected ${key} entries to be "text" or "image"`)
834
- }
835
- return entry
836
- })
837
- }
838
-
839
- function costField(record: Record<string, unknown>, key: string): ModelCostRates {
840
- const value = record[key]
841
- if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
842
- return {
843
- input: numberField(value, "input"),
844
- output: numberField(value, "output"),
845
- cacheRead: numberField(value, "cacheRead"),
846
- cacheWrite: numberField(value, "cacheWrite"),
847
- }
848
- }
849
-
850
- function levelMapField(
851
- record: Record<string, unknown>,
852
- key: string,
853
- ): Record<string, string | null> {
854
- const value = record[key]
855
- if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
856
- const map: Record<string, string | null> = {}
857
- for (const [level, mapped] of Object.entries(value)) {
858
- if (mapped !== null && typeof mapped !== "string") {
859
- throw new Error(`Expected ${key} values to be strings or null`)
860
- }
861
- map[level] = mapped
862
- }
863
- return map
864
- }
865
-
866
- export function modelsFromCache(value: unknown): readonly PengepulModel[] {
867
- if (!isRecord(value)) throw new Error("Expected model cache to be an object")
868
- if (value["version"] !== MODEL_CACHE_VERSION) {
869
- throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
870
- }
871
- if (!Array.isArray(value["models"])) throw new Error("Expected cached models to be an array")
872
-
873
- const parsed: PengepulModel[] = value["models"].map((entry) => {
874
- if (!isRecord(entry)) throw new Error("Expected cached model entry to be an object")
875
- return {
876
- id: stringField(entry, "id"),
877
- name: stringField(entry, "name"),
878
- dialect: stringField(entry, "dialect") as PengepulDialect,
879
- reasoning: entry["reasoning"] === true,
880
- input: inputField(entry, "input"),
881
- cost: costField(entry, "cost"),
882
- contextWindow: numberField(entry, "contextWindow"),
883
- maxTokens: numberField(entry, "maxTokens"),
884
- ...(entry["thinkingLevelMap"] !== undefined
885
- ? { thinkingLevelMap: levelMapField(entry, "thinkingLevelMap") }
886
- : {}),
887
- }
888
- })
889
- const servable = servableModels(parsed)
890
- if (servable.length === 0) throw new Error("pengepul cache holds no valid models")
891
- return servable
892
- }
893
-
894
- async function readCache(cachePath: string): Promise<readonly PengepulModel[]> {
895
- const contents = await readFile(cachePath, "utf-8")
896
- return modelsFromCache(JSON.parse(contents))
897
- }
898
-
899
- /** Reads the cached catalog without touching the network; empty when missing/invalid. */
900
- export async function loadCachedPengepulModels(
901
- cachePath: string,
902
- ): Promise<readonly PengepulModel[]> {
903
- try {
904
- return await readCache(cachePath)
905
- } catch {
906
- return []
907
- }
908
- }
909
-
910
-
@@ -2,11 +2,11 @@
2
2
  * The pengepul provider, as pi-ai sees it.
3
3
  *
4
4
  * Registered as a native provider, so pi resolves auth through this module and
5
- * hands the credential back to `refreshModels`. That closes two gaps the
6
- * config-value form had: model discovery now uses the same resolved key pi uses
7
- * for requests (it used to read only `PENGEPUL_API_KEY` and pengepul's own
8
- * config, so a key stored in `auth.json` produced a 401), and the relay base
9
- * comes from the credential instead of the environment.
5
+ * hands the credential back to `refreshModels`. Discovery and requests
6
+ * therefore resolve the same key and the same relay base: environment first,
7
+ * then the credential. A provider declared as plain config values has no
8
+ * credential at discovery time, so a key stored in `auth.json` left its
9
+ * catalog fetch with nothing to send but an environment variable, and a 401.
10
10
  *
11
11
  * The relay base lives on the credential as `baseUrl`, a field pi core does not
12
12
  * read. Per-model base URLs are derived from it, because pi applies a base URL
@@ -27,12 +27,10 @@ import type {
27
27
  } from "@earendil-works/pi-ai"
28
28
  import { anthropicMessagesApi, lazyStream, openAICompletionsApi } from "@earendil-works/pi-ai/compat"
29
29
 
30
- import { API_KEY_ENV, extractApiKeys } from "./api-key.ts"
31
- import { RELAY_BASE_ENV } from "./config.ts"
30
+ import { API_KEY_ENV, RELAY_BASE_ENV } from "./config.ts"
32
31
  import {
33
32
  credentialApiKey,
34
33
  credentialRelayBase,
35
- relayBaseFromConfigText,
36
34
  resolveApiKey,
37
35
  resolveRelayBase,
38
36
  type PengepulCredential,
@@ -43,7 +41,6 @@ import {
43
41
  fetchPengepulModels,
44
42
  getModelsTimeoutMs,
45
43
  isPengepulModelEntry,
46
- loadCachedPengepulModels,
47
44
  PENGEPUL_PROVIDER_ID,
48
45
  restampRelayBase,
49
46
  toPengepulModels,
@@ -58,16 +55,9 @@ export interface PengepulApiKeyCredential extends ApiKeyCredential {
58
55
  baseUrl?: string
59
56
  }
60
57
 
61
- /** Where the config-file fallback key comes from, for the status label. */
62
- const CONFIG_FILE_LABEL = "~/.pengepul/config.yaml"
63
-
64
58
  export interface PengepulProviderOptions {
65
59
  /** Environment map holding the optional `PENGEPUL_*` overrides. */
66
60
  env?: Record<string, string | undefined>
67
- /** Text of pengepul's own config, when this machine happens to run the relay. */
68
- configText?: string
69
- /** Where the pre-0.3 catalog cache lives; read once to seed pi's store. */
70
- legacyCachePath: string
71
61
  /** Catalog fetch transport, for tests. */
72
62
  fetchImpl?: typeof fetch
73
63
  /** Builtin metadata lookup, injected so the catalog logic stays free of pi imports. */
@@ -90,9 +80,9 @@ function isPengepulDialect(api: Api): api is PengepulDialect {
90
80
  /**
91
81
  * A provider whose catalog comes from pengepul and whose auth is the relay key.
92
82
  *
93
- * Ambient sources are read from the captured environment and config text, not
94
- * from `AuthContext.env`, because `refreshModels` receives no auth context and
95
- * both paths must agree on what the key and base are.
83
+ * The environment overrides are read from the captured environment, not from
84
+ * `AuthContext.env`, because `refreshModels` receives no auth context and both
85
+ * paths must agree on what the key and base are.
96
86
  */
97
87
  export function createPengepulProvider(options: PengepulProviderOptions): Provider {
98
88
  const env = options.env ?? process.env
@@ -104,23 +94,23 @@ export function createPengepulProvider(options: PengepulProviderOptions): Provid
104
94
 
105
95
  const environmentRelayBase = env[RELAY_BASE_ENV]
106
96
  const environmentApiKey = env[API_KEY_ENV]
107
- const configText = options.configText
108
- const configRelayBase = relayBaseFromConfigText(configText)
109
- const configApiKey = configText ? extractApiKeys(configText)[0] : undefined
110
97
 
111
98
  let models: PengepulModelEntry[] = []
112
99
 
113
- /** The key from outside auth.json, with the label the status UI shows. */
114
- function ambientKey(): { key?: string; source?: string } {
115
- if (environmentApiKey?.trim()) return { key: environmentApiKey.trim(), source: API_KEY_ENV }
116
- if (configApiKey) return { key: configApiKey, source: CONFIG_FILE_LABEL }
117
- return {}
100
+ /** The relay base in force: the environment override, else the credential, else loopback. */
101
+ function relayBaseFor(credential: unknown): string {
102
+ return resolveRelayBase({
103
+ environment: environmentRelayBase,
104
+ credential: credentialRelayBase(credential as PengepulCredential | undefined),
105
+ })
118
106
  }
119
107
 
120
- function resolveKey(credential: unknown): { key?: string; source?: string } {
121
- const stored = credentialApiKey(credential as PengepulCredential | undefined)
122
- if (stored) return { key: stored, source: "stored credential" }
123
- return ambientKey()
108
+ /** The key in force, with the label the status UI shows for it. */
109
+ function resolveKey(credential: unknown) {
110
+ return resolveApiKey({
111
+ environment: environmentApiKey,
112
+ credential: credentialApiKey(credential as PengepulCredential | undefined),
113
+ })
124
114
  }
125
115
 
126
116
  const streams: Record<PengepulDialect, ProviderStreams> = {
@@ -138,38 +128,26 @@ export function createPengepulProvider(options: PengepulProviderOptions): Provid
138
128
  })
139
129
  }
140
130
 
141
- async function restoreOrSeed(context: RefreshModelsContext): Promise<boolean> {
142
- const relayBase = resolveRelayBase({
143
- credential: credentialRelayBase(context.credential as PengepulCredential | undefined),
144
- environment: environmentRelayBase,
145
- config: configRelayBase,
146
- })
147
-
131
+ /**
132
+ * Re-publish pi's stored catalog with every base URL re-derived from the
133
+ * base in force, so a relay that moved is not reached at its old address.
134
+ * Returns false only when pi refused the publication, the one case that
135
+ * skips the network phase.
136
+ */
137
+ async function restoreStored(context: RefreshModelsContext): Promise<boolean> {
148
138
  const stored = (context.stored?.models ?? []).filter(isPengepulModelEntry)
149
- if (stored.length > 0) {
150
- const restored = restampRelayBase(stored, relayBase)
151
- return context.publish({ update: () => { models = restored } })
152
- }
153
-
154
- // Pre-0.3 installs kept their own cache file. Read it once so an upgrade
155
- // does not start blind; pi's store owns the catalog from here on.
156
- const legacy = await loadCachedPengepulModels(options.legacyCachePath)
157
- if (legacy.length === 0) return true
158
- const seeded = toPengepulModels(legacy, relayBase)
159
- return context.publish({ update: () => { models = seeded } })
139
+ if (stored.length === 0) return true
140
+ const restored = restampRelayBase(stored, relayBaseFor(context.credential))
141
+ return context.publish({ update: () => { models = restored } })
160
142
  }
161
143
 
162
144
  async function refreshFromRelay(context: RefreshModelsContext): Promise<void> {
163
- const relayBase = resolveRelayBase({
164
- credential: credentialRelayBase(context.credential as PengepulCredential | undefined),
165
- environment: environmentRelayBase,
166
- config: configRelayBase,
167
- })
145
+ const relayBase = relayBaseFor(context.credential)
168
146
 
169
147
  const resolved = resolveKey(context.credential)
170
- if (!resolved.key) {
148
+ if (!resolved) {
171
149
  logWarning(
172
- `No pengepul API key is configured (${API_KEY_ENV}, ${CONFIG_FILE_LABEL}, or auth.json), so the model catalog cannot be refreshed. Run /login pengepul.`,
150
+ `No pengepul API key is configured (${API_KEY_ENV} or auth.json), so the model catalog cannot be refreshed. Run /login pengepul.`,
173
151
  )
174
152
  return
175
153
  }
@@ -201,10 +179,7 @@ export function createPengepulProvider(options: PengepulProviderOptions): Provid
201
179
  return {
202
180
  id: PENGEPUL_PROVIDER_ID,
203
181
  name: "Pengepul",
204
- baseUrl: resolveRelayBase({
205
- environment: environmentRelayBase,
206
- config: configRelayBase,
207
- }),
182
+ baseUrl: resolveRelayBase({ environment: environmentRelayBase }),
208
183
  auth: {
209
184
  apiKey: {
210
185
  name: "Pengepul relay",
@@ -231,25 +206,20 @@ export function createPengepulProvider(options: PengepulProviderOptions): Provid
231
206
  },
232
207
  check: async (input): Promise<AuthCheck | undefined> => {
233
208
  const resolved = resolveKey(input.credential)
234
- return resolved.key
235
- ? { type: "api_key", ...(resolved.source ? { source: resolved.source } : {}) }
236
- : undefined
209
+ return resolved ? { type: "api_key", source: resolved.source } : undefined
237
210
  },
238
211
  resolve: async (input): Promise<AuthResult | undefined> => {
239
212
  const resolved = resolveKey(input.credential)
240
- if (!resolved.key) return undefined
213
+ if (!resolved) return undefined
241
214
  // No `baseUrl` here on purpose: pi would apply it to every model and
242
215
  // collapse the two dialect base URLs into one.
243
- return {
244
- auth: { apiKey: resolved.key },
245
- ...(resolved.source ? { source: resolved.source } : {}),
246
- }
216
+ return { auth: { apiKey: resolved.key }, source: resolved.source }
247
217
  },
248
218
  },
249
219
  },
250
220
  getModels: () => models,
251
221
  refreshModels: async (context) => {
252
- if (!(await restoreOrSeed(context))) return
222
+ if (!(await restoreStored(context))) return
253
223
  if (!context.allowNetwork || context.signal.aborted) return
254
224
  await refreshFromRelay(context)
255
225
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pwguler/pi-pengepul-provider",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "pi custom provider for pengepul, a relay that pools your Claude/Codex subscriptions. Key and relay base live in auth.json; both native wires (Anthropic Messages, OpenAI Chat Completions) come from one catalog.",
6
6
  "license": "MIT",
@@ -1,70 +0,0 @@
1
- /**
2
- * Read pengepul's own 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
- * This is the fallback for the machine that runs the relay itself: a client box
10
- * configures the key in `auth.json`, which pi resolves and hands to the
11
- * provider. Only extraction lives here, so the file read stays with the caller
12
- * and this module stays io-free.
13
- */
14
-
15
- export const DEFAULT_CONFIG_PATH = "~/.pengepul/config.yaml"
16
-
17
- export const API_KEY_ENV = "PENGEPUL_API_KEY"
18
- export const CONFIG_PATH_ENV = "PENGEPUL_CONFIG"
19
-
20
- /**
21
- * Extract `api-keys:` entries from pengepul's YAML config, without a YAML
22
- * dependency. Handles both the inline-flow form and the block-sequence form:
23
- *
24
- * api-keys: [sk-local-a, sk-local-b]
25
- * api-keys:
26
- * - sk-local-a
27
- * - sk-local-b
28
- */
29
- export function extractApiKeys(configText: string): string[] {
30
- const lines = configText.split(/\r?\n/)
31
- const keys: string[] = []
32
-
33
- for (let i = 0; i < lines.length; i++) {
34
- const line = lines[i]
35
- if (line === undefined) continue
36
-
37
- const match = /^\s*api-keys:\s*(.*)$/.exec(line)
38
- if (!match) continue
39
-
40
- const rest = (match[1] ?? "").trim()
41
- if (rest.startsWith("[")) {
42
- // Inline flow sequence: [a, b, c]
43
- for (const token of rest.slice(1, -1).split(",")) {
44
- const key = token.trim().replace(/^["']|["']$/g, "")
45
- if (key) keys.push(key)
46
- }
47
- } else if (rest === "" || rest === "|" || rest === ">") {
48
- // Block sequence follows. Sequence entries may sit at any indent —
49
- // pengepul itself writes them flush with the key (`- sk-local-…`) —
50
- // so scan forward through blanks, comments, and `- ` entries and
51
- // stop at the first line that starts another key.
52
- for (let j = i + 1; j < lines.length; j++) {
53
- const item = lines[j]
54
- if (item === undefined) continue
55
- const token = item.trim()
56
- if (token === "" || token.startsWith("#")) continue
57
- if (!token.startsWith("-")) break
58
- const value = token.slice(1).trim().replace(/^["']|["']$/g, "")
59
- if (value) keys.push(value)
60
- }
61
- } else {
62
- // Single inline scalar: api-keys: sk-local-a
63
- const value = rest.replace(/^["']|["']$/g, "")
64
- if (value) keys.push(value)
65
- }
66
- break
67
- }
68
-
69
- return keys
70
- }