@pwguler/pi-pengepul-provider 0.2.3 → 0.3.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/README.md CHANGED
@@ -41,8 +41,8 @@ prefixed `pengepul/<id>`. To try it without installing, use
41
41
 
42
42
  ## What it does
43
43
 
44
- - Registers the `pengepul` provider against your relay's base URL
45
- (`http://127.0.0.1:8317` by default).
44
+ - Registers the `pengepul` provider against the relay base URL on your
45
+ credential (`http://127.0.0.1:8317` by default).
46
46
  - Discovers models from `GET /v1/models`, maps each to the right wire:
47
47
  - `claude-*` / `anthropic/*` and `owned_by: anthropic` → Anthropic Messages
48
48
  (`POST /v1/messages`),
@@ -53,24 +53,55 @@ prefixed `pengepul/<id>`. To try it without installing, use
53
53
  `max_output_tokens`, `input_modalities`, `pricing`). Fields the relay omits
54
54
  fall back to pi's builtin catalog for the same id, then to family
55
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.
56
+ - Caches the catalog in pi's model store, so startup does not wait on the
57
+ network and a briefly absent relay is covered. The cached models are
58
+ re-pointed at the relay base configured now, so moving the relay does not
59
+ leave requests aimed at the old address.
58
60
  - Reuses pi's built-in stream functions for both wires — no custom transport.
59
- - Registers no commands: the catalog refreshes on every startup.
61
+ - Registers no commands: pi refreshes the catalog on every startup.
60
62
 
61
63
  ## Configuration
62
64
 
65
+ Everything lives in `~/.pi/agent/auth.json`: the relay's API key and the relay
66
+ base it applies to.
67
+
68
+ ```json
69
+ {
70
+ "pengepul": {
71
+ "type": "api_key",
72
+ "key": "sk-local-...",
73
+ "baseUrl": "http://127.0.0.1:8317"
74
+ }
75
+ }
76
+ ```
77
+
78
+ `/login pengepul` writes both fields for you — it asks for the key, then for the
79
+ relay URL, and defaults the URL to `http://127.0.0.1:8317`. Editing the file by
80
+ hand works the same way; `baseUrl` may end in `/v1` or not.
81
+
82
+ To reach a relay on another machine, put that machine's address in `baseUrl`
83
+ (and make the relay listen beyond loopback there: `host: 0.0.0.0` in
84
+ `~/.pengepul/config.yaml` on the relay, or forward the port over SSH). The key
85
+ is the relay's own key — `pengepul config api-key` prints it.
86
+
87
+ Optional overrides, for CI or a one-off shell. Each is a fallback: the
88
+ credential wins when it carries a value.
89
+
63
90
  | Setting | Env var | Default |
64
91
  |---|---|---|
65
- | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317` |
66
- | API key | `PENGEPUL_API_KEY` | read from `~/.pengepul/config.yaml` |
92
+ | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the relay's own `config.yaml` |
93
+ | API key | `PENGEPUL_API_KEY` | `api-keys[0]` in `~/.pengepul/config.yaml` |
67
94
  | Config path | `PENGEPUL_CONFIG` | `~/.pengepul/config.yaml` |
68
- | Model cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
95
+ | Legacy cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
69
96
  | Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
70
97
 
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.
98
+ `PENGEPUL_MODELS_CACHE` names the pre-0.3 cache file. It is read once, when pi's
99
+ model store has no pengepul catalog yet, and never written again.
100
+
101
+ Do not set `providers.pengepul.baseUrl` in `models.json`. pi applies that value
102
+ to every model of the provider, which collapses the two wires onto one URL —
103
+ Anthropic Messages traffic would be sent to `/v1` and Chat Completions traffic
104
+ to `/`.
74
105
 
75
106
  ## Notes
76
107
 
@@ -81,16 +112,40 @@ is set.
81
112
  pengepul bills against — displayed costs are upstream list prices.
82
113
  - The relay must be running and reachable for discovery to succeed. Without a
83
114
  cached catalog on a first start, pengepul models stay unavailable until a
84
- start with the relay up.
115
+ start with the relay up. A relay that answers with 401 leaves the last known
116
+ catalog registered and logs a warning; discovery does not take pi down with
117
+ it.
85
118
 
86
119
  ## Development
87
120
 
88
121
  ```sh
122
+ npm install
89
123
  bun test
90
124
  npx tsc --noEmit
91
125
  bun scripts/e2e-live.ts # live e2e against a running relay; sends one tiny completion
