@pwguler/pi-pengepul-provider 0.4.0 → 0.5.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
@@ -89,25 +89,45 @@ To reach a relay on another machine, put that machine's address in `baseUrl`
89
89
  `~/.pengepul/config.yaml` on the relay, or forward the port over SSH). The key
90
90
  is the relay's own key — `pengepul config api-key` prints it.
91
91
 
92
- Optional overrides, for CI or a one-off shell. Each is a fallback: the
93
- credential wins when it carries a value.
92
+ Optional overrides, for CI or a one-off shell. The first two outrank the
93
+ credential: set either and it wins, whatever `auth.json` holds.
94
94
 
95
95
  | Setting | Env var | Default |
96
96
  |---|---|---|
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` |
97
+ | Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the credential's `baseUrl` |
98
+ | API key | `PENGEPUL_API_KEY` | the credential's `key` |
101
99
  | Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
102
100
 
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.
101
+ ```sh
102
+ PENGEPUL_BASE_URL=http://10.0.0.9:8317 PENGEPUL_API_KEY=sk-local-... pi
103
+ ```
105
104
 
106
105
  Do not set `providers.pengepul.baseUrl` in `models.json`. pi applies that value
107
106
  to every model of the provider, which collapses the two wires onto one URL —
108
107
  Anthropic Messages traffic would be sent to `/v1` and Chat Completions traffic
109
108
  to `/`.
110
109
 
110
+ ## Upgrading from 0.4.0 or earlier
111
+
112
+ This release is breaking: the relay's own `~/.pengepul/config.yaml` is no longer
113
+ a source. That file belongs to the machine running the relay, and reading it let
114
+ a key on that box configure every client sharing it.
115
+
116
+ - A key that came from `api-keys:` in it is no longer picked up. Run
117
+ `/login pengepul`, or put it in `auth.json` as `"pengepul".key`, or export
118
+ `PENGEPUL_API_KEY`. `pengepul config api-key` prints it.
119
+ - The relay base built from its `host` and `port` is no longer picked up
120
+ either. Set `baseUrl` in `auth.json`, or `PENGEPUL_BASE_URL`, if the relay is
121
+ not on `http://127.0.0.1:8317`.
122
+ - A pre-0.3 `<agent-dir>/pengepul-models.json` is no longer imported. The
123
+ catalog now comes from the relay on the next start, so the relay has to be
124
+ reachable once; pi's model store covers the rest.
125
+
126
+ Resolution order changed as well, and that is the part to check after an
127
+ upgrade: `PENGEPUL_BASE_URL` and `PENGEPUL_API_KEY` now win over `auth.json`,
128
+ where they previously lost to it. An exported key from an old shell now
129
+ overrides the one in the credential.
130
+
111
131
  ## Notes
112
132
 
113
133
  - 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))
@@ -692,11 +690,6 @@ interface FetchModelsOptions {
692
690
  lookupBuiltin?: BuiltinModelLookup
693
691
  }
694
692
 
695
- interface LoadModelsOptions extends FetchModelsOptions {
696
- cachePath: string
697
- relayBase: string
698
- }
699
-
700
693
  function errorMessage(error: unknown): string {
701
694
  return error instanceof Error ? error.message : String(error)
702
695
  }
@@ -816,95 +809,3 @@ export async function fetchPengepulModels(
816
809
 
817
810
  return modelsFromApiResponse(body, options.lookupBuiltin)
818
811
  }
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.0",
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
- }