@pwguler/pi-pengepul-provider 0.2.1 → 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 +119 -29
  2. package/package.json +1 -1
@@ -92,20 +92,45 @@ export function bareId(id: string): string {
92
92
  return slash === -1 ? id : id.slice(slash + 1)
93
93
  }
94
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
+
95
101
  /**
96
102
  * Fallback for models pi's catalog does not know. Family-shaped but
97
- * conservative; the builtin lookup wins whenever it has the id. The final
98
- * branch treats unknown ids as non-reasoning, so an unrecognized id never
99
- * 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.
100
108
  */
101
109
  function heuristicMeta(id: string): BuiltinModelMeta {
102
- const lower = bareId(id).toLowerCase()
103
- if (lower.startsWith("claude-")) {
110
+ const name = modelName(id).toLowerCase()
111
+ if (name.startsWith("claude-")) {
104
112
  return { reasoning: true, contextWindow: 200_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
105
113
  }
106
- 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
+ ) {
107
120
  return { reasoning: true, contextWindow: 272_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
108
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
+ }
109
134
  return {
110
135
  reasoning: false,
111
136
  contextWindow: DEFAULT_CONTEXT_WINDOW,
@@ -217,20 +242,19 @@ function toPengepulModel(
217
242
  const reasoning = meta.reasoning || ownedBy === "anthropic"
218
243
 
219
244
  // The relay enforces reasoning_effort low|medium|high|xhigh|max at its
220
- // request layer, uniformly across families: `minimal` 400s and its
221
- // thinking toggle never actually disables thinking. That holds regardless
222
- // of where the reasoning knowledge came from, so every openai-completions
223
- // reasoning model gets off/minimal nulled inherited catalogs were
224
- // written for other upstreams and do offer the unsafe levels (observed:
225
- // openrouter muse-spark `minimal: "minimal"`, opencode-go hy4-preview
226
- // `off: "none"`). Overlay, never replace: inherited strings stay valid on
227
- // the wire and inherited nulls keep their levels hidden. The Messages
228
- // dialect needs no overlay: it folds `minimal` into `low` and already
229
- // nulls `off` via forceAdaptiveThinking.
230
- let thinkingLevelMap = meta.thinkingLevelMap ? { ...meta.thinkingLevelMap } : undefined
231
- if (reasoning && dialect === "openai-completions") {
232
- thinkingLevelMap = { ...(thinkingLevelMap ?? {}), off: null, minimal: null }
233
- }
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
+ )
234
258
 
235
259
  return {
236
260
  id,
@@ -245,6 +269,37 @@ function toPengepulModel(
245
269
  }
246
270
  }
247
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
+
248
303
  function isRecord(value: unknown): value is Record<string, unknown> {
249
304
  return typeof value === "object" && value !== null && !Array.isArray(value)
250
305
  }
@@ -295,7 +350,12 @@ export function toProviderModelConfigs(
295
350
  contextWindow: number
296
351
  maxTokens: number
297
352
  thinkingLevelMap?: Record<string, string | null>
298
- compat?: { forceAdaptiveThinking?: boolean; supportsLongCacheRetention?: boolean }
353
+ compat?: {
354
+ forceAdaptiveThinking?: boolean
355
+ supportsLongCacheRetention?: boolean
356
+ sendSessionAffinityHeaders?: boolean
357
+ sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"
358
+ }
299
359
  }> {
300
360
  return models.map((model) => {
301
361
  const adaptive = model.dialect === "anthropic-messages" && model.reasoning
@@ -327,14 +387,44 @@ export function toProviderModelConfigs(
327
387
  // unsupported so pi omits the thinking param entirely (server default
328
388
  // = adaptive), and forceAdaptiveThinking routes an explicit level to
329
389
  // {type:"adaptive"} + effort instead of budget_tokens.
330
- ...(adaptive || longCacheRetention
331
- ? {
332
- compat: {
333
- ...(adaptive ? { forceAdaptiveThinking: true as const } : {}),
334
- ...(longCacheRetention ? { supportsLongCacheRetention: true as const } : {}),
335
- },
336
- }
337
- : {}),
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
+ },
338
428
  }
339
429
  })
340
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pwguler/pi-pengepul-provider",
3
- "version": "0.2.1",
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",