92
126
  ```
93
127
 
128
+ The `@earendil-works/*` copies `npm install` writes into `node_modules` are for
129
+ `tsc` and the tests only. At runtime pi serves the extension those modules from
130
+ its own bundle (`loader.js` maps `@earendil-works/pi-ai/providers/all` to a
131
+ virtual module), so the local copies exist to be *type-checked against*, not to
132
+ be the ones in play.
133
+
134
+ Nothing here can enforce that the two match: the peer ranges are `*` and the
135
+ host's version is not knowable at install time. The lockfile records whichever
136
+ version was current when it was last refreshed, so the comparison is a manual
137
+ one, worth making whenever a measurement has to be trusted:
138
+
139
+ ```sh
140
+ pi --version # host pi release
141
+ cat node_modules/@earendil-works/pi-ai/package.json # local copy
142
+ ```
143
+
144
+ When they drift, anything measured from this directory describes a different
145
+ model catalog than the running extension sees — the 0.84.4 copy carried 40
146
+ `:batch` entries where 0.85.1 carries 68 — and local verification quietly
147
+ disagrees with production.
148
+
94
149
  ## Releasing
95
150
 
96
151
  Releases publish from CI on a version tag. Bump, tag, push:
@@ -1,53 +1,22 @@
1
1
  /**
2
- * Resolve pengepul's local API key.
2
+ * Read pengepul's own API key.
3
3
  *
4
4
  * pengepul authenticates every request with a static key from its config:
5
5
  * `~/.pengepul/config.yaml`, under `api-keys:` (the first is generated on first
6
6
  * run and is `sk-local-...`). Clients send it as `Authorization: Bearer <key>`
7
7
  * or `x-api-key: <key>`.
8
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.
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.
11
13
  */
12
14
 
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
- }
15
+ export const DEFAULT_CONFIG_PATH = "~/.pengepul/config.yaml"
22
16
 
23
17
  export const API_KEY_ENV = "PENGEPUL_API_KEY"
24
18
  export const CONFIG_PATH_ENV = "PENGEPUL_CONFIG"
25
19
 
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
20
  /**
52
21
  * Extract `api-keys:` entries from pengepul's YAML config, without a YAML
53
22
  * dependency. Handles both the inline-flow form and the block-sequence form:
@@ -1,40 +1,34 @@
1
1
  /**
2
- * Resolve pengepul connection and cache settings from the environment.
2
+ * Where the pengepul provider's fallback sources live.
3
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.
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.
6
7
  */
7
8
 
8
9
  import { join } from "node:path"
9
10
 
10
- import { DEFAULT_RELAY_BASE } from "./models.ts"
11
+ import { CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH } from "./api-key.ts"
12
+
13
+ export { CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH }
11
14
 
12
15
  export const RELAY_BASE_ENV = "PENGEPUL_BASE_URL"
13
16
  export const MODELS_CACHE_ENV = "PENGEPUL_MODELS_CACHE"
14
17
  export const MODELS_TIMEOUT_MS_ENV = "PENGEPUL_MODELS_TIMEOUT_MS"
15
18
 
16
19
  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
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
23
24
  }
24
25
 
25
26
  export function resolveSettings(
26
27
  env: Record<string, string | undefined>,
27
28
  agentDir: string,
28
29
  ): 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 }
