@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.
Files changed (2) hide show
  1. package/extensions/models.ts +138 -22
  2. package/package.json +1 -1
@@ -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 `pricing`. That is the first-party truth for what
13
- * this relay actually serves, so it wins. The rollout is partial (some ids
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
- /** v2 cached pre-multi-catalog lookups (commandcode ids missed reasoning); reject it. */
36
- const MODEL_CACHE_VERSION = 4
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 from the catalog; undefined = pi's default. */
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 final
97
- * branch treats unknown ids as non-reasoning, so an unrecognized id never
98
- * gets reasoning params the upstream may reject.
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 lower = bareId(id).toLowerCase()
102
- if (lower.startsWith("claude-")) {
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 (lower.startsWith("gpt-") || lower.startsWith("codex-") || /^o[1-9]/.test(lower)) {
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
- ...(meta.thinkingLevelMap ? { thinkingLevelMap: { ...meta.thinkingLevelMap } } : {}),
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?: { forceAdaptiveThinking?: boolean; supportsLongCacheRetention?: boolean }
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
- ...(adaptive || longCacheRetention
305
- ? {
306
- compat: {
307
- ...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
308
- ...(longCacheRetention ? { supportsLongCacheRetention: true as const } : {}),
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.0",
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",