pi-inferhub 0.2.0 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damn Mersault
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -7,9 +7,10 @@ InferHub **combos** as selectable chat models.
7
7
  InferHub pools AI subscriptions behind one OpenAI-compatible endpoint. Combos
8
8
  are its server-side routed bundles — failover, cache-sticky, billed in USDC —
9
9
  and they are the primary consumption surface. This extension exposes **combos
10
- only**: each combo on your account appears as exactly one model named
11
- `combo/<slug>`. Upstream `prefix/model` entries, bare-name aliases, and
12
- free-tier models from InferHub's model list are intentionally not exposed.
10
+ only by default**: each combo on your account appears as exactly one model
11
+ named `combo/<slug>`. Upstream `prefix/model` entries, bare-name aliases, and
12
+ free-tier models are not exposed unless you opt in with
13
+ [`publishUpstream`](#full-upstream-catalog-optional).
13
14
 
14
15
  ## Install
15
16
 
@@ -51,9 +52,30 @@ points you to `/login inferhub` if not.
51
52
  ## Model selection
52
53
 
53
54
  Send `/model` and filter by provider name `inferhub` or by `combo/`. Each
54
- model id is `combo/<slug>` — the routable inference name on
55
+ combo model id is `combo/<slug>` — the routable inference name on
55
56
  `https://api.inferhub.dev/v1`.
56
57
 
58
+ ## Configuration
59
+
60
+ Optional `~/.pi/agent/pi-inferhub.json` (or `$PI_AGENT_DIR/pi-inferhub.json`).
61
+ A missing file means defaults; a malformed one logs an issue and keeps the
62
+ defaults — it never breaks startup.
63
+
64
+ ```json
65
+ { "publishUpstream": true }
66
+ ```
67
+
68
+ ### Full upstream catalog (optional)
69
+
70
+ `publishUpstream` (default `false`) additionally publishes every chat model
71
+ from `/v1/models` as a direct-selectable `prefix/model` entry, listed after
72
+ the combos. This bypasses combo routing — you pick one specific serving — and
73
+ is intended for pinning a particular upstream. Alias rows, `free/` tiers, and
74
+ image/video generation models are never published, even with the option on.
75
+
76
+ The `INFERHUB_PUBLISH_UPSTREAM` environment variable overrides the file in
77
+ both directions (`1/true/yes/on`, `0/false/no/off`).
78
+
57
79
  ## Catalog derivation
58
80
 
59
81
  On refresh the extension makes two authenticated GETs —
@@ -63,19 +85,34 @@ combo to one model:
63
85
 
64
86
  - **Context/output limits** — minimum across resolvable members
65
87
  (`input_token_limit` / `max_output_tokens`), so a combo is only as large as
66
- its smallest member. When no resolvable member supplies limits, conservative
67
- fallbacks apply: **128k context / 8k max output**.
88
+ its smallest member. When no resolvable member supplies limits, fallbacks
89
+ apply: **256k context / 128k max output**. A limit of `0` is treated as
90
+ missing data rather than a real ceiling.
91
+ - **Output cap enforcement** — `max_tokens` is sent rather than
92
+ `max_completion_tokens`, because InferHub ignores the latter (a request
93
+ capped at 20 tokens still produced 107).
68
94
  - **Image input** — offered (`text` + `image`) only when *every* resolvable
69
95
  member's modality includes image; otherwise text-only.
70
- - **Reasoning** — the combo is marked reasoning-capable when any resolvable
71
- member advertises `reasoning_levels`; Pi's thinking levels map onto the
72
- advertised ladder (levels a combo doesn't advertise are unavailable).
96
+ - **Reasoning** — combos are always marked reasoning-capable: a false
97
+ negative hides Pi's thinking knob (the worse error), while a false positive
98
+ only shows an inert one. Where members advertise `reasoning_levels`, Pi's
99
+ levels map onto that union; when none advertise a ladder the map is omitted
100
+ so every level stays selectable.
101
+ - **Surface** — each combo rides the API its membership implies:
102
+ **Anthropic messages** for Claude-only combos, **[OI] responses** for
103
+ GPT-only combos, and **chat completions** for everything else — including
104
+ combos that mix both native families. Family is decided from the model name,
105
+ not the serving prefix.
73
106
  - **Cost** — per-direction minimum of resolvable members' *official* rates
74
- (see below). `cacheRead`/`cacheWrite` are shown as `0`: the catalog carries
75
- no per-combo cache pricing, and none is fabricated.
107
+ (see below). Cache reads bill at **0.1x the input rate** (owner-confirmed);
108
+ cache writes are left at `0` because no write rate is published.
76
109
 
77
- Combos whose members are all unresolvable aliases get safe defaults:
78
- text-only, reasoning-capable, `0` cost, and the fallback limits.
110
+ Member resolution is conservative but forgiving: a member resolves directly
111
+ from `/v1/models` when its prefixed row carries data; otherwise it joins the
112
+ prefixed servings of the same model name (exact suffix first, then a
113
+ normalized last-segment match, which covers multi-segment prefixes such as
114
+ `cp/cline-pass/<model>`). Combos whose members resolve to nothing at all get
115
+ safe defaults: text-only, reasoning-capable, `0` cost, and the fallback limits.
79
116
 
80
117
  ## Refresh semantics
81
118
 
@@ -98,6 +135,27 @@ member's *official* list rates (`official_in` / `official_out`) per direction.
98
135
  InferHub's marketplace bills below list, so the real `usage.cost` reported per
99
136
  request comes in **at or under** the displayed estimate.
100
137
 
138
+ Cached input is priced at **0.1x the input rate**, the rate InferHub charges
139
+ (confirmed by its owner). On the Anthropic surface this is not theoretical:
140
+ sending the same prefix twice cut a request's real cost by roughly 16x in
141
+ testing, because the second call billed its 1218-token prefix as a cache read
142
+ instead of fresh input.
143
+
144
+ ## Surfaces
145
+
146
+ InferHub exposes three compatible APIs. The extension picks per model:
147
+
148
+ | membership | API | endpoint |
149
+ |---|---|---|
150
+ | Claude models only | Anthropic messages | `api.inferhub.dev/v1/messages` |
151
+ | GPT models only | [OI] responses | `api.inferhub.dev/v1/responses` |
152
+ | both families, or neither | chat completions | `api.inferhub.dev/v1/chat/completions` |
153
+
154
+ This buys native behaviour where it is unambiguous: structured thinking
155
+ control, `cache_control` breakpoints, and the responses surface's typed tool
156
+ calls. Mixed combos deliberately fall back to chat completions, which InferHub
157
+ translates for every model.
158
+
101
159
  ## Design rationale
102
160
 
103
161
  The full design — why combos only, why `/v1/models` supplies member metadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inferhub",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for the InferHub inference provider",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -23,9 +23,15 @@
23
23
  "typebox": "*"
24
24
  },
25
25
  "peerDependenciesMeta": {
26
- "@earendil-works/pi-ai": { "optional": false },
27
- "@earendil-works/pi-coding-agent": { "optional": false },
28
- "typebox": { "optional": false }
26
+ "@earendil-works/pi-ai": {
27
+ "optional": false
28
+ },
29
+ "@earendil-works/pi-coding-agent": {
30
+ "optional": false
31
+ },
32
+ "typebox": {
33
+ "optional": false
34
+ }
29
35
  },
30
36
  "devDependencies": {
31
37
  "@earendil-works/pi-ai": "^1.0.4",
@@ -34,5 +40,17 @@
34
40
  "typebox": "^1.3.36",
35
41
  "typescript": "^6.0.3",
36
42
  "vitest": "^5.0.3"
43
+ },
44
+ "files": [
45
+ "src",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "engines": {
50
+ "node": ">=22"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/damnmersault/pi-inferhub.git"
37
55
  }
38
56
  }
package/src/catalog.ts CHANGED
@@ -1,14 +1,55 @@
1
- import type { Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { Compile } from "typebox/compile";
4
4
  import { fetchJson, type FetchJsonOptions } from "./http.js";
5
- import { INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js";
5
+ import { ANTHROPIC_BASE_URL, INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js";
6
6
 
7
7
  /** pi thinking levels, in ladder order; `off` is handled separately. */
8
8
  const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
9
9
 
10
10
  const CONTEXT_WINDOW_FALLBACK = 262_144;
11
11
  const MAX_TOKENS_FALLBACK = 131_072;
12
+ /**
13
+ * Owner-confirmed 2026-10-18 (and matching the marketplace cache rule): InferHub
14
+ * bills cached input at 0.1x the input rate. Cache WRITES have no published
15
+ * rate, so they stay 0 rather than being guessed.
16
+ */
17
+ const CACHE_READ_MULTIPLIER = 0.1;
18
+
19
+ type ChatModel = Model<Api>;
20
+
21
+ /** Which native surface a model name belongs to. */
22
+ export type NativeFamily = "anthropic" | "openai" | "other";
23
+
24
+ /**
25
+ * Family of the model NAME (last path segment), never the serving prefix:
26
+ * live catalog 2026-10-18 shows `kiro/` serving both claude-* and gpt-* rows.
27
+ * `gpt-oss` is an open-weight model, not an [OI] native one, so it stays other.
28
+ */
29
+ export function nativeFamily(id: string): NativeFamily {
30
+ const name = (id.split("/").pop() ?? id).toLowerCase();
31
+ if (name.includes("claude")) return "anthropic";
32
+ if (/^(gpt(?!-oss)|o\d|codex)/.test(name)) return "openai";
33
+ return "other";
34
+ }
35
+
36
+ interface Surface {
37
+ api: Api;
38
+ baseUrl: string;
39
+ }
40
+
41
+ /**
42
+ * One native family present → its own surface. Both native families present
43
+ * (or none) → chat completions, which InferHub translates for every model.
44
+ */
45
+ export function selectSurface(names: readonly string[]): Surface {
46
+ const families = new Set(names.map(nativeFamily));
47
+ const anthropic = families.has("anthropic");
48
+ const openai = families.has("openai");
49
+ if (anthropic && !openai) return { api: "anthropic-messages", baseUrl: ANTHROPIC_BASE_URL };
50
+ if (openai && !anthropic) return { api: "openai-responses", baseUrl: INFERENCE_BASE_URL };
51
+ return { api: "openai-completions", baseUrl: INFERENCE_BASE_URL };
52
+ }
12
53
 
13
54
  export const MemberSchema = Type.Union([
14
55
  Type.Object({ kind: Type.Literal("model"), model: Type.String() }),
@@ -31,6 +72,8 @@ export const CatalogModelSchema = Type.Object({
31
72
  id: Type.String(),
32
73
  owned_by: Type.Optional(Type.String()),
33
74
  modality: Type.Optional(Type.String()),
75
+ output_modality: Type.Optional(Type.String()),
76
+ upstream_label: Type.Optional(Type.String()),
34
77
  input_token_limit: Type.Optional(Type.Number()),
35
78
  max_output_tokens: Type.Optional(Type.Number()),
36
79
  reasoning_levels: Type.Optional(Type.Array(Type.String())),
@@ -60,24 +103,32 @@ const ModelsEnvelope = Type.Object({ data: Type.Array(CatalogModelSchema) });
60
103
  const CombosValidator = Compile(CombosEnvelope);
61
104
  const ModelsValidator = Compile(ModelsEnvelope);
62
105
 
106
+ export interface FetchCatalogOptions extends FetchJsonOptions {
107
+ /** Also publish direct-selectable upstream models from /v1/models. */
108
+ publishUpstream?: boolean;
109
+ }
110
+
63
111
  /** GET /combos + GET /v1/models (independent), then map combos to pi models. */
64
112
  export async function fetchComboCatalog(
65
113
  token: string,
66
114
  signal?: AbortSignal,
67
- opts?: FetchJsonOptions,
68
- ): Promise<Model<"openai-completions">[]> {
115
+ opts?: FetchCatalogOptions,
116
+ ): Promise<ChatModel[]> {
69
117
  const [combos, models] = await Promise.all([
70
118
  fetchJson<Combo[]>(`${MGMT_BASE_URL}/combos`, token, CombosValidator, signal, opts),
71
119
  fetchJson<Static<typeof ModelsEnvelope>>(`${INFERENCE_BASE_URL}/models`, token, ModelsValidator, signal, opts),
72
120
  ]);
73
- return combosToModels(combos, models.data);
121
+ const comboModels = combosToModels(combos, models.data);
122
+ // Combos are the product: they stay first. The upstream catalog only widens
123
+ // the list when the operator opts in.
124
+ return opts?.publishUpstream ? [...comboModels, ...catalogToModels(models.data)] : comboModels;
74
125
  }
75
126
 
76
127
  /** Pure combo → Model mapping; output order follows `combos` order. */
77
128
  export function combosToModels(
78
129
  combos: Combo[],
79
130
  catalogModels: CatalogModel[],
80
- ): Model<"openai-completions">[] {
131
+ ): ChatModel[] {
81
132
  const catalogById = new Map(catalogModels.map((model) => [model.id, model]));
82
133
  return combos.map((combo) => comboToModel(combo, catalogById, catalogModels));
83
134
  }
@@ -86,10 +137,16 @@ function comboToModel(
86
137
  combo: Combo,
87
138
  catalogById: Map<string, CatalogModel>,
88
139
  catalogModels: CatalogModel[],
89
- ): Model<"openai-completions"> {
140
+ ): ChatModel {
90
141
  // Alias rows (`owned_by: "alias"`, no data) and alias-kind members resolve
91
142
  // through prefixed sibling servings of the same model name; min-of over the
92
143
  // siblings bounds the combo exactly like directly-joined members.
144
+ //
145
+ // Live shape 2026-10-18: combos reference multi-segment prefixes
146
+ // (cp/cline-pass/<model>, cp/zai/<model>) that have no exact serving, while
147
+ // the same model is served under another prefix (zai/<model>,
148
+ // cmc/moonshotai/<model>). Exact suffix matches win; the normalized
149
+ // last-segment name is the recovery path, never a wider net.
93
150
  const resolvable: CatalogModel[] = [];
94
151
  for (const member of combo.members) {
95
152
  const name = member.kind === "model" ? member.model : member.alias;
@@ -98,58 +155,144 @@ function comboToModel(
98
155
  resolvable.push(entry);
99
156
  continue;
100
157
  }
101
- for (const sibling of catalogModels) {
102
- if (sibling.owned_by === "alias") continue;
103
- if (sibling.id.endsWith(`/${name}`)) resolvable.push(sibling);
158
+ const exact = catalogModels.filter(
159
+ (sibling) => sibling.owned_by !== "alias" && sibling.id.endsWith(`/${name}`),
160
+ );
161
+ if (exact.length > 0) {
162
+ resolvable.push(...exact);
163
+ continue;
164
+ }
165
+ const normalized = normalizeModelName(name);
166
+ if (normalized.length > 0) {
167
+ resolvable.push(
168
+ ...catalogModels.filter(
169
+ (sibling) => sibling.owned_by !== "alias" && normalizeModelName(sibling.id) === normalized,
170
+ ),
171
+ );
104
172
  }
105
173
  }
106
174
 
107
- // Alias-only / all-missed-join combos reason optimistically: a false
108
- // negative hides thinking levels, a false positive shows an inert knob.
175
+ // Optimistic by design: a false negative hides thinking levels (the worse
176
+ // error); a false positive shows an inert knob. Applies to every combo,
177
+ // including level-less resolved members (live-verified 2026-10-18 on
178
+ // combo/deepseek-v4-pro: no advertised levels, honors reasoning_effort).
179
+ const name = combo.name.trim().length > 0 ? combo.name : combo.slug;
180
+ const surface = selectSurface(combo.members.map((member) => (member.kind === "model" ? member.model : member.alias)));
181
+ return buildModel(`combo/${combo.slug}`, name, resolvable, surface);
182
+ }
183
+
184
+ /**
185
+ * Direct-selectable upstream models from `/v1/models`, in catalog order.
186
+ * Skipped: alias rows (information-free duplicates of a prefixed serving),
187
+ * image-output rows (not chat models), and blank ids (live shape `cc/`).
188
+ */
189
+ export function catalogToModels(catalogModels: CatalogModel[]): ChatModel[] {
190
+ return catalogModels
191
+ .filter(
192
+ (row) =>
193
+ row.owned_by !== "alias" &&
194
+ // AGENTS.md product rule: never surface image/video generation or
195
+ // `free/` tier models, even when the upstream catalog is published.
196
+ row.output_modality !== "image" &&
197
+ row.output_modality !== "video" &&
198
+ !row.id.startsWith("free/") &&
199
+ // Live shape `cc/`: prefix with an empty model segment — not selectable.
200
+ (row.id.split("/").pop() ?? "").trim().length > 0,
201
+ )
202
+ .map((row) => {
203
+ const label = row.upstream_label?.trim();
204
+ return buildModel(row.id, label && label.length > 0 ? label : row.id, [row], selectSurface([row.id]));
205
+ });
206
+ }
207
+
208
+ /** Shared pi-model construction for combo and direct-catalog entries. */
209
+ function buildModel(id: string, name: string, sources: CatalogModel[], surface: Surface): ChatModel {
109
210
  const advertised = new Set<string>();
110
- for (const model of resolvable) {
211
+ for (const model of sources) {
111
212
  for (const level of model.reasoning_levels ?? []) advertised.add(level);
112
213
  }
113
- const reasoning = resolvable.length === 0 || advertised.size > 0;
114
214
 
115
- const image = resolvable.length > 0 && resolvable.every((model) => (model.modality ?? "").includes("image"));
215
+ const image = sources.length > 0 && sources.every((model) => (model.modality ?? "").includes("image"));
116
216
 
117
- const priced = resolvable.flatMap((model) => (model.pricing ? [model.pricing] : []));
217
+ const priced = sources.flatMap((model) => (model.pricing ? [model.pricing] : []));
218
+ const inputRate = minOf(priced.map((pricing) => pricing.official_in)) ?? 0;
118
219
 
119
- const model: Model<"openai-completions"> = {
120
- id: `combo/${combo.slug}`,
121
- name: combo.name.trim().length > 0 ? combo.name : combo.slug,
122
- api: "openai-completions",
220
+ const model: ChatModel = {
221
+ id,
222
+ name,
223
+ api: surface.api,
123
224
  provider: PROVIDER_NAME,
124
- baseUrl: INFERENCE_BASE_URL,
225
+ baseUrl: surface.baseUrl,
125
226
  input: image ? ["text", "image"] : ["text"],
126
227
  cost: {
127
- input: minOf(priced.map((pricing) => pricing.official_in)) ?? 0,
228
+ input: inputRate,
128
229
  output: minOf(priced.map((pricing) => pricing.official_out)) ?? 0,
129
- cacheRead: 0,
230
+ cacheRead: roundRate(inputRate * CACHE_READ_MULTIPLIER),
130
231
  cacheWrite: 0,
131
232
  },
132
- reasoning,
133
- contextWindow: minOf(resolvable.map((m) => m.input_token_limit)) ?? CONTEXT_WINDOW_FALLBACK,
134
- maxTokens: minOf(resolvable.map((m) => m.max_output_tokens)) ?? MAX_TOKENS_FALLBACK,
233
+ reasoning: true,
234
+ contextWindow: minPositiveOf(sources.map((source) => source.input_token_limit)) ?? CONTEXT_WINDOW_FALLBACK,
235
+ maxTokens: minPositiveOf(sources.map((source) => source.max_output_tokens)) ?? MAX_TOKENS_FALLBACK,
135
236
  };
136
- // thinkingLevelMap: built only from an advertised union; an empty union
137
- // (alias-only combos) omits the map so pi defaults keep thinking levels
138
- // selectable — marking them all `null` would hide the knob entirely.
139
- if (reasoning && advertised.size > 0) model.thinkingLevelMap = buildThinkingLevelMap(advertised);
237
+
238
+ if (surface.api === "openai-completions") {
239
+ // InferHub's [OI]-compatible surface ignores max_completion_tokens (verified
240
+ // live 2026-10-18: a 20-token cap sent that way produced 107 tokens; the
241
+ // same cap as max_tokens stopped at 20). pi-ai auto-detects per-provider
242
+ // and inferhub is not on its list, so pin the field explicitly.
243
+ model.compat = { maxTokensField: "max_tokens" };
244
+ }
245
+
246
+ // thinkingLevelMap: built only from an advertised union; a level-less union
247
+ // omits the level keys so pi defaults keep thinking levels selectable —
248
+ // marking them all `null` would hide the knob entirely.
249
+ if (surface.api === "anthropic-messages") {
250
+ // These Claude models reject thinking:{type:"disabled"} (400, live
251
+ // 2026-10-18) and require adaptive thinking. `off: null` makes pi-ai omit
252
+ // the field instead; the map is therefore always present, even when no
253
+ // ladder is advertised.
254
+ model.compat = { forceAdaptiveThinking: true };
255
+ // With no advertised ladder only `off` is pinned: the level keys stay
256
+ // absent so pi keeps every level selectable (optimistic by design).
257
+ model.thinkingLevelMap = advertised.size > 0 ? buildThinkingLevelMap(advertised, null) : { off: null };
258
+ } else if (surface.api === "openai-responses") {
259
+ // [OI] reasoning-off is effort "none"; the value "off" is accepted but
260
+ // leaves the effort unset (live-verified 2026-10-18).
261
+ if (advertised.size > 0) model.thinkingLevelMap = buildThinkingLevelMap(advertised, "none");
262
+ else model.thinkingLevelMap = { off: "none" };
263
+ } else if (advertised.size > 0) {
264
+ model.thinkingLevelMap = buildThinkingLevelMap(advertised, "off");
265
+ }
266
+
140
267
  return model;
141
268
  }
142
269
 
270
+ /** Cache rates are derived; trim float noise (3 * 0.1 = 0.30000000000000004). */
271
+ function roundRate(value: number): number {
272
+ return Math.round(value * 1e6) / 1e6;
273
+ }
274
+
143
275
  /** Each pi level maps to itself when the combo advertises it, else `null`. */
144
- function buildThinkingLevelMap(advertised: ReadonlySet<string>): ThinkingLevelMap {
145
- const map: ThinkingLevelMap = { off: "off" };
276
+ function buildThinkingLevelMap(advertised: ReadonlySet<string>, off: "off" | "none" | null): ThinkingLevelMap {
277
+ const map: ThinkingLevelMap = { off };
146
278
  for (const level of PI_THINKING_LEVELS) {
147
279
  map[level] = advertised.has(level) ? level : null;
148
280
  }
149
281
  return map;
150
282
  }
151
283
 
284
+ /** Last path segment, lowercased, alphanumerics only: "cp/cline-pass/Kimi-K3" → "kimik3". */
285
+ function normalizeModelName(id: string): string {
286
+ return (id.split("/").pop() ?? id).toLowerCase().replace(/[^a-z0-9]/g, "");
287
+ }
288
+
152
289
  function minOf(values: Array<number | undefined>): number | undefined {
153
290
  const present = values.filter((value): value is number => value !== undefined);
154
291
  return present.length > 0 ? Math.min(...present) : undefined;
155
292
  }
293
+
294
+ /** Limits: 0 (or non-finite) is missing data, never a real ceiling. Prices keep 0. */
295
+ function minPositiveOf(values: Array<number | undefined>): number | undefined {
296
+ const present = values.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > 0);
297
+ return present.length > 0 ? Math.min(...present) : undefined;
298
+ }
package/src/config.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Canonical config file for this package, under pi's agent dir. */
6
+ export const CONFIG_FILE_NAME = "pi-inferhub.json";
7
+
8
+ /** Env override for `publishUpstream`; wins over the file when set. */
9
+ export const PUBLISH_UPSTREAM_ENV = "INFERHUB_PUBLISH_UPSTREAM";
10
+
11
+ export interface InferhubConfig {
12
+ /**
13
+ * Also publish every chat model from `/v1/models` as a direct-selectable
14
+ * `prefix/model` entry, alongside the `combo/<slug>` models. Combos stay
15
+ * first; this only widens the catalog. Default: false.
16
+ */
17
+ publishUpstream: boolean;
18
+ /** Human-readable problems found while loading; never fatal. */
19
+ issues: string[];
20
+ }
21
+
22
+ /** pi's agent dir: `PI_AGENT_DIR` when set, else `~/.pi/agent`. */
23
+ export function defaultAgentDir(): string {
24
+ return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
25
+ }
26
+
27
+ function parseEnvBoolean(raw: string): boolean | undefined {
28
+ const value = raw.trim().toLowerCase();
29
+ if (["1", "true", "yes", "on"].includes(value)) return true;
30
+ if (["0", "false", "no", "off"].includes(value)) return false;
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * Load `<agentDir>/pi-inferhub.json`. Missing file = defaults; malformed file =
36
+ * defaults + issue; unknown keys are ignored, wrong-typed values are reported
37
+ * and leave the default in place. Never throws for file content problems.
38
+ */
39
+ export function loadInferhubConfig(agentDir: string = defaultAgentDir()): InferhubConfig {
40
+ const issues: string[] = [];
41
+ let raw: unknown;
42
+ try {
43
+ raw = JSON.parse(readFileSync(join(agentDir, CONFIG_FILE_NAME), "utf8"));
44
+ } catch (error) {
45
+ const code = (error as NodeJS.ErrnoException)?.code;
46
+ if (code === "ENOENT") return { publishUpstream: false, issues };
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ issues.push(`${CONFIG_FILE_NAME} is not valid JSON (${message}); using defaults`);
49
+ return { publishUpstream: false, issues };
50
+ }
51
+
52
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
53
+ issues.push(`${CONFIG_FILE_NAME} must contain a JSON object; using defaults`);
54
+ return { publishUpstream: false, issues };
55
+ }
56
+
57
+ let publishUpstream = false;
58
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
59
+ if (key !== "publishUpstream") continue;
60
+ if (typeof value === "boolean") publishUpstream = value;
61
+ else issues.push(`publishUpstream must be a boolean (got ${typeof value}); using false`);
62
+ }
63
+
64
+ const envRaw = process.env[PUBLISH_UPSTREAM_ENV];
65
+ if (envRaw !== undefined) {
66
+ const envValue = parseEnvBoolean(envRaw);
67
+ if (envValue === undefined) {
68
+ issues.push(`${PUBLISH_UPSTREAM_ENV} must be a boolean-like value; ignoring`);
69
+ } else {
70
+ publishUpstream = envValue;
71
+ }
72
+ }
73
+
74
+ return { publishUpstream, issues };
75
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { createProvider, type Provider } from "@earendil-works/pi-ai";
1
+ import { createProvider, type Api, type Provider } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  // Pi's extension loader doesn't virtualize lazy API implementation subpaths yet
4
- import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
4
+ import { anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi } from "@earendil-works/pi-ai/compat";
5
5
  import { inferhubApiKeyAuth, loginInferhub } from "./auth.js";
6
6
  import { fetchComboCatalog } from "./catalog.js";
7
+ import { type InferhubConfig, loadInferhubConfig } from "./config.js";
7
8
  import { INFERENCE_BASE_URL, PROVIDER_DISPLAY_NAME, PROVIDER_NAME } from "./inferhub.js";
8
9
 
9
10
  /**
@@ -17,8 +18,14 @@ export interface InferhubRegistrationPi {
17
18
  registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void> }): void;
18
19
  }
19
20
 
20
- function inferhubProvider(): Provider<"openai-completions"> {
21
- return createProvider({
21
+ export interface InferhubRegistrationOptions {
22
+ /** Config loader seam; production reads `<agentDir>/pi-inferhub.json`. */
23
+ loadConfig?: () => InferhubConfig;
24
+ }
25
+
26
+ function inferhubProvider(options: InferhubRegistrationOptions = {}): Provider<Api> {
27
+ const loadConfig = options.loadConfig ?? (() => loadInferhubConfig());
28
+ return createProvider<Api>({
22
29
  id: PROVIDER_NAME,
23
30
  name: PROVIDER_DISPLAY_NAME,
24
31
  baseUrl: INFERENCE_BASE_URL,
@@ -36,15 +43,23 @@ function inferhubProvider(): Provider<"openai-completions"> {
36
43
  const credential = context.credential;
37
44
  const token = credential?.type === "api_key" ? credential.key : undefined;
38
45
  if (!token) return [];
39
- return fetchComboCatalog(token, context.signal);
46
+ return fetchComboCatalog(token, context.signal, { publishUpstream: loadConfig().publishUpstream });
47
+ },
48
+ api: {
49
+ // Combos carry the surface their membership selects: anthropic-messages
50
+ // for Claude-only, openai-responses for GPT-only, chat completions for
51
+ // everything else (including mixed membership). createProvider
52
+ // dispatches on model.api.
53
+ "openai-completions": openAICompletionsApi(),
54
+ "openai-responses": openAIResponsesApi(),
55
+ "anthropic-messages": anthropicMessagesApi(),
40
56
  },
41
- api: openAICompletionsApi(),
42
57
  });
43
58
  }
44
59
 
45
60
  /** Register the InferHub provider and its companion command. */
46
- export function registerInferhubProvider(pi: InferhubRegistrationPi): void {
47
- pi.registerProvider(inferhubProvider());
61
+ export function registerInferhubProvider(pi: InferhubRegistrationPi, options?: InferhubRegistrationOptions): void {
62
+ pi.registerProvider(inferhubProvider(options));
48
63
 
49
64
  // pi's registerCommand takes a single command name; `/login inferhub` is
50
65
  // provided by pi's auth system through the apiKey login wired above. This
package/src/inferhub.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export const PROVIDER_NAME = "inferhub";
2
2
  export const PROVIDER_DISPLAY_NAME = "InferHub";
3
3
  export const INFERENCE_BASE_URL = "https://api.inferhub.dev/v1";
4
+ /** Anthropic surface lives on the bare host: pi-ai appends /v1/messages itself. */
5
+ export const ANTHROPIC_BASE_URL = "https://api.inferhub.dev";
4
6
  export const MGMT_BASE_URL = "https://inferhub.dev/api";