@pwguler/pi-pengepul-provider 0.2.0 → 0.2.2
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/extensions/models.ts +138 -22
- package/package.json +1 -1
package/extensions/models.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The relay advertises id/owned_by and, since pengepul 0.6.0, optional
|
|
11
11
|
* per-model metadata: `context_window`, `max_output_tokens`,
|
|
12
|
-
* `input_modalities`, and `
|
|
13
|
-
* this relay actually serves, so it wins. The rollout is
|
|
12
|
+
* `input_modalities`, `pricing`, and `reasoning`. That is the first-party
|
|
13
|
+
* truth for what this relay actually serves, so it wins. The rollout is
|
|
14
|
+
* partial (some ids
|
|
14
15
|
* still come back with ids only), so two fallbacks remain: pi's builtin
|
|
15
16
|
* catalog - pengepul forwards the same ids upstream, so pi's numbers are the
|
|
16
17
|
* next best source - and then family heuristics. The catalog lookup is
|
|
@@ -32,8 +33,8 @@ export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
|
|
|
32
33
|
const DEFAULT_CONTEXT_WINDOW = 200_000
|
|
33
34
|
const DEFAULT_MAX_TOKENS = 64_000
|
|
34
35
|
const ZERO_COST: ModelCostRates = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
35
|
-
/**
|
|
36
|
-
const MODEL_CACHE_VERSION =
|
|
36
|
+
/** v5 cached entries predate the relay-uniform off/minimal overlay (foreign catalog maps offered levels the relay 400s); reject it. */
|
|
37
|
+
const MODEL_CACHE_VERSION = 6
|
|
37
38
|
|
|
38
39
|
export type ModelInput = ("text" | "image")[]
|
|
39
40
|
|
|
@@ -74,7 +75,7 @@ export interface PengepulModel {
|
|
|
74
75
|
cost: ModelCostRates
|
|
75
76
|
contextWindow: number
|
|
76
77
|
maxTokens: number
|
|
77
|
-
/** Level-to-wire mapping inherited
|
|
78
|
+
/** Level-to-wire mapping (inherited, relay-unsafe levels nulled); undefined = pi's default. */
|
|
78
79
|
thinkingLevelMap?: Record<string, string | null>
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -91,20 +92,45 @@ export function bareId(id: string): string {
|
|
|
91
92
|
return slash === -1 ? id : id.slice(slash + 1)
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/** `openrouter/openai/gpt-5.4:batch` -> `gpt-5.4:batch`: the model name without every routing prefix. */
|
|
96
|
+
export function modelName(id: string): string {
|
|
97
|
+
const slash = id.lastIndexOf("/")
|
|
98
|
+
return slash === -1 ? id : id.slice(slash + 1)
|
|
99
|
+
}
|
|
100
|
+
|
|
94
101
|
/**
|
|
95
102
|
* Fallback for models pi's catalog does not know. Family-shaped but
|
|
96
|
-
* conservative; the builtin lookup wins whenever it has the id. The
|
|
97
|
-
*
|
|
98
|
-
*
|
|
103
|
+
* conservative; the builtin lookup wins whenever it has the id. The relay
|
|
104
|
+
* prefixes ids with routing namespaces (`openrouter/openai/gpt-5.4:batch`),
|
|
105
|
+
* so the family is read from the final segment. The final branch treats
|
|
106
|
+
* unknown ids as non-reasoning, so an unrecognized id never gets reasoning
|
|
107
|
+
* params the upstream may reject.
|
|
99
108
|
*/
|
|
100
109
|
function heuristicMeta(id: string): BuiltinModelMeta {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
110
|
+
const name = modelName(id).toLowerCase()
|
|
111
|
+
if (name.startsWith("claude-")) {
|
|
103
112
|
return { reasoning: true, contextWindow: 200_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
|
|
104
113
|
}
|
|
105
|
-
if (
|
|
114
|
+
if (
|
|
115
|
+
name.startsWith("codex-") ||
|
|
116
|
+
/^gpt-[5-9]/.test(name) ||
|
|
117
|
+
name.startsWith("gpt-oss") ||
|
|
118
|
+
/^o[1-9]/.test(name)
|
|
119
|
+
) {
|
|
106
120
|
return { reasoning: true, contextWindow: 272_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
|
|
107
121
|
}
|
|
122
|
+
// Families that name their reasoning: DeepSeek's R1 line and ids that spell
|
|
123
|
+
// it out (`sonar-reasoning-pro`). The relay's own numbers win when it sends
|
|
124
|
+
// them; these are placeholders for the ids it describes with nothing else.
|
|
125
|
+
if (name.startsWith("deepseek-r1") || name.includes("reasoning")) {
|
|
126
|
+
return {
|
|
127
|
+
reasoning: true,
|
|
128
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
129
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
130
|
+
input: ["text"],
|
|
131
|
+
cost: ZERO_COST,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
return {
|
|
109
135
|
reasoning: false,
|
|
110
136
|
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
@@ -117,6 +143,8 @@ function heuristicMeta(id: string): BuiltinModelMeta {
|
|
|
117
143
|
/**
|
|
118
144
|
* Metadata pengepul itself advertises for a model (pengepul >= 0.6.0).
|
|
119
145
|
* Every field is optional: the rollout is partial and older relays send none.
|
|
146
|
+
* `reasoning` is the relay's first-party say on whether the upstream accepts
|
|
147
|
+
* reasoning params; absent or non-boolean falls back to the catalog.
|
|
120
148
|
* Returns undefined when the entry carries no usable metadata at all.
|
|
121
149
|
*/
|
|
122
150
|
export function metaFromRelayEntry(
|
|
@@ -126,16 +154,19 @@ export function metaFromRelayEntry(
|
|
|
126
154
|
const maxTokens = optionalPositiveNumber(entry["max_output_tokens"])
|
|
127
155
|
const input = optionalInputModalities(entry["input_modalities"])
|
|
128
156
|
const cost = optionalPricing(entry["pricing"])
|
|
157
|
+
const reasoning = optionalBoolean(entry["reasoning"])
|
|
129
158
|
|
|
130
159
|
if (
|
|
131
160
|
contextWindow === undefined &&
|
|
132
161
|
maxTokens === undefined &&
|
|
133
162
|
input === undefined &&
|
|
134
|
-
cost === undefined
|
|
163
|
+
cost === undefined &&
|
|
164
|
+
reasoning === undefined
|
|
135
165
|
) {
|
|
136
166
|
return undefined
|
|
137
167
|
}
|
|
138
168
|
return {
|
|
169
|
+
...(reasoning !== undefined ? { reasoning } : {}),
|
|
139
170
|
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
140
171
|
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
141
172
|
...(input !== undefined ? { input } : {}),
|
|
@@ -147,6 +178,10 @@ function optionalPositiveNumber(value: unknown): number | undefined {
|
|
|
147
178
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
|
|
148
179
|
}
|
|
149
180
|
|
|
181
|
+
function optionalBoolean(value: unknown): boolean | undefined {
|
|
182
|
+
return typeof value === "boolean" ? value : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
150
185
|
function optionalInputModalities(value: unknown): ModelInput | undefined {
|
|
151
186
|
if (!Array.isArray(value)) return undefined
|
|
152
187
|
const input = value.filter(
|
|
@@ -206,6 +241,21 @@ function toPengepulModel(
|
|
|
206
241
|
// capable, even when pi's catalog does not know its exact id yet.
|
|
207
242
|
const reasoning = meta.reasoning || ownedBy === "anthropic"
|
|
208
243
|
|
|
244
|
+
// The relay enforces reasoning_effort low|medium|high|xhigh|max at its
|
|
245
|
+
// request layer, uniformly across families: `minimal` 400s and its thinking
|
|
246
|
+
// toggle never actually disables thinking. That holds no matter where the
|
|
247
|
+
// reasoning knowledge came from, so every openai-completions reasoning model
|
|
248
|
+
// gets the relay's shape: inherited strings case-folded to the enum (pi's
|
|
249
|
+
// catalogs spell Google efforts `HIGH` and Qwen's `default`), values that
|
|
250
|
+
// match under no casing hidden, and off/minimal always null. Overlay, never
|
|
251
|
+
// replace: inherited nulls keep their levels hidden. The Messages dialect
|
|
252
|
+
// needs none of this: it folds `minimal` into `low` and already nulls `off`
|
|
253
|
+
// via forceAdaptiveThinking.
|
|
254
|
+
const thinkingLevelMap = relaySafeLevelMap(
|
|
255
|
+
meta.thinkingLevelMap,
|
|
256
|
+
reasoning && dialect === "openai-completions",
|
|
257
|
+
)
|
|
258
|
+
|
|
209
259
|
return {
|
|
210
260
|
id,
|
|
211
261
|
name: displayName(id),
|
|
@@ -215,10 +265,41 @@ function toPengepulModel(
|
|
|
215
265
|
cost: { ...meta.cost },
|
|
216
266
|
contextWindow: meta.contextWindow,
|
|
217
267
|
maxTokens: meta.maxTokens,
|
|
218
|
-
...(
|
|
268
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
219
269
|
}
|
|
220
270
|
}
|
|
221
271
|
|
|
272
|
+
/** The efforts the relay accepts; anything else is rejected at its request layer. */
|
|
273
|
+
const RELAY_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"])
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Shape an inherited level map for the relay's wire. With `enforce` (an
|
|
277
|
+
* openai-completions reasoning model) the relay's enum is the only vocabulary
|
|
278
|
+
* that reaches it: inherited strings are case-folded to the enum or hidden,
|
|
279
|
+
* and off/minimal are always hidden. Without it the map passes through.
|
|
280
|
+
*/
|
|
281
|
+
function relaySafeLevelMap(
|
|
282
|
+
inherited: Record<string, string | null> | undefined,
|
|
283
|
+
enforce: boolean,
|
|
284
|
+
): Record<string, string | null> | undefined {
|
|
285
|
+
if (!enforce) return inherited ? { ...inherited } : undefined
|
|
286
|
+
|
|
287
|
+
const map: Record<string, string | null> = {}
|
|
288
|
+
for (const [level, mapped] of Object.entries(inherited ?? {})) {
|
|
289
|
+
map[level] = typeof mapped === "string" ? relayEffort(mapped) : mapped
|
|
290
|
+
}
|
|
291
|
+
map["off"] = null
|
|
292
|
+
map["minimal"] = null
|
|
293
|
+
return map
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The relay's effort enum, case-folded; null keeps the level out of the picker. */
|
|
297
|
+
function relayEffort(value: string): string | null {
|
|
298
|
+
if (RELAY_EFFORTS.has(value)) return value
|
|
299
|
+
const lower = value.toLowerCase()
|
|
300
|
+
return RELAY_EFFORTS.has(lower) ? lower : null
|
|
301
|
+
}
|
|
302
|
+
|
|
222
303
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
223
304
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
224
305
|
}
|
|
@@ -269,7 +350,12 @@ export function toProviderModelConfigs(
|
|
|
269
350
|
contextWindow: number
|
|
270
351
|
maxTokens: number
|
|
271
352
|
thinkingLevelMap?: Record<string, string | null>
|
|
272
|
-
compat?: {
|
|
353
|
+
compat?: {
|
|
354
|
+
forceAdaptiveThinking?: boolean
|
|
355
|
+
supportsLongCacheRetention?: boolean
|
|
356
|
+
sendSessionAffinityHeaders?: boolean
|
|
357
|
+
sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"
|
|
358
|
+
}
|
|
273
359
|
}> {
|
|
274
360
|
return models.map((model) => {
|
|
275
361
|
const adaptive = model.dialect === "anthropic-messages" && model.reasoning
|
|
@@ -301,14 +387,44 @@ export function toProviderModelConfigs(
|
|
|
301
387
|
// unsupported so pi omits the thinking param entirely (server default
|
|
302
388
|
// = adaptive), and forceAdaptiveThinking routes an explicit level to
|
|
303
389
|
// {type:"adaptive"} + effort instead of budget_tokens.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
390
|
+
// Both dialects pin both fields, and that is why `compat` is
|
|
391
|
+
// unconditional. The relay's prompt-cache affinity key resolves in this
|
|
392
|
+
// order: `x-claude-code-session-id`, `x-session-id`, the body's
|
|
393
|
+
// `prompt_cache_key`, then a hash of the cacheable request prefix
|
|
394
|
+
// (app.rs `conversation_key`). pi emits `x-session-id` only for the
|
|
395
|
+
// `openrouter` affinity format, and only when the send flag is set;
|
|
396
|
+
// the auto-detected defaults are wrong here in both cases
|
|
397
|
+
// (openai-completions picks `openai`: session_id + x-client-request-id +
|
|
398
|
+
// x-session-affinity; anthropic-messages picks nothing). The header is
|
|
399
|
+
// the cheaper and more explicit of the two signals and it outranks the
|
|
400
|
+
// body field, so pinning it keeps a session's account stable by the
|
|
401
|
+
// relay's first rule rather than its third. Losing the pin costs a
|
|
402
|
+
// session that migrates between pooled accounts its whole prefix: the
|
|
403
|
+
// upstream cache is per account.
|
|
404
|
+
//
|
|
405
|
+
// Measured, not assumed — `test/affinity-wire.test.ts` dumps both bodies:
|
|
406
|
+
// openai-completions carries `prompt_cache_key: <sessionId>` (and
|
|
407
|
+
// `prompt_cache_retention: "24h"`) under PI_CACHE_RETENTION=long, so the
|
|
408
|
+
// body field alone would name the conversation; anthropic-messages
|
|
409
|
+
// carries no `prompt_cache_key` at all, and pi-ai hardcodes
|
|
410
|
+
// `x-session-affinity` there, which this relay does not read. Messages
|
|
411
|
+
// traffic therefore rests entirely on the relay's prefix fallback until
|
|
412
|
+
// a pi release honours `sessionAffinityFormat` on that dialect.
|
|
413
|
+
//
|
|
414
|
+
// The two dialects do not land at the same time. openai-completions
|
|
415
|
+
// honors `sessionAffinityFormat` in every released pi. anthropic-messages
|
|
416
|
+
// only reads it from the unreleased change on pi main (commit
|
|
417
|
+
// bbb61e34a); through pi-ai 0.85.1 that client hardcodes the header name
|
|
418
|
+
// `x-session-affinity`, which this relay does not read, so the Claude
|
|
419
|
+
// pin below is inert until pi ships it. Pin now rather than later: the
|
|
420
|
+
// cost of an ignored header is zero, and the cost of forgetting is
|
|
421
|
+
// silently re-billed Claude prefixes.
|
|
422
|
+
compat: {
|
|
423
|
+
...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
|
|
424
|
+
...(longCacheRetention ? { supportsLongCacheRetention: true as const } : {}),
|
|
425
|
+
sendSessionAffinityHeaders: true as const,
|
|
426
|
+
sessionAffinityFormat: "openrouter" as const,
|
|
427
|
+
},
|
|
312
428
|
}
|
|
313
429
|
})
|
|
314
430
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pwguler/pi-pengepul-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi custom provider for pengepul, a local relay that pools your Claude/Codex subscriptions. Connects pi to http://127.0.0.1:8317 over the native Anthropic Messages and OpenAI Chat Completions wires.",
|
|
6
6
|
"license": "MIT",
|