@pwguler/pi-pengepul-provider 0.2.4 → 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,7 +112,9 @@ 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
 
@@ -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,31 +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
- catalogIdForms,
23
- loadCachedPengepulModels,
24
- loadPengepulModels,
25
- toProviderModelConfigs,
26
- type PengepulModel,
27
- } from "./models.ts"
28
- import { createPengepulRuntime } from "./runtime.ts"
20
+ import { catalogIdForms, type PengepulModel } from "./models.ts"
21
+ import { createPengepulProvider } from "./provider.ts"
29
22
 
30
23
  function expandHome(path: string): string {
31
24
  if (path === "~") return process.env.HOME ?? path
@@ -93,49 +86,24 @@ function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<type
93
86
  }
94
87
  }
95
88
 
96
- function createProviderConfigFactory(relayBase: string, apiKey: string | undefined) {
97
- return (models: readonly PengepulModel[]): ProviderConfig => ({
98
- name: "Pengepul",
99
- baseUrl: relayBase,
100
- apiKey: apiKey ?? "$PENGEPUL_API_KEY",
101
- api: "anthropic-messages",
102
- models: toProviderModelConfigs(models, relayBase),
103
- })
104
- }
105
-
106
89
  /**
107
- * Model discovery and provider registration are async: the relay's catalog is
108
- * fetched live (and cached), so the runtime handles the cache-first, then
109
- * 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.
110
94
  */