30
+ return {
31
+ configPath: env[CONFIG_PATH_ENV] ?? DEFAULT_CONFIG_PATH,
32
+ legacyCachePath: env[MODELS_CACHE_ENV] ?? join(agentDir, "pengepul-models.json"),
33
+ }
40
34
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pengepul credential and relay-base resolution.
3
+ *
4
+ * The credential is the pengepul entry in pi's `auth.json`: an API key for the
5
+ * relay plus the relay base it applies to. pi core resolves the key itself but
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.
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.
12
+ */
13
+
14
+ import { normalizeRootBaseUrl } from "./dialect.ts"
15
+ import { DEFAULT_RELAY_BASE } from "./models.ts"
16
+
17
+ /** The fields this provider reads off a stored credential. */
18
+ export interface PengepulCredential {
19
+ /** Always `"api_key"` in pi's store; read as unknown so a hand-edited file cannot lie its way past a cast. */
20
+ type?: unknown
21
+ key?: unknown
22
+ baseUrl?: unknown
23
+ }
24
+
25
+ function nonEmptyString(value: unknown): string | undefined {
26
+ if (typeof value !== "string") return undefined
27
+ const trimmed = value.trim()
28
+ return trimmed === "" ? undefined : trimmed
29
+ }
30
+
31
+ /** The relay base stored on the credential, when the user set one. */
32
+ export function credentialRelayBase(credential: PengepulCredential | undefined): string | undefined {
33
+ return nonEmptyString(credential?.baseUrl)
34
+ }
35
+
36
+ /** The API key stored on the credential, when the user set one. */
37
+ export function credentialApiKey(credential: PengepulCredential | undefined): string | undefined {
38
+ return nonEmptyString(credential?.key)
39
+ }
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
+ export interface RelayBaseSources {
81
+ /** The relay base stored on the pengepul credential. */
82
+ credential?: string | undefined
83
+ /** The `PENGEPUL_BASE_URL` environment value. */
84
+ environment?: string | undefined
85
+ /** The relay base derived from pengepul's own config file. */
86
+ config?: string | undefined
87
+ }
88
+
89
+ /**
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.
93
+ */
94
+ export function resolveRelayBase(sources: RelayBaseSources): string {
95
+ const chosen =
96
+ nonEmptyString(sources.credential) ??
97
+ nonEmptyString(sources.environment) ??
98
+ nonEmptyString(sources.config) ??
99
+ DEFAULT_RELAY_BASE
100
+ return normalizeRootBaseUrl(chosen)
101
+ }
102
+
103
+ export interface ApiKeySources {
104
+ /** The key stored on the pengepul credential. */
105
+ credential?: string | undefined
106
+ /** The key resolved from `PENGEPUL_API_KEY` or pengepul's own config file. */
107
+ ambient?: string | undefined
108
+ }
109
+
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)
113
+ }
@@ -1,30 +1,24 @@
1
1
  /**
2
2
  * @pwguler/pi-pengepul-provider entry point - the real edge adapter.
3
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.
4
+ * Registers pengepul as a pi provider. pengepul is a relay that pools your
5
+ * Claude/Codex subscriptions and speaks both native wires. The provider itself
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.
8
10
  */
9
11
 
10
12
  import {
11
13
  getAgentDir,
12
14
  type ExtensionAPI,
13
- type ProviderConfig,
14
15
  } from "@earendil-works/pi-coding-agent"
15
16
  import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"
16
17
  import { readFileSync } from "node:fs"
17
18
 
18
- import { resolveApiKey } from "./api-key.ts"
19
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"
20
+ import { catalogIdForms, type PengepulModel } from "./models.ts"
21
+ import { createPengepulProvider } from "./provider.ts"
28
22
 
29
23
  function expandHome(path: string): string {
30
24
  if (path === "~") return process.env.HOME ?? path
@@ -53,11 +47,10 @@ function metaFromModel(model: NonNullable<ReturnType<typeof getBuiltinModel>>) {
53
47
  }
54
48
 
55
49
  /**
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.
50
+ * Multi-catalog lookup over pi's builtin models. A relay id can match several
51
+ * catalogs, so each of the id's shapes is tried in turn - verbatim, bare under
52
+ * a vendor catalog, last segment, and with the routing namespace removed - and
53
+ * the first hit wins. Reasoning metadata and the thinkingLevelMap flow from it.
61
54
  */
62
55
  function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<typeof metaFromModel> | undefined {
63
56
  type Entry = { provider: string; id: string };
@@ -77,70 +70,40 @@ function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<type
77
70
  }
78
71
 
79
72
  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)
73
+ for (const form of catalogIdForms(id)) {
74
+ const candidates: Array<Entry | undefined> = [
75
+ exact.get(form),
76
+ segments.get(form),
77
+ lower.get(form.toLowerCase()),
78
+ ]
79
+ for (const candidate of candidates) {
80
+ if (candidate === undefined) continue
81
+ const model = getBuiltinModel(candidate.provider as never, candidate.id as never)
82
+ if (model) return metaFromModel(model)
83
+ }
91
84
  }
92
85
  return undefined
93
86
  }
94
87
  }
95
88
 
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
89
  /**
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.
90
+ * 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
92
+ * allowed, against the relay. Registration is synchronous; nothing here waits
93
+ * on the relay, and the catalog survives a restart through pi's model store.
115
94
  */
116
- export default async function (pi: ExtensionAPI) {
95
+ export default function (pi: ExtensionAPI) {
117
96
  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
97
 
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),
98
+ const provider = createPengepulProvider({
99
+ env: process.env,
100
+ configText: readConfigText(settings.configPath),
101
+ legacyCachePath: settings.legacyCachePath,
102
+ lookupBuiltin: createBuiltinLookup(),
139
103
  })
140
104
 
141
- pi.on("session_shutdown", () => {
142
- runtime.dispose()
143
- })
144
-
145
- await runtime.initialize()
105
+ pi.registerProvider(provider)
146
106
  }
107
+
108
+ /** The pengepul catalog shape, re-exported for callers that build on the core. */
109
+ export type { PengepulModel }