pi-inferhub 0.1.0 → 0.2.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/AGENTS.md CHANGED
@@ -59,18 +59,21 @@ upstream quirk.
59
59
  members' `official` rates (conservative upper bound — InferHub bills
60
60
  discounts below official). `cost.cacheRead/cacheWrite` = 0 always —
61
61
  InferHub's /v1/models pricing carries no cache fields; never fabricate.
62
- 3. **Fallback constants**: `CONTEXT_WINDOW_FALLBACK = 128_000`,
63
- `MAX_TOKENS_FALLBACK = 8_192` — used when no resolvable member supplies
64
- the field. pi-ai's `Model` type requires both as numbers; never guess a
65
- model's ceiling other than via these constants.
62
+ 3. **Fallback constants**: `CONTEXT_WINDOW_FALLBACK = 262_144`,
63
+ `MAX_TOKENS_FALLBACK = 131_072` — used when no resolvable member supplies
64
+ the field (last resort after the alias sibling join; see 5). pi-ai's
65
+ `Model` type requires both as numbers; never guess a model's ceiling
66
+ other than via these constants.
66
67
  4. **Image rule**: `input: ["text","image"]` only when ≥1 resolvable member
67
68
  AND every resolvable member's `modality` includes `image`. Understating
68
69
  input is acceptable; overstating sends requests the upstream rejects.
69
- 5. **Alias-member fallback**: empty resolvable set (alias-only combos, or
70
- all member joins missed) → `input:["text"]`, `reasoning:true`, zero
71
- cost, fallback limits. `reasoning:true` is deliberate: a false negative
72
- hides thinking levels (the worse error); a false positive shows an inert
73
- knob.
70
+ 5. **Alias-member fallback**: bare alias rows (`owned_by: "alias"`, no
71
+ data — observed live 2026-10-15) and alias-kind members first resolve
72
+ through prefixed sibling servings of the same model name (`<prefix>/<name>`
73
+ rows in the /v1/models catalog, min-of applies). Only when no sibling
74
+ carries data either → `input:["text"]`, `reasoning:true`, zero cost,
75
+ fallback limits. `reasoning:true` is deliberate: a false negative hides
76
+ thinking levels (the worse error); a false positive shows an inert knob.
74
77
  6. **thinkingLevelMap**: built from the union of resolvable members'
75
78
  `reasoning_levels`; each pi level (`minimal|low|medium|high|xhigh|max`)
76
79
  maps to itself when advertised, else `null`; `off:"off"`. Set only when
@@ -100,10 +103,20 @@ npm pack --dry-run # inspect package contents before publishing
100
103
  pi packs the extension from `package.json` `pi.extensions:
101
104
  ["./src/index.ts"]`. Local dev: `pi -e <repo path>`. Published install:
102
105
  `npm:pi-inferhub` in `~/.pi/agent/settings.json` packages (NOT yet
103
- published as of 2026-10-07 — v0.1.0).
106
+ published npm:pi-inferhub (v0.1.1).
104
107
 
105
108
  ## Environment gotchas (learned the hard way)
106
109
 
110
+ - **Pi packages declare host modules as peerDependencies, never
111
+ dependencies**: `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`,
112
+ `typebox` (and pi-tui if used) go in `peerDependencies` with `"*"` ranges;
113
+ keep `dependencies` empty. Regular dependencies make pi's package installer
114
+ warn about duplicate host-module copies — extensions must bind to the
115
+ copies the pi host already loads. Mirror the same modules in
116
+ `devDependencies` (with real version pins) for the repo's own
117
+ tests/typecheck.
118
+
119
+
107
120
  - **TypeScript 6.0.3**: tsconfig MUST carry `types: ["node"]` — TS 6 dropped
108
121
  automatic `@types/*` inclusion; without it `node:module`/`fetch`/`AbortSignal`
109
122
  don't resolve and `tsc --noEmit` fails.
