@pwguler/pi-pengepul-provider 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -8
- package/extensions/config.ts +10 -27
- package/extensions/credential.ts +33 -57
- package/extensions/index.ts +6 -28
- package/extensions/models.ts +66 -122
- package/extensions/provider.ts +39 -69
- package/package.json +1 -1
- package/extensions/api-key.ts +0 -70
package/README.md
CHANGED
|
@@ -58,6 +58,10 @@ prefixed `pengepul/<id>`. To try it without installing, use
|
|
|
58
58
|
relay already serves — takes them from the same family's previous minor, so
|
|
59
59
|
it does not sit at `high` until the next pi catalog update. Nothing to
|
|
60
60
|
configure: `thinkingLevelMap` is resolved per id here.
|
|
61
|
+
- Publishes Anthropic's cache lifetimes (5 minutes, 1 hour) on the Messages
|
|
62
|
+
wire, so pi's cache warmer can keep a Claude conversation's entry alive
|
|
63
|
+
instead of re-billing its whole prefix after five idle minutes. See
|
|
64
|
+
[Prompt cache](#prompt-cache).
|
|
61
65
|
- Caches the catalog in pi's model store, so startup does not wait on the
|
|
62
66
|
network and a briefly absent relay is covered. The cached models are
|
|
63
67
|
re-pointed at the relay base configured now, so moving the relay does not
|
|
@@ -89,25 +93,86 @@ To reach a relay on another machine, put that machine's address in `baseUrl`
|
|
|
89
93
|
`~/.pengepul/config.yaml` on the relay, or forward the port over SSH). The key
|
|
90
94
|
is the relay's own key — `pengepul config api-key` prints it.
|
|
91
95
|
|
|
92
|
-
Optional overrides, for CI or a one-off shell.
|
|
93
|
-
credential
|
|
96
|
+
Optional overrides, for CI or a one-off shell. The first two outrank the
|
|
97
|
+
credential: set either and it wins, whatever `auth.json` holds.
|
|
94
98
|
|
|
95
99
|
| Setting | Env var | Default |
|
|
96
100
|
|---|---|---|
|
|
97
|
-
| Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the
|
|
98
|
-
| API key | `PENGEPUL_API_KEY` |
|
|
99
|
-
| Config path | `PENGEPUL_CONFIG` | `~/.pengepul/config.yaml` |
|
|
100
|
-
| Legacy cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
|
|
101
|
+
| Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317`, or the credential's `baseUrl` |
|
|
102
|
+
| API key | `PENGEPUL_API_KEY` | the credential's `key` |
|
|
101
103
|
| Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
```sh
|
|
106
|
+
PENGEPUL_BASE_URL=http://10.0.0.9:8317 PENGEPUL_API_KEY=sk-local-... pi
|
|
107
|
+
```
|
|
105
108
|
|
|
106
109
|
Do not set `providers.pengepul.baseUrl` in `models.json`. pi applies that value
|
|
107
110
|
to every model of the provider, which collapses the two wires onto one URL —
|
|
108
111
|
Anthropic Messages traffic would be sent to `/v1` and Chat Completions traffic
|
|
109
112
|
to `/`.
|
|
110
113
|
|
|
114
|
+
## Prompt cache
|
|
115
|
+
|
|
116
|
+
The upstream prompt cache is one entry per account and lives five minutes by
|
|
117
|
+
default, so a conversation that waits longer than that pays for its whole prefix
|
|
118
|
+
again — on a 700k-token Opus session, dollars per return, logged by the relay as
|
|
119
|
+
a `cache_write` with a near-zero `cache_read`.
|
|
120
|
+
|
|
121
|
+
This provider declares Anthropic's lifetimes (5 minutes, 1 hour) on every
|
|
122
|
+
Messages-wire model, which is what lets pi keep an entry alive at all: pi
|
|
123
|
+
refreshes an entry by re-sending the prompt with a one-token cap before it
|
|
124
|
+
expires, and it only warms a model whose lifetime it can resolve. Two pi
|
|
125
|
+
settings decide when that happens, and neither covers an idle gap out of the
|
|
126
|
+
box:
|
|
127
|
+
|
|
128
|
+
| Setting | Where | Effect |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `cacheWarming: "idle"` | `~/.pi/agent/settings.json` | Warms between agent runs. The default, `"streaming"`, stops as soon as a run settles — exactly when a five-minute entry is about to expire. |
|
|
131
|
+
| `env: { "PI_CACHE_RETENTION": "long" }` | the `pengepul` credential in `auth.json` | Asks for the 1-hour tier instead of the 5-minute one. The relay forwards the extended TTL to Anthropic. |
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"pengepul": {
|
|
136
|
+
"type": "api_key",
|
|
137
|
+
"key": "sk-local-...",
|
|
138
|
+
"baseUrl": "http://127.0.0.1:8317",
|
|
139
|
+
"env": { "PI_CACHE_RETENTION": "long" }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Idle warming covers gaps up to 30 minutes, pi's own safety limit, and pi only
|
|
145
|
+
triggers it when the expected saving clears a threshold of its own — a small
|
|
146
|
+
prompt is left to expire rather than refreshed. The long tier is what carries a
|
|
147
|
+
longer break. Both are cheap next to a miss: a refresh is one cache read plus a
|
|
148
|
+
one-token completion, while the long tier only raises the write premium, on the
|
|
149
|
+
few hundred tokens a turn actually adds.
|
|
150
|
+
|
|
151
|
+
`/session` reports the mode, the refresh cost, and the miss penalty it is
|
|
152
|
+
weighing, and `showCacheMissNotices` in `settings.json` prints each miss with
|
|
153
|
+
its re-billed token count.
|
|
154
|
+
|
|
155
|
+
## Upgrading from 0.4.0 or earlier
|
|
156
|
+
|
|
157
|
+
This release is breaking: the relay's own `~/.pengepul/config.yaml` is no longer
|
|
158
|
+
a source. That file belongs to the machine running the relay, and reading it let
|
|
159
|
+
a key on that box configure every client sharing it.
|
|
160
|
+
|
|
161
|
+
- A key that came from `api-keys:` in it is no longer picked up. Run
|
|
162
|
+
`/login pengepul`, or put it in `auth.json` as `"pengepul".key`, or export
|
|
163
|
+
`PENGEPUL_API_KEY`. `pengepul config api-key` prints it.
|
|
164
|
+
- The relay base built from its `host` and `port` is no longer picked up
|
|
165
|
+
either. Set `baseUrl` in `auth.json`, or `PENGEPUL_BASE_URL`, if the relay is
|
|
166
|
+
not on `http://127.0.0.1:8317`.
|
|
167
|
+
- A pre-0.3 `<agent-dir>/pengepul-models.json` is no longer imported. The
|
|
168
|
+
catalog now comes from the relay on the next start, so the relay has to be
|
|
169
|
+
reachable once; pi's model store covers the rest.
|
|
170
|
+
|
|
171
|
+
Resolution order changed as well, and that is the part to check after an
|
|
172
|
+
upgrade: `PENGEPUL_BASE_URL` and `PENGEPUL_API_KEY` now win over `auth.json`,
|
|
173
|
+
where they previously lost to it. An exported key from an old shell now
|
|
174
|
+
overrides the one in the credential.
|
|
175
|
+
|
|
111
176
|
## Notes
|
|
112
177
|
|
|
113
178
|
- pengepul >= 0.6.0 advertises per-model context windows, output caps,
|
package/extensions/config.ts
CHANGED
|
@@ -1,34 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The environment overrides this provider reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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"
|
package/extensions/credential.ts
CHANGED
|
@@ -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
|
|
7
|
+
* this module is pure: an env map and credential fields in, a value out.
|
|
8
8
|
*
|
|
9
|
-
* Precedence
|
|
10
|
-
*
|
|
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
|
|
86
|
-
|
|
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:
|
|
91
|
-
* loopback. A trailing `/v1` is accepted and stripped, so 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.
|
|
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
|
-
/**
|
|
111
|
-
export
|
|
112
|
-
|
|
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
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
package/extensions/models.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the next successful
|
|
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
|
|
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
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
492
|
+
* Drop the ids this relay cannot serve on either wire, on the way in from the
|
|
493
|
+
* relay. The store replays what pi persisted, so a route this filter drops
|
|
494
|
+
* survives in a store written before the filter existed only until the next
|
|
495
|
+
* successful fetch replaces it.
|
|
498
496
|
*/
|
|
499
497
|
function servableModels(models: readonly PengepulModel[]): PengepulModel[] {
|
|
500
498
|
return models.filter((model) => !isBatchRoute(model.id))
|
|
@@ -524,8 +522,22 @@ export function modelsFromApiResponse(
|
|
|
524
522
|
|
|
525
523
|
import type { Api, Model } from "@earendil-works/pi-ai"
|
|
526
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Prompt cache lifetime in seconds for each retention tier a request can ask
|
|
527
|
+
* for, the shape pi reads off a model to decide when to refresh its cache
|
|
528
|
+
* entry. Declared here because pi-ai 0.85.1's `Model` has no `promptCache`;
|
|
529
|
+
* a host that predates the field ignores it.
|
|
530
|
+
*/
|
|
531
|
+
export interface PromptCacheTiers {
|
|
532
|
+
short: number
|
|
533
|
+
long: number
|
|
534
|
+
}
|
|
535
|
+
|
|
527
536
|
/** The pi-ai model shapes this provider serves: one per dialect the relay speaks. */
|
|
528
|
-
export type PengepulModelEntry =
|
|
537
|
+
export type PengepulModelEntry = (
|
|
538
|
+
| Model<"anthropic-messages">
|
|
539
|
+
| Model<"openai-completions">
|
|
540
|
+
) & { promptCache?: PromptCacheTiers }
|
|
529
541
|
|
|
530
542
|
/** The provider id every pengepul model is stamped with. */
|
|
531
543
|
export const PENGEPUL_PROVIDER_ID = "pengepul"
|
|
@@ -564,18 +576,17 @@ function sharedModelFields(model: PengepulModel, relayBase: string) {
|
|
|
564
576
|
* openai-completions carries `prompt_cache_key: <sessionId>` (and
|
|
565
577
|
* `prompt_cache_retention: "24h"`) under PI_CACHE_RETENTION=long, so the body
|
|
566
578
|
* field alone would name the conversation; anthropic-messages carries no
|
|
567
|
-
* `prompt_cache_key` at all, and pi-ai hardcodes `x-session-affinity` there
|
|
568
|
-
* which this relay does not read. Messages traffic therefore rests
|
|
569
|
-
* the relay's prefix fallback until
|
|
570
|
-
*
|
|
579
|
+
* `prompt_cache_key` at all, and pi-ai hardcodes `x-session-affinity` there up
|
|
580
|
+
* to 0.85.1, which this relay does not read. Messages traffic therefore rests
|
|
581
|
+
* on the relay's prefix fallback until 0.87, where `sessionAffinityFormat`
|
|
582
|
+
* starts being read on that dialect too and the same `openrouter` value lands
|
|
583
|
+
* as `x-session-id`.
|
|
571
584
|
*
|
|
572
|
-
* The
|
|
573
|
-
* `
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
* zero. Pin now rather than later — the cost of forgetting is silently
|
|
578
|
-
* re-billed Claude prefixes.
|
|
585
|
+
* The field is re-declared below rather than taken from
|
|
586
|
+
* `AnthropicMessagesCompat` so the pin does not depend on the host's pi-ai
|
|
587
|
+
* version: 0.85.1 ignores a value it never types, and the cost of an ignored
|
|
588
|
+
* header is zero. Pin now rather than later — the cost of forgetting is
|
|
589
|
+
* silently re-billed Claude prefixes.
|
|
579
590
|
*/
|
|
580
591
|
function affinityPin() {
|
|
581
592
|
return {
|
|
@@ -584,6 +595,32 @@ function affinityPin() {
|
|
|
584
595
|
}
|
|
585
596
|
}
|
|
586
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Anthropic's prompt cache lifetimes: five minutes by default, one hour when
|
|
600
|
+
* the request asks for the extended TTL. Copied per model rather than shared,
|
|
601
|
+
* so nothing downstream can mutate one model's lifetimes into another's.
|
|
602
|
+
*
|
|
603
|
+
* pi reads `model.promptCache[tier]` to know when the entry a request wrote
|
|
604
|
+
* expires, and skips warming a model whose lifetime it cannot resolve
|
|
605
|
+
* (`cache-warmer.js` `getPromptCacheTtlMs`). No pi catalog answers for a relay
|
|
606
|
+
* id, so a Claude model served here would otherwise never be warmed: any idle
|
|
607
|
+
* past the TTL — five minutes on the default tier — re-bills the whole prefix
|
|
608
|
+
* at the write rate, which for a 700k-token Opus conversation is dollars per
|
|
609
|
+
* miss.
|
|
610
|
+
*
|
|
611
|
+
* Only the Messages wire gets them. Those ids are the ones the relay forwards
|
|
612
|
+
* to Anthropic's own API, whose lifetimes these are; the Chat Completions wire
|
|
613
|
+
* serves aggregated vendors (commandcode, openrouter) through upstreams whose
|
|
614
|
+
* cache lifetimes this provider has no measurement for, and pi does not warm a
|
|
615
|
+
* lifetime it cannot resolve.
|
|
616
|
+
*
|
|
617
|
+
* Measured, not assumed: a `cache_control` ttl of `1h` on this wire comes back
|
|
618
|
+
* with the whole prefix in `usage.cache_creation.ephemeral_1h_input_tokens`,
|
|
619
|
+
* so the relay passes the extended TTL through and `PI_CACHE_RETENTION=long`
|
|
620
|
+
* buys the hour declared here.
|
|
621
|
+
*/
|
|
622
|
+
const ANTHROPIC_PROMPT_CACHE: PromptCacheTiers = { short: 300, long: 3600 }
|
|
623
|
+
|
|
587
624
|
/**
|
|
588
625
|
* Anthropic compat as pi-ai 0.85.1 types it, plus the affinity format a later
|
|
589
626
|
* pi reads. Declared here so the pin does not depend on the host's pi-ai
|
|
@@ -635,6 +672,10 @@ export function toPengepulModels(
|
|
|
635
672
|
// The 1h cache TTL is a Messages-dialect feature too: `cache_control.ttl`
|
|
636
673
|
// has nowhere to go on the Chat Completions wire. Reasoning is not part
|
|
637
674
|
// of it — a non-reasoning Claude model caches the same way.
|
|
675
|
+
//
|
|
676
|
+
// The lifetimes pi's cache warmer schedules its refreshes against, on
|
|
677
|
+
// this wire only, for the reason just given.
|
|
678
|
+
promptCache: { ...ANTHROPIC_PROMPT_CACHE },
|
|
638
679
|
compat: {
|
|
639
680
|
...affinityPin(),
|
|
640
681
|
...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
|
|
@@ -692,11 +733,6 @@ interface FetchModelsOptions {
|
|
|
692
733
|
lookupBuiltin?: BuiltinModelLookup
|
|
693
734
|
}
|
|
694
735
|
|
|
695
|
-
interface LoadModelsOptions extends FetchModelsOptions {
|
|
696
|
-
cachePath: string
|
|
697
|
-
relayBase: string
|
|
698
|
-
}
|
|
699
|
-
|
|
700
736
|
function errorMessage(error: unknown): string {
|
|
701
737
|
return error instanceof Error ? error.message : String(error)
|
|
702
738
|
}
|
|
@@ -816,95 +852,3 @@ export async function fetchPengepulModels(
|
|
|
816
852
|
|
|
817
853
|
return modelsFromApiResponse(body, options.lookupBuiltin)
|
|
818
854
|
}
|
|
819
|
-
|
|
820
|
-
function numberField(record: Record<string, unknown>, key: string): number {
|
|
821
|
-
const value = record[key]
|
|
822
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
823
|
-
throw new Error(`Expected ${key} to be a finite number`)
|
|
824
|
-
}
|
|
825
|
-
return value
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function inputField(record: Record<string, unknown>, key: string): ModelInput {
|
|
829
|
-
const value = record[key]
|
|
830
|
-
if (!Array.isArray(value)) throw new Error(`Expected ${key} to be an array`)
|
|
831
|
-
return value.map((entry) => {
|
|
832
|
-
if (entry !== "text" && entry !== "image") {
|
|
833
|
-
throw new Error(`Expected ${key} entries to be "text" or "image"`)
|
|
834
|
-
}
|
|
835
|
-
return entry
|
|
836
|
-
})
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function costField(record: Record<string, unknown>, key: string): ModelCostRates {
|
|
840
|
-
const value = record[key]
|
|
841
|
-
if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
|
|
842
|
-
return {
|
|
843
|
-
input: numberField(value, "input"),
|
|
844
|
-
output: numberField(value, "output"),
|
|
845
|
-
cacheRead: numberField(value, "cacheRead"),
|
|
846
|
-
cacheWrite: numberField(value, "cacheWrite"),
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
function levelMapField(
|
|
851
|
-
record: Record<string, unknown>,
|
|
852
|
-
key: string,
|
|
853
|
-
): Record<string, string | null> {
|
|
854
|
-
const value = record[key]
|
|
855
|
-
if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
|
|
856
|
-
const map: Record<string, string | null> = {}
|
|
857
|
-
for (const [level, mapped] of Object.entries(value)) {
|
|
858
|
-
if (mapped !== null && typeof mapped !== "string") {
|
|
859
|
-
throw new Error(`Expected ${key} values to be strings or null`)
|
|
860
|
-
}
|
|
861
|
-
map[level] = mapped
|
|
862
|
-
}
|
|
863
|
-
return map
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
export function modelsFromCache(value: unknown): readonly PengepulModel[] {
|
|
867
|
-
if (!isRecord(value)) throw new Error("Expected model cache to be an object")
|
|
868
|
-
if (value["version"] !== MODEL_CACHE_VERSION) {
|
|
869
|
-
throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
|
|
870
|
-
}
|
|
871
|
-
if (!Array.isArray(value["models"])) throw new Error("Expected cached models to be an array")
|
|
872
|
-
|
|
873
|
-
const parsed: PengepulModel[] = value["models"].map((entry) => {
|
|
874
|
-
if (!isRecord(entry)) throw new Error("Expected cached model entry to be an object")
|
|
875
|
-
return {
|
|
876
|
-
id: stringField(entry, "id"),
|
|
877
|
-
name: stringField(entry, "name"),
|
|
878
|
-
dialect: stringField(entry, "dialect") as PengepulDialect,
|
|
879
|
-
reasoning: entry["reasoning"] === true,
|
|
880
|
-
input: inputField(entry, "input"),
|
|
881
|
-
cost: costField(entry, "cost"),
|
|
882
|
-
contextWindow: numberField(entry, "contextWindow"),
|
|
883
|
-
maxTokens: numberField(entry, "maxTokens"),
|
|
884
|
-
...(entry["thinkingLevelMap"] !== undefined
|
|
885
|
-
? { thinkingLevelMap: levelMapField(entry, "thinkingLevelMap") }
|
|
886
|
-
: {}),
|
|
887
|
-
}
|
|
888
|
-
})
|
|
889
|
-
const servable = servableModels(parsed)
|
|
890
|
-
if (servable.length === 0) throw new Error("pengepul cache holds no valid models")
|
|
891
|
-
return servable
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async function readCache(cachePath: string): Promise<readonly PengepulModel[]> {
|
|
895
|
-
const contents = await readFile(cachePath, "utf-8")
|
|
896
|
-
return modelsFromCache(JSON.parse(contents))
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/** Reads the cached catalog without touching the network; empty when missing/invalid. */
|
|
900
|
-
export async function loadCachedPengepulModels(
|
|
901
|
-
cachePath: string,
|
|
902
|
-
): Promise<readonly PengepulModel[]> {
|
|
903
|
-
try {
|
|
904
|
-
return await readCache(cachePath)
|
|
905
|
-
} catch {
|
|
906
|
-
return []
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
package/extensions/provider.ts
CHANGED
|
@@ -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`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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 =
|
|
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
|
|
148
|
+
if (!resolved) {
|
|
171
149
|
logWarning(
|
|
172
|
-
`No pengepul API key is configured (${API_KEY_ENV}
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi custom provider for pengepul, a relay that pools your Claude/Codex subscriptions. Key and relay base live in auth.json; both native wires (Anthropic Messages, OpenAI Chat Completions) come from one catalog.",
|
|
6
6
|
"license": "MIT",
|
package/extensions/api-key.ts
DELETED
|
@@ -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
|
-
}
|