@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 +45 -12
- package/extensions/api-key.ts +6 -37
- package/extensions/config.ts +15 -21
- package/extensions/credential.ts +113 -0
- package/extensions/index.ts +22 -54
- package/extensions/models.ts +153 -164
- package/extensions/provider.ts +269 -0
- package/package.json +2 -2
- package/extensions/runtime.ts +0 -211
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
|
|
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
|
|
57
|
-
|
|
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
|
|
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` |
|
|
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
|
-
|
|
|
95
|
+
| Legacy cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
|
|
69
96
|
| Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
|
|
70
97
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
package/extensions/api-key.ts
CHANGED
|
@@ -1,53 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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:
|
package/extensions/config.ts
CHANGED
|
@@ -1,40 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Where the pengepul provider's fallback sources live.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
/** Where the
|
|
20
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
env[MODELS_CACHE_ENV] ?? join(agentDir, "pengepul-models.json")
|
|
32
|
-
|
|
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
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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 }
|
package/extensions/models.ts
CHANGED
|
@@ -20,10 +20,9 @@
|
|
|
20
20
|
* The network/cache are injected so the catalog logic stays testable.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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[
|
|
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
|
-
}).
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "pi custom provider for pengepul, a
|
|
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": {
|
package/extensions/runtime.ts
DELETED
|
@@ -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
|
-
}
|