@@ -112,9 +125,6 @@ published as of 2026-10-07 — v0.1.0).
112
125
  error detail lives on `instancePath` (there is no `path` field). Extra
113
126
  payload keys pass through by default; do NOT add
114
127
  `additionalProperties: false` to wire schemas.
115
- - **Git signing**: fresh clones inherit `gpg.format=ssh` globally but need
116
- `git config user.signingkey ~/.ssh/michaelmacleod.id_ecdsa.pub` locally
117
- before the first commit fails.
118
128
  - **pi-ai `AuthEvent`** is a union; non-info variants carry no `message`.
119
129
  Anything typing an interaction callback as `{type: string; message:
120
130
  string}` breaks under contravariance — use `{type: string; message?:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inferhub",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension for the InferHub inference provider",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -16,13 +16,22 @@
16
16
  "test": "vitest run",
17
17
  "typecheck": "tsc --noEmit"
18
18
  },
19
- "dependencies": {
20
- "@earendil-works/pi-ai": "^1.0.4",
21
- "@earendil-works/pi-coding-agent": "^1.0.4",
22
- "typebox": "^1.3.36"
19
+ "dependencies": {},
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-ai": "*",
22
+ "@earendil-works/pi-coding-agent": "*",
23
+ "typebox": "*"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "@earendil-works/pi-ai": { "optional": false },
27
+ "@earendil-works/pi-coding-agent": { "optional": false },
28
+ "typebox": { "optional": false }
23
29
  },
24
30
  "devDependencies": {
31
+ "@earendil-works/pi-ai": "^1.0.4",
32
+ "@earendil-works/pi-coding-agent": "^1.0.4",
25
33
  "@types/node": "^24.0.0",
34
+ "typebox": "^1.3.36",
26
35
  "typescript": "^6.0.3",
27
36
  "vitest": "^5.0.3"
28
37
  }
Binary file
package/src/catalog.ts CHANGED
@@ -7,8 +7,8 @@ import { INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js"
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
- const CONTEXT_WINDOW_FALLBACK = 128_000;
11
- const MAX_TOKENS_FALLBACK = 8_192;
10
+ const CONTEXT_WINDOW_FALLBACK = 262_144;
11
+ const MAX_TOKENS_FALLBACK = 131_072;
12
12
 
13
13
  export const MemberSchema = Type.Union([
14
14
  Type.Object({ kind: Type.Literal("model"), model: Type.String() }),
@@ -24,8 +24,12 @@ export const ComboSchema = Type.Object({
24
24
  });
25
25
  export type Combo = Static<typeof ComboSchema>;
26
26
 
27
+ /** Live-pinned 2026-10-15: bare (prefix-less) catalog rows are aliases served by
28
+ * prefixed upstream rows — they carry owned_by "alias" and NO limits/pricing/
29
+ * reasoning fields at all. Unknown extra fields pass validation regardless. */
27
30
  export const CatalogModelSchema = Type.Object({
28
31
  id: Type.String(),
32
+ owned_by: Type.Optional(Type.String()),
29
33
  modality: Type.Optional(Type.String()),
30
34
  input_token_limit: Type.Optional(Type.Number()),
31
35
  max_output_tokens: Type.Optional(Type.Number()),
@@ -34,6 +38,8 @@ export const CatalogModelSchema = Type.Object({
34
38
  Type.Object({
35
39
  official_in: Type.Number(),
36
40
  official_out: Type.Number(),
41
+ min_ask_in: Type.Optional(Type.Number()),
42
+ min_ask_out: Type.Optional(Type.Number()),
37
43
  }),
38
44
  ),
39
45
  });
@@ -45,6 +51,10 @@ export type CatalogModel = Static<typeof CatalogModelSchema>;
45
51
  * validation (no additionalProperties restrictions).
46
52
  */
47
53
  const CombosEnvelope = Type.Array(ComboSchema);
54
+ /** Bare (prefix-less) alias row shape observed live 2026-10-15:
55
+ * {id, object:"model", created, owned_by:"alias", supports_cache} — no limits,
56
+ * pricing, modality, or reasoning fields at all. Prefixed servings of the same
57
+ * model carry the data; catalog.ts resolves aliases through them (sibling join). */
48
58
  const ModelsEnvelope = Type.Object({ data: Type.Array(CatalogModelSchema) });
49
59
 
50
60
  const CombosValidator = Compile(CombosEnvelope);
@@ -69,18 +79,29 @@ export function combosToModels(
69
79
  catalogModels: CatalogModel[],
70
80
  ): Model<"openai-completions">[] {
71
81
  const catalogById = new Map(catalogModels.map((model) => [model.id, model]));
72
- return combos.map((combo) => comboToModel(combo, catalogById));
82
+ return combos.map((combo) => comboToModel(combo, catalogById, catalogModels));
73
83
  }
74
84
 
75
85
  function comboToModel(
76
86
  combo: Combo,
77
87
  catalogById: Map<string, CatalogModel>,
88
+ catalogModels: CatalogModel[],
78
89
  ): Model<"openai-completions"> {
90
+ // Alias rows (`owned_by: "alias"`, no data) and alias-kind members resolve
91
+ // through prefixed sibling servings of the same model name; min-of over the
92
+ // siblings bounds the combo exactly like directly-joined members.
79
93
  const resolvable: CatalogModel[] = [];
80
94
  for (const member of combo.members) {
81
- if (member.kind !== "model") continue;
82
- const entry = catalogById.get(member.model);
83
- if (entry !== undefined) resolvable.push(entry);
95
+ const name = member.kind === "model" ? member.model : member.alias;
96
+ const entry = member.kind === "model" ? catalogById.get(member.model) : undefined;
97
+ if (entry !== undefined && entry.owned_by !== "alias") {
98
+ resolvable.push(entry);
99
+ continue;
100
+ }
101
+ for (const sibling of catalogModels) {
102
+ if (sibling.owned_by === "alias") continue;
103
+ if (sibling.id.endsWith(`/${name}`)) resolvable.push(sibling);
104
+ }
84
105
  }
85
106
 
86
107
  // Alias-only / all-missed-join combos reason optimistically: a false
package/src/index.ts CHANGED
@@ -55,7 +55,7 @@ export function registerInferhubProvider(pi: InferhubRegistrationPi): void {
55
55
  const status = ctx.modelRegistry.getProviderAuthStatus(PROVIDER_NAME);
56
56
  if (status?.configured) {
57
57
  const source = status.label ? ` (${status.label})` : "";
58
- ctx.ui.notify(`InferHub is configured${source}. Run /models to pick a combo.`, "info");
58
+ ctx.ui.notify(`InferHub is configured${source}. Run /model to pick a combo.`, "info");
59
59
  return;
60
60
  }
61
61
  ctx.ui.notify(
@@ -20,6 +20,10 @@ const combo = (members: Combo["members"], overrides: Partial<Combo> = {}): Combo
20
20
  id: "c1", name: "My Combo", slug: "my-combo", members, ...overrides,
21
21
  });
22
22
 
23
+ /** Bare alias row shape observed live 2026-10-15: id without prefix, owned_by "alias",
24
+ * no limits/pricing/reasoning fields at all. */
25
+ const aliasRow = (name: string): CatalogModel => ({ id: name, owned_by: "alias" });
26
+
23
27
  describe("combosToModels", () => {
24
28
  it("maps id, name, provider, baseUrl, api", () => {
25
29
  const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }])], catalog);
@@ -49,8 +53,8 @@ describe("combosToModels", () => {
49
53
  expect(aliasOnly[0].input).toEqual(["text"]);
50
54
  expect(aliasOnly[0].reasoning).toBe(true);
51
55
  expect(aliasOnly[0].cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
52
- expect(aliasOnly[0].contextWindow).toBe(128_000);
53
- expect(aliasOnly[0].maxTokens).toBe(8_192);
56
+ expect(aliasOnly[0].contextWindow).toBe(262_144);
57
+ expect(aliasOnly[0].maxTokens).toBe(131_072);
54
58
 
55
59
  const partial = combosToModels([combo([{ kind: "model", model: "ghost/gone" }, { kind: "model", model: "croc/glm-5.3" }])], catalog);
56
60
  expect(partial[0].contextWindow).toBe(200_000);
@@ -66,6 +70,51 @@ describe("combosToModels", () => {
66
70
  expect(combosToModels([], catalog)).toEqual([]);
67
71
  });
68
72
 
73
+ it("raises bare alias rows and alias-kind members via prefixed sibling join (live combo/fast shape)", () => {
74
+ // Members named like a user-built combo: bare names whose catalog rows are
75
+ // information-free alias rows, but prefixed servings of the same model exist.
76
+ const sibCatalog: CatalogModel[] = [
77
+ aliasRow("qwen3.8-flash"),
78
+ aliasRow("deepseek-v4.1-flash"),
79
+ aliasRow("glm-5.3-flash"),
80
+ { id: "ocg/qwen3.8-flash", ...base },
81
+ { id: "ali/qwen3.8-flash", ...base },
82
+ { id: "ocg/deepseek-v4.1-flash", ...base, pricing: { official_in: 2.0, official_out: 10.0 } },
83
+ { id: "zai/glm-5.3-flash", ...base },
84
+ ];
85
+ const [m] = combosToModels([combo([
86
+ { kind: "model", model: "qwen3.8-flash" },
87
+ { kind: "alias", alias: "deepseek-v4.1-flash" },
88
+ { kind: "model", model: "glm-5.3-flash" },
89
+ ])], sibCatalog);
90
+ expect(m.contextWindow).toBe(200_000); // min over sibling servings, not the 128k fallback
91
+ expect(m.maxTokens).toBe(32_000);
92
+ expect(m.reasoning).toBe(true);
93
+ expect(m.thinkingLevelMap).toMatchObject({ low: "low", high: "high" });
94
+ expect(m.input).toEqual(["text", "image"]); // every sibling serving is vision-capable
95
+ expect(m.cost).toEqual({ input: 2.0, output: 10.0, cacheRead: 0, cacheWrite: 0 });
96
+ });
97
+
98
+ it("does not cross-join a siblingless alias and keeps safe defaults (claude fable shape)", () => {
99
+ // cc/ row exists but is null everywhere and has NO prefixed siblings carrying data.
100
+ const [m] = combosToModels([combo([{ kind: "model", model: "claude-fable-5-1" }])], [aliasRow("claude-fable-5-1")]);
101
+ expect(m.contextWindow).toBe(262_144);
102
+ expect(m.maxTokens).toBe(131_072);
103
+ expect(m.reasoning).toBe(true); // alias-only optimism invariant
104
+ expect(m.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
105
+ });
106
+
107
+ it("prefers a direct row's real data over sibling join", () => {
108
+ // A prefixed member resolves directly; siblings must not inflate its set.
109
+ const sibCatalog: CatalogModel[] = [
110
+ { id: "croc/glm-5.3", ...base },
111
+ { id: "other/glm-5.3", ...base, input_token_limit: 1_000_000, max_output_tokens: 128_000 },
112
+ ];
113
+ const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }])], sibCatalog);
114
+ expect(m.contextWindow).toBe(200_000); // only the directly-joined member counts
115
+ expect(m.maxTokens).toBe(32_000);
116
+ });
117
+
69
118
  it("reasoning false when no member advertises levels", () => {
70
119
  const noLevels: CatalogModel = { id: "x/plain", modality: "text" };
71
120
  const [m] = combosToModels([combo([{ kind: "model", model: "x/plain" }])], [noLevels]);