111
- export default async function (pi: ExtensionAPI) {
95
+ export default function (pi: ExtensionAPI) {
112
96
  const settings = resolveSettings(process.env, getAgentDir())
113
- const apiKey = resolveApiKey(process.env, readConfigText).key
114
97
 
115
- // The relay advertises only ids; context/pricing/modality numbers come from
116
- // pi's builtin catalogs, searched across providers until one knows the id
117
- // (aggregator, vendor, and last-segment shapes). The lookup is injected so
118
- // the catalog logic stays free of pi-ai imports.
119
- const lookupBuiltin = createBuiltinLookup()
120
-
121
- const runtime = createPengepulRuntime(pi, {
122
- loadModels: (signal) =>
123
- loadPengepulModels({
124
- url: modelsUrl(settings.relayBase),
125
- apiKey,
126
- cachePath: settings.modelsCachePath,
127
- relayBase: settings.relayBase,
128
- timeoutMs: settings.modelsTimeoutMs,
129
- lookupBuiltin,
130
- signal,
131
- }),
132
- loadCachedModels: () => loadCachedPengepulModels(settings.modelsCachePath),
133
- createProviderConfig: createProviderConfigFactory(settings.relayBase, apiKey),
134
- })
135
-
136
- pi.on("session_shutdown", () => {
137
- runtime.dispose()
98
+ const provider = createPengepulProvider({
99
+ env: process.env,
100
+ configText: readConfigText(settings.configPath),
101
+ legacyCachePath: settings.legacyCachePath,
102
+ lookupBuiltin: createBuiltinLookup(),
138
103
  })
139
104
 
140
- await runtime.initialize()
105
+ pi.registerProvider(provider)
141
106
  }
107
+
108
+ /** The pengepul catalog shape, re-exported for callers that build on the core. */
109
+ export type { PengepulModel }
@@ -20,10 +20,9 @@
20
20
  * The network/cache are injected so the catalog logic stays testable.
21
21
  */
22
22
 
23
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
24
- import { randomUUID } from "node:crypto"
25
- import { dirname } from "node:path"
23
+ import { readFile } from "node:fs/promises"
26
24
 
25
+ import { MODELS_TIMEOUT_MS_ENV } from "./config.ts"
27
26
  import { baseUrlForDialect, dialectForModelId } from "./dialect.ts"
28
27
  import type { PengepulDialect } from "./dialect.ts"
29
28
 
@@ -65,7 +64,7 @@ export interface BuiltinModelMeta {
65
64
  */
66
65
  export type BuiltinModelLookup = (id: string, dialect: PengepulDialect) => BuiltinModelMeta | undefined
67
66
 
68
- /** A pengepul model ready to become a pi `ProviderModelConfig`. */
67
+ /** A pengepul model ready to become a pi model entry. */
69
68
  export interface PengepulModel {
70
69
  id: string
71
70
  name: string
@@ -79,12 +78,6 @@ export interface PengepulModel {
79
78
  thinkingLevelMap?: Record<string, string | null>
80
79
  }
81
80
 
82
- export interface PengepulModelSource {
83
- models: readonly PengepulModel[]
84
- /** "live" = fetched from the relay; "cache" = read from disk; "empty" = none. */
85
- source: "live" | "cache" | "empty"
86
- warning?: string
87
- }
88
81
 
89
82
  /** `anthropic/claude-opus-5` -> `claude-opus-5` (the id upstream actually serves). */
90
83
  export function bareId(id: string): string {
@@ -454,105 +447,162 @@ export function modelsFromApiResponse(
454
447
  return models
455
448
  }
456
449
 
457
- /** Map models to pi `ProviderModelConfig` entries. Pure. */
458
- export function toProviderModelConfigs(
459
- models: readonly PengepulModel[],
460
- relayBase: string,
461
- ): Array<{
462
- id: string
463
- name: string
464
- api: PengepulDialect
465
- baseUrl: string
466
- reasoning: boolean
467
- input: ("text" | "image")[]
468
- cost: {
469
- input: number
470
- output: number
471
- cacheRead: number
472
- cacheWrite: number
450
+ import type { Api, Model } from "@earendil-works/pi-ai"
451
+
452
+ /** The pi-ai model shapes this provider serves: one per dialect the relay speaks. */
453
+ export type PengepulModelEntry = Model<"anthropic-messages"> | Model<"openai-completions">
454
+
455
+ /** The provider id every pengepul model is stamped with. */
456
+ export const PENGEPUL_PROVIDER_ID = "pengepul"
457
+
458
+ /** What every model carries regardless of wire: identity, pricing, and the limits. */
459
+ function sharedModelFields(model: PengepulModel, relayBase: string) {
460
+ return {
461
+ id: model.id,
462
+ name: model.name,
463
+ provider: PENGEPUL_PROVIDER_ID,
464
+ baseUrl: baseUrlForDialect(relayBase, model.dialect),
465
+ reasoning: model.reasoning,
466
+ input: model.input,
467
+ cost: model.cost,
468
+ contextWindow: model.contextWindow,
469
+ maxTokens: model.maxTokens,
473
470
  }
474
- contextWindow: number
475
- maxTokens: number
476
- thinkingLevelMap?: Record<string, string | null>
477
- compat?: {
478
- forceAdaptiveThinking?: boolean
479
- supportsLongCacheRetention?: boolean
480
- sendSessionAffinityHeaders?: boolean
481
- sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"
471
+ }
472
+
473
+ /**
474
+ * The relay's prompt-cache affinity pin, on both wires.
475
+ *
476
+ * The relay's conversation_key resolves `x-claude-code-session-id`, then
477
+ * `x-session-id`, then the body's `prompt_cache_key`, then a hash of the
478
+ * cacheable prefix. pi emits one of those headers only for the `openrouter`
479
+ * affinity format, and only when the send flag is set. Both auto-detected
480
+ * defaults are wrong here: openai-completions picks `openai` (session_id +
481
+ * x-client-request-id + x-session-affinity) and anthropic-messages picks
482
+ * nothing at all. The header is the cheaper and more explicit of the two
483
+ * signals and it outranks the body field, so the pin keeps a session's account
484
+ * stable by the relay's first rule rather than its third. Losing it costs a
485
+ * session that migrates between pooled accounts its whole prefix: the upstream
486
+ * cache is per account.
487
+ *
488
+ * Measured, not assumed — `test/affinity-wire.test.ts` dumps both bodies:
489
+ * openai-completions carries `prompt_cache_key: <sessionId>` (and
490
+ * `prompt_cache_retention: "24h"`) under PI_CACHE_RETENTION=long, so the body
491
+ * field alone would name the conversation; anthropic-messages carries no
492
+ * `prompt_cache_key` at all, and pi-ai hardcodes `x-session-affinity` there,
493
+ * which this relay does not read. Messages traffic therefore rests entirely on
494
+ * the relay's prefix fallback until a pi release honours
495
+ * `sessionAffinityFormat` on that dialect.
496
+ *
497
+ * The two dialects do not land at the same time. openai-completions honors
498
+ * `sessionAffinityFormat` in every released pi. anthropic-messages only reads it
499
+ * from the unreleased change on pi main (commit bbb61e34a), which is why the
500
+ * field is re-declared below rather than taken from `AnthropicMessagesCompat`:
501
+ * against pi-ai 0.85.1 the pin is inert, and the cost of an ignored header is
502
+ * zero. Pin now rather than later — the cost of forgetting is silently
503
+ * re-billed Claude prefixes.
504
+ */
505
+ function affinityPin() {
506
+ return {
507
+ sendSessionAffinityHeaders: true as const,
508
+ sessionAffinityFormat: "openrouter" as const,
482
509
  }
483
- }> {
510
+ }
511
+
512
+ /**
513
+ * Anthropic compat as pi-ai 0.85.1 types it, plus the affinity format a later
514
+ * pi reads. Declared here so the pin does not depend on the host's pi-ai
515
+ * version; the field is optional, so a host that predates it ignores the value.
516
+ */
517
+ type AnthropicCompat = NonNullable<Model<"anthropic-messages">["compat"]> & {
518
+ sessionAffinityFormat?: "openrouter"
519
+ }
520
+
521
+ /**
522
+ * Map the relay catalog to pi models, one base URL per dialect. Pure.
523
+ *
524
+ * The per-model `baseUrl` is the only place the dialect split can live: pi
525
+ * applies a base URL returned from `auth.resolve()` to every model at once
526
+ * (`models.js` `applyAuth`), so a provider-wide value would send Anthropic
527
+ * Messages traffic to `/v1` and Chat Completions traffic to the root.
528
+ */
529
+ export function toPengepulModels(
530
+ models: readonly PengepulModel[],
531
+ relayBase: string,
532
+ ): PengepulModelEntry[] {
484
533
  return models.map((model) => {
485
- const adaptive = model.dialect === "anthropic-messages" && model.reasoning
486
- // The 1h cache TTL is a Messages-dialect feature: `cache_control.ttl`
487
- // has nowhere to go on the Chat Completions wire. Reasoning is not part
488
- // of it — a non-reasoning Claude model caches the same way.
489
- const longCacheRetention = model.dialect === "anthropic-messages"
534
+ const shared = sharedModelFields(model, relayBase)
535
+ if (model.dialect === "anthropic-messages") {
536
+ const adaptive = model.reasoning
537
+ return {
538
+ ...shared,
539
+ api: "anthropic-messages" as const,
540
+ // Inherited level mapping (e.g. deepseek {high:"high"}) flows through;
541
+ // adaptive Claude models additionally mark "off" unsupported so the
542
+ // stream omits thinking:{type:"disabled"} (upstream rejects it).
543
+ ...(model.thinkingLevelMap || adaptive
544
+ ? {
545
+ thinkingLevelMap: {
546
+ ...(model.thinkingLevelMap ?? {}),
547
+ ...(adaptive ? { off: null } : {}),
548
+ },
549
+ }
550
+ : {}),
551
+ // Reasoning-capable Claude models run on the adaptive-thinking wire:
552
+ // pi's streamSimple always passes thinkingEnabled:false when no level is
553
+ // selected, and the stream would send thinking:{type:"disabled"}, which
554
+ // the upstream rejects (400: "thinking.type.disabled is not supported
555
+ // for this model"). thinkingLevelMap.off = null marks "off" as
556
+ // unsupported so pi omits the thinking param entirely (server default
557
+ // = adaptive), and forceAdaptiveThinking routes an explicit level to
558
+ // {type:"adaptive"} + effort instead of budget_tokens.
559
+ //
560
+ // The 1h cache TTL is a Messages-dialect feature too: `cache_control.ttl`
561
+ // has nowhere to go on the Chat Completions wire. Reasoning is not part
562
+ // of it — a non-reasoning Claude model caches the same way.
563
+ compat: {
564
+ ...affinityPin(),
565
+ ...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
566
+ supportsLongCacheRetention: true as const,
567
+ } satisfies AnthropicCompat,
568
+ }
569
+ }
570
+
490
571
  return {
491
- id: model.id,
492
- name: model.name,
493
- api: model.dialect,
494
- baseUrl: baseUrlForDialect(relayBase, model.dialect),
495
- reasoning: model.reasoning,
496
- input: model.input,
497
- cost: model.cost,
498
- contextWindow: model.contextWindow,
499
- maxTokens: model.maxTokens,
500
- // Inherited level mapping (e.g. deepseek {high:"high"}) flows through;
501
- // adaptive Claude models additionally mark "off" unsupported so the
502
- // stream omits thinking:{type:"disabled"} (upstream rejects it).
503
- ...(model.thinkingLevelMap || adaptive
504
- ? { thinkingLevelMap: { ...(model.thinkingLevelMap ?? {}), ...(adaptive ? { off: null } : {}) } }
505
- : {}),
506
- // Reasoning-capable Claude models run on the adaptive-thinking wire:
507
- // pi's streamSimple always passes thinkingEnabled:false when no level is
508
- // selected, and the stream would send thinking:{type:"disabled"}, which
509
- // the upstream rejects (400: "thinking.type.disabled is not supported
510
- // for this model"). thinkingLevelMap.off = null marks "off" as
511
- // unsupported so pi omits the thinking param entirely (server default
512
- // = adaptive), and forceAdaptiveThinking routes an explicit level to
513
- // {type:"adaptive"} + effort instead of budget_tokens.
514
- // Both dialects pin both fields, and that is why `compat` is
515
- // unconditional. The relay's prompt-cache affinity key resolves in this
516
- // order: `x-claude-code-session-id`, `x-session-id`, the body's
517
- // `prompt_cache_key`, then a hash of the cacheable request prefix
518
- // (app.rs `conversation_key`). pi emits `x-session-id` only for the
519
- // `openrouter` affinity format, and only when the send flag is set;
520
- // the auto-detected defaults are wrong here in both cases
521
- // (openai-completions picks `openai`: session_id + x-client-request-id +
522
- // x-session-affinity; anthropic-messages picks nothing). The header is
523
- // the cheaper and more explicit of the two signals and it outranks the
524
- // body field, so pinning it keeps a session's account stable by the
525
- // relay's first rule rather than its third. Losing the pin costs a
526
- // session that migrates between pooled accounts its whole prefix: the
527
- // upstream cache is per account.
528
- //
529
- // Measured, not assumed — `test/affinity-wire.test.ts` dumps both bodies:
530
- // openai-completions carries `prompt_cache_key: <sessionId>` (and
531
- // `prompt_cache_retention: "24h"`) under PI_CACHE_RETENTION=long, so the
532
- // body field alone would name the conversation; anthropic-messages
533
- // carries no `prompt_cache_key` at all, and pi-ai hardcodes
534
- // `x-session-affinity` there, which this relay does not read. Messages
535
- // traffic therefore rests entirely on the relay's prefix fallback until
536
- // a pi release honours `sessionAffinityFormat` on that dialect.
537
- //
538
- // The two dialects do not land at the same time. openai-completions
539
- // honors `sessionAffinityFormat` in every released pi. anthropic-messages
540
- // only reads it from the unreleased change on pi main (commit
541
- // bbb61e34a); through pi-ai 0.85.1 that client hardcodes the header name
542
- // `x-session-affinity`, which this relay does not read, so the Claude
543
- // pin below is inert until pi ships it. Pin now rather than later: the
544
- // cost of an ignored header is zero, and the cost of forgetting is
545
- // silently re-billed Claude prefixes.
546
- compat: {
547
- ...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
548
- ...(longCacheRetention ? { supportsLongCacheRetention: true as const } : {}),
549
- sendSessionAffinityHeaders: true as const,
550
- sessionAffinityFormat: "openrouter" as const,
551
- },
572
+ ...shared,
573
+ api: "openai-completions" as const,
574
+ ...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
575
+ compat: { ...affinityPin() },
552
576
  }
553
577
  })
554
578
  }
555
579
 
580
+ /** Whether a stored pi model is one this provider published. */
581
+ export function isPengepulModelEntry(model: Model<Api>): model is PengepulModelEntry {
582
+ return (
583
+ model.provider === PENGEPUL_PROVIDER_ID &&
584
+ (model.api === "anthropic-messages" || model.api === "openai-completions")
585
+ )
586
+ }
587
+
588
+ /**
589
+ * Re-derive every model's base URL from the base currently configured.
590
+ *
591
+ * pi's model store keeps whole models, baseUrl included, and replays them
592
+ * before the network phase. A relay that moved would otherwise be reached at
593
+ * its old address until a fetch succeeds — which never happens when the old
594
+ * address is gone.
595
+ */
596
+ export function restampRelayBase(
597
+ models: readonly PengepulModelEntry[],
598
+ relayBase: string,
599
+ ): PengepulModelEntry[] {
600
+ return models.map((model) => ({
601
+ ...model,
602
+ baseUrl: baseUrlForDialect(relayBase, model.api),
603
+ }))
604
+ }
605
+
556
606
  /** Picker label: the bare model part of a relay id, suffixed. `anthropic/claude-opus-5` -> `claude-opus-5 (pengepul)`. */
557
607
  function displayName(id: string): string {
558
608
  return `${bareId(id)} (pengepul)`
@@ -588,7 +638,7 @@ function configuredTimeoutMs(timeoutMs: number | undefined): number {
588
638
  }
589
639
 
590
640
  export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
591
- const raw = env["PENGEPUL_MODELS_TIMEOUT_MS"]
641
+ const raw = env[MODELS_TIMEOUT_MS_ENV]
592
642
  if (!raw) return DEFAULT_MODELS_TIMEOUT_MS
593
643
  const parsed = Number(raw)
594
644
  return configuredTimeoutMs(parsed)
@@ -674,7 +724,7 @@ export async function fetchPengepulModels(
674
724
  throw new Error(
675
725
  `pengepul rejected the API key (${
676
726
  response.status
677
- }). Set PENGEPUL_API_KEY or check ~/.pengepul/config.yaml.`,
727
+ }). Run /login pengepul, or set the key in ~/.pi/agent/auth.json or PENGEPUL_API_KEY.`,
678
728
  )
679
729
  }
680
730
  if (!response.ok) {
@@ -782,65 +832,4 @@ export async function loadCachedPengepulModels(
782
832
  }
783
833
  }
784
834
 
785
- async function writeCache(cachePath: string, models: readonly PengepulModel[]): Promise<void> {
786
- await mkdir(dirname(cachePath), { recursive: true })
787
- // Unique per write: the runtime can issue two overlapping writes in one
788
- // process (cache-first + background refresh), and a shared pid-keyed name
789
- // would let the first rename remove the second's source mid-flight.
790
- const temporaryPath = `${cachePath}.${process.pid}.${randomUUID()}.tmp`
791
-
792
- try {
793
- await writeFile(
794
- temporaryPath,
795
- `${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
796
- { encoding: "utf-8", mode: 0o600 },
797
- )
798
- await rename(temporaryPath, cachePath)
799
- } finally {
800
- try {
801
- await rm(temporaryPath, { force: true })
802
- } catch {
803
- // Best-effort cleanup must not hide the original cache write error.
804
- }
805
- }
806
- }
807
-
808
- export async function loadPengepulModels(
809
- options: LoadModelsOptions,
810
- ): Promise<PengepulModelSource> {
811
- const cachePath = options.cachePath
812
-
813
- try {
814
- const models = await fetchPengepulModels(options)
815
-
816
- try {
817
- await writeCache(cachePath, models)
818
- return { models, source: "live" }
819
- } catch (error) {
820
- return {
821
- models,
822
- source: "live",
823
- warning: `Loaded the live pengepul model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
824
- }
825
- }
826
- } catch (liveError) {
827
- if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError)
828
-
829
- try {
830
- const models = await readCache(cachePath)
831
- return {
832
- models,
833
- source: "cache",
834
- warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
835
- }
836
- } catch (cacheError) {
837
- return {
838
- models: [],
839
- source: "empty",
840
- warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). pengepul models will remain unavailable until the next startup refresh succeeds.`,
841
- }
842
- }
843
- }
844
- }
845
-
846
835
 
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The pengepul provider, as pi-ai sees it.
3
+ *
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.
10
+ *
11
+ * The relay base lives on the credential as `baseUrl`, a field pi core does not
12
+ * read. Per-model base URLs are derived from it, because pi applies a base URL
13
+ * returned from `resolve()` to every model at once and pengepul's two wires need
14
+ * different paths (`/` for Messages, `/v1` for Chat Completions).
15
+ */
16
+
17
+ import type {
18
+ Api,
19
+ ApiKeyCredential,
20
+ AuthCheck,
21
+ AuthResult,
22
+ Model,
23
+ Provider,
24
+ ProviderAuthInteraction,
25
+ ProviderStreams,
26
+ RefreshModelsContext,
27
+ } from "@earendil-works/pi-ai"
28
+ import { anthropicMessagesApi, lazyStream, openAICompletionsApi } from "@earendil-works/pi-ai/compat"
29
+
30
+ import { API_KEY_ENV, extractApiKeys } from "./api-key.ts"
31
+ import { RELAY_BASE_ENV } from "./config.ts"
32
+ import {
33
+ credentialApiKey,
34
+ credentialRelayBase,
35
+ relayBaseFromConfigText,
36
+ resolveApiKey,
37
+ resolveRelayBase,
38
+ type PengepulCredential,
39
+ } from "./credential.ts"
40
+ import { modelsUrl, type PengepulDialect } from "./dialect.ts"
41
+ import {
42
+ DEFAULT_RELAY_BASE,
43
+ fetchPengepulModels,
44
+ getModelsTimeoutMs,
45
+ isPengepulModelEntry,
46
+ loadCachedPengepulModels,
47
+ PENGEPUL_PROVIDER_ID,
48
+ restampRelayBase,
49
+ toPengepulModels,
50
+ type BuiltinModelLookup,
51
+ type PengepulModelEntry,
52
+ } from "./models.ts"
53
+
54
+ /** The credential this provider writes: the relay key plus the base it applies to. */
55
+ export interface PengepulApiKeyCredential extends ApiKeyCredential {
56
+ type: "api_key"
57
+ key?: string
58
+ baseUrl?: string
59
+ }
60
+
61
+ /** Where the config-file fallback key comes from, for the status label. */
62
+ const CONFIG_FILE_LABEL = "~/.pengepul/config.yaml"
63
+
64
+ export interface PengepulProviderOptions {
65
+ /** Environment map holding the optional `PENGEPUL_*` overrides. */
66
+ 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
+ /** Catalog fetch transport, for tests. */
72
+ fetchImpl?: typeof fetch
73
+ /** Builtin metadata lookup, injected so the catalog logic stays free of pi imports. */
74
+ lookupBuiltin?: BuiltinModelLookup
75
+ /** Warning sink; defaults to `console.warn`. */
76
+ logWarning?: (message: string) => void
77
+ /** Clock, for tests. */
78
+ now?: () => number
79
+ }
80
+
81
+ function errorMessage(error: unknown): string {
82
+ return error instanceof Error ? error.message : String(error)
83
+ }
84
+
85
+ /** The `Api` union is open-ended, so narrow it before indexing by wire. */
86
+ function isPengepulDialect(api: Api): api is PengepulDialect {
87
+ return api === "anthropic-messages" || api === "openai-completions"
88
+ }
89
+
90
+ /**
91
+ * A provider whose catalog comes from pengepul and whose auth is the relay key.
92
+ *
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.
96
+ */
97
+ export function createPengepulProvider(options: PengepulProviderOptions): Provider {
98
+ const env = options.env ?? process.env
99
+ const logWarning =
100
+ options.logWarning ?? ((message: string) => console.warn(`[pengepul] ${message}`))
101
+ const now = options.now ?? Date.now
102
+ const fetchImpl = options.fetchImpl ?? fetch
103
+ const timeoutMs = getModelsTimeoutMs(env as NodeJS.ProcessEnv)
104
+
105
+ const environmentRelayBase = env[RELAY_BASE_ENV]
106
+ 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
+
111
+ let models: PengepulModelEntry[] = []
112
+
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 {}
118
+ }
119
+
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()
124
+ }
125
+
126
+ const streams: Record<PengepulDialect, ProviderStreams> = {
127
+ "anthropic-messages": anthropicMessagesApi(),
128
+ "openai-completions": openAICompletionsApi(),
129
+ }
130
+
131
+ function streamsFor(model: Model<Api>): ProviderStreams | undefined {
132
+ return isPengepulDialect(model.api) ? streams[model.api] : undefined
133
+ }
134
+
135
+ function unsupported(model: Model<Api>) {
136
+ return lazyStream(model, async () => {
137
+ throw new Error(`pengepul serves no "${model.api}" wire, so ${model.id} cannot be streamed`)
138
+ })
139
+ }
140
+
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
+
148
+ 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 } })
160
+ }
161
+
162
+ 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
+ })
168
+
169
+ const resolved = resolveKey(context.credential)
170
+ if (!resolved.key) {
171
+ 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.`,
173
+ )
174
+ return
175
+ }
176
+
177
+ try {
178
+ const catalog = await fetchPengepulModels({
179
+ url: modelsUrl(relayBase),
180
+ apiKey: resolved.key,
181
+ fetchImpl,
182
+ timeoutMs,
183
+ signal: context.signal,
184
+ ...(options.lookupBuiltin ? { lookupBuiltin: options.lookupBuiltin } : {}),
185
+ })
186
+ if (context.signal.aborted) return
187
+
188
+ const entries = toPengepulModels(catalog, relayBase)
189
+ await context.publish({
190
+ persist: { models: entries, checkedAt: now() },
191
+ update: () => { models = entries },
192
+ })
193
+ } catch (error) {
194
+ if (context.signal.aborted) return
195
+ logWarning(
196
+ `Could not refresh the pengepul model catalog (${errorMessage(error)}). Keeping the last known catalog.`,
197
+ )
198
+ }
199
+ }
200
+
201
+ return {
202
+ id: PENGEPUL_PROVIDER_ID,
203
+ name: "Pengepul",
204
+ baseUrl: resolveRelayBase({
205
+ environment: environmentRelayBase,
206
+ config: configRelayBase,
207
+ }),
208
+ auth: {
209
+ apiKey: {
210
+ name: "Pengepul relay",
211
+ login: async (
212
+ interaction: ProviderAuthInteraction,
213
+ ): Promise<PengepulApiKeyCredential> => {
214
+ const key = (await interaction.prompt({
215
+ type: "secret",
216
+ message: "Pengepul API key",
217
+ })).trim()
218
+ const answer = (await interaction.prompt({
219
+ type: "text",
220
+ message: "Pengepul relay URL",
221
+ placeholder: DEFAULT_RELAY_BASE,
222
+ })).trim()
223
+ // Written, not defaulted at read time: pi replaces the whole
224
+ // credential on login, so the URL has to be in the stored entry or a
225
+ // key rotation silently points the relay back at loopback.
226
+ return {
227
+ type: "api_key",
228
+ ...(key === "" ? {} : { key }),
229
+ baseUrl: resolveRelayBase({ credential: answer }),
230
+ }
231
+ },
232
+ check: async (input): Promise<AuthCheck | undefined> => {
233
+ const resolved = resolveKey(input.credential)
234
+ return resolved.key
235
+ ? { type: "api_key", ...(resolved.source ? { source: resolved.source } : {}) }
236
+ : undefined
237
+ },
238
+ resolve: async (input): Promise<AuthResult | undefined> => {
239
+ const resolved = resolveKey(input.credential)
240
+ if (!resolved.key) return undefined
241
+ // No `baseUrl` here on purpose: pi would apply it to every model and
242
+ // collapse the two dialect base URLs into one.
243
+ return {
244
+ auth: { apiKey: resolved.key },
245
+ ...(resolved.source ? { source: resolved.source } : {}),
246
+ }
247
+ },
248
+ },
249
+ },
250
+ getModels: () => models,
251
+ refreshModels: async (context) => {
252
+ if (!(await restoreOrSeed(context))) return
253
+ if (!context.allowNetwork || context.signal.aborted) return
254
+ await refreshFromRelay(context)
255
+ },
256
+ stream: (model, context, streamOptions) => {
257
+ const implementation = streamsFor(model)
258
+ return implementation
259
+ ? implementation.stream(model, context, streamOptions)
260
+ : unsupported(model)
261
+ },
262
+ streamSimple: (model, context, streamOptions) => {
263
+ const implementation = streamsFor(model)
264
+ return implementation
265
+ ? implementation.streamSimple(model, context, streamOptions)
266
+ : unsupported(model)
267
+ },
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@pwguler/pi-pengepul-provider",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "pi custom provider for pengepul, a local relay that pools your Claude/Codex subscriptions. Connects pi to http://127.0.0.1:8317 over the native Anthropic Messages and OpenAI Chat Completions wires.",
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",
7
7
  "author": "pwguler",
8
8
  "repository": {
@@ -1,211 +0,0 @@
1
- /**
2
- * Cached-model runtime for the pengepul provider.
3
- *
4
- * Registers the provider immediately from the cache (so startup never waits on
5
- * the network), refreshes it in the background, and disposes cleanly. There
6
- * are no user-facing commands: the startup refresh is the only refresh.
7
- *
8
- * The pi seam is an interface, so the whole thing is testable with a fake host.
9
- */
10
-
11
- import type { PengepulModel, PengepulModelSource } from "./models.ts"
12
-
13
- export interface PengepulRuntimeApi {
14
- registerProvider(name: string, config: unknown): void
15
- }
16
-
17
- export interface PengepulRuntimeOptions {
18
- /** A provider config built from a model list. */
19
- createProviderConfig: (models: readonly PengepulModel[]) => unknown
20
- /** Live fetch; resolves to the catalog plus a source marker. */
21
- loadModels: (signal: AbortSignal) => Promise<PengepulModelSource>
22
- /** Cached catalog only; empty when no valid cache exists. */
23
- loadCachedModels: () => Promise<readonly PengepulModel[]>
24
- now?: () => number
25
- logWarning?: (message: string) => void
26
- }
27
-
28
- export interface PengepulRefreshResult {
29
- refreshed: boolean
30
- source: PengepulModelSource["source"]
31
- modelCount: number
32
- warning?: string
33
- }
34
-
35
- interface RuntimeStatus {
36
- source: PengepulModelSource["source"]
37
- modelCount: number
38
- providerRegistered: boolean
39
- lastSuccess?: number
40
- lastAttempt?: number
41
- warning?: string
42
- refreshing: boolean
43
- }
44
-
45
- const REDACTED = "[redacted]"
46
-
47
- function errorMessage(error: unknown): string {
48
- return error instanceof Error ? error.message : String(error)
49
- }
50
-
51
- function redactDiagnosticText(value: string): string {
52
- return value
53
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`)
54
- .replace(/\b(?:sk-local|sk-|api[-_ ]?key|token|secret|password)[_-]?[\w.~+/=-]{8,}\b/gi, REDACTED)
55
- .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => {
56
- const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "="
57
- return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}`
58
- })
59
- }
60
-
61
- export class PengepulRuntime {
62
- private readonly now: () => number
63
- private readonly logWarning: (message: string) => void
64
- private status: RuntimeStatus
65
- private providerRegistered = false
66
- private refreshPromise: Promise<PengepulRefreshResult> | undefined
67
- private readonly shutdown = new AbortController()
68
-
69
- constructor(
70
- private readonly pi: PengepulRuntimeApi,
71
- private readonly options: PengepulRuntimeOptions,
72
- ) {
73
- this.now = options.now ?? Date.now
74
- this.logWarning = options.logWarning ?? ((message) => console.warn(`[pengepul] ${message}`))
75
- this.status = {
76
- source: "empty",
77
- modelCount: 0,
78
- providerRegistered: false,
79
- refreshing: false,
80
- }
81
- }
82
-
83
- /**
84
- * Registers the cached catalog immediately (so host startup does not wait on
85
- * the network) and refreshes it in the background. Without a valid cache the
86
- * live refresh is awaited so models are available right away.
87
- */
88
- async initialize(): Promise<void> {
89
- const cached = await this.options.loadCachedModels()
90
- if (cached.length === 0) {
91
- await this.refresh()
92
- return
93
- }
94
-
95
- this.pi.registerProvider("pengepul", this.options.createProviderConfig(cached))
96
- this.providerRegistered = true
97
- this.status = {
98
- ...this.status,
99
- source: "cache",
100
- modelCount: cached.length,
101
- providerRegistered: true,
102
- lastSuccess: this.now(),
103
- }
104
- void this.refresh()
105
- }
106
-
107
- /** Aborts any background refresh so a stopping host does not wait on the network. */
108
- dispose(): void {
109
- this.shutdown.abort(new Error("pengepul provider shut down"))
110
- }
111
-
112
- refresh(): Promise<PengepulRefreshResult> {
113
- if (this.refreshPromise) return this.refreshPromise
114
-
115
- const refreshPromise = this.refreshCatalog().finally(() => {
116
- if (this.refreshPromise === refreshPromise) this.refreshPromise = undefined
117
- })
118
- this.refreshPromise = refreshPromise
119
- return refreshPromise
120
- }
121
-
122
- private async refreshCatalog(): Promise<PengepulRefreshResult> {
123
- this.status = { ...this.status, lastAttempt: this.now(), refreshing: true }
124
-
125
- try {
126
- const loaded = await this.options.loadModels(this.shutdown.signal)
127
- const warning = loaded.warning ? redactDiagnosticText(loaded.warning) : undefined
128
-
129
- const shouldRegister =
130
- !this.providerRegistered ||
131
- loaded.source === "live" ||
132
- (this.status.modelCount === 0 && loaded.models.length > 0)
133
-
134
- if (shouldRegister) {
135
- this.pi.registerProvider("pengepul", this.options.createProviderConfig(loaded.models))
136
- this.providerRegistered = true
137
-
138
- if (loaded.models.length === 0) {
139
- const preservedWarning = warning ?? "pengepul model discovery returned no models"
140
- this.status = {
141
- ...this.status,
142
- source: loaded.source,
143
- modelCount: 0,
144
- providerRegistered: true,
145
- warning: preservedWarning,
146
- refreshing: false,
147
- }
148
- this.warn(preservedWarning)
149
- return { refreshed: false, source: loaded.source, modelCount: 0, warning: preservedWarning }
150
- }
151
-
152
- this.status = {
153
- ...this.status,
154
- source: loaded.source,
155
- modelCount: loaded.models.length,
156
- providerRegistered: true,
157
- lastSuccess: this.now(),
158
- warning,
159
- refreshing: false,
160
- }
161
- if (warning) this.warn(warning)
162
- return { refreshed: true, source: loaded.source, modelCount: loaded.models.length, warning }
163
- }
164
-
165
- const preservedWarning = warning ?? "pengepul model discovery returned no models"
166
- this.status = { ...this.status, warning: preservedWarning, refreshing: false }
167
- this.warn(preservedWarning)
168
- return {
169
- refreshed: false,
170
- source: this.status.source,
171
- modelCount: this.status.modelCount,
172
- warning: preservedWarning,
173
- }
174
- } catch (error) {
175
- if (this.shutdown.signal.aborted) {
176
- this.status = { ...this.status, refreshing: false }
177
- return {
178
- refreshed: false,
179
- source: this.status.source,
180
- modelCount: this.status.modelCount,
181
- }
182
- }
183
- const warning = redactDiagnosticText(
184
- `Could not refresh the pengepul model catalog: ${errorMessage(error)}`,
185
- )
186
- this.status = { ...this.status, warning, refreshing: false }
187
- this.warn(warning)
188
- return {
189
- refreshed: false,
190
- source: this.status.source,
191
- modelCount: this.status.modelCount,
192
- warning,
193
- }
194
- }
195
- }
196
-
197
- private warn(message: string): void {
198
- try {
199
- this.logWarning(redactDiagnosticText(message))
200
- } catch {
201
- // Diagnostics must never make a catalog refresh fail.
202
- }
203
- }
204
- }
205
-
206
- export function createPengepulRuntime(
207
- pi: PengepulRuntimeApi,
208
- options: PengepulRuntimeOptions,
209
- ): PengepulRuntime {
210
- return new PengepulRuntime(pi, options)
211
- }