@aliou/pi-ts-aperture 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aliou/pi-ts-aperture",
3
3
  "description": "Route Pi LLM providers through Tailscale Aperture",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -0,0 +1,121 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { configLoader } from "../lib/config";
3
+ import { fetchGatewayModels } from "../lib/gateway";
4
+ import type { Api, Model } from "../lib/types";
5
+ import { ApertureRuntime } from "./runtime";
6
+
7
+ vi.mock("../lib/config", () => ({
8
+ configLoader: {
9
+ getConfig: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ vi.mock("../lib/gateway", () => ({
14
+ fetchGatewayModels: vi.fn(),
15
+ }));
16
+
17
+ const getConfig = vi.mocked(configLoader.getConfig);
18
+ const fetchModels = vi.mocked(fetchGatewayModels);
19
+
20
+ function model(provider: string, id: string): Model<Api> {
21
+ return { provider, id } as Model<Api>;
22
+ }
23
+
24
+ async function check(models: Model<Api>[]) {
25
+ const notify = vi.fn();
26
+ const runtime = new ApertureRuntime();
27
+
28
+ await runtime.checkMissingModels(
29
+ {
30
+ getModels: () => models,
31
+ notify,
32
+ },
33
+ "http://gateway.test",
34
+ );
35
+
36
+ return notify;
37
+ }
38
+
39
+ describe("ApertureRuntime.checkMissingModels", () => {
40
+ beforeEach(() => {
41
+ getConfig.mockReturnValue({
42
+ baseUrl: "http://gateway.test",
43
+ providers: [],
44
+ checkGatewayModels: ["synthetic"],
45
+ });
46
+ fetchModels.mockResolvedValue([]);
47
+ });
48
+
49
+ test("matches gateway models by provider and id", async () => {
50
+ fetchModels.mockResolvedValue([{ providerId: "openrouter", id: "foo" }]);
51
+
52
+ const notify = await check([
53
+ model("synthetic", "foo"),
54
+ model("openrouter", "foo"),
55
+ ]);
56
+
57
+ expect(notify).toHaveBeenCalledOnce();
58
+ expect(notify.mock.calls[0][0]).toContain("synthetic: foo");
59
+ });
60
+
61
+ test("only checks configured providers", async () => {
62
+ getConfig.mockReturnValue({
63
+ baseUrl: "http://gateway.test",
64
+ providers: [],
65
+ checkGatewayModels: ["synthetic"],
66
+ });
67
+ fetchModels.mockResolvedValue([{ providerId: "synthetic", id: "foo" }]);
68
+
69
+ const notify = await check([
70
+ model("synthetic", "foo"),
71
+ model("openrouter", "missing-openrouter"),
72
+ ]);
73
+
74
+ expect(notify).not.toHaveBeenCalled();
75
+ });
76
+
77
+ test("truncates missing models per provider", async () => {
78
+ getConfig.mockReturnValue({
79
+ baseUrl: "http://gateway.test",
80
+ providers: [],
81
+ checkGatewayModels: ["openrouter", "synthetic"],
82
+ });
83
+ fetchModels.mockResolvedValue([{ providerId: "synthetic", id: "syn-1" }]);
84
+
85
+ const notify = await check([
86
+ model("openrouter", "or-1"),
87
+ model("openrouter", "or-2"),
88
+ model("openrouter", "or-3"),
89
+ model("openrouter", "or-4"),
90
+ model("openrouter", "or-5"),
91
+ model("openrouter", "or-6"),
92
+ model("openrouter", "or-7"),
93
+ model("synthetic", "syn-1"),
94
+ model("synthetic", "syn-2"),
95
+ model("synthetic", "syn-3"),
96
+ ]);
97
+
98
+ expect(notify).toHaveBeenCalledOnce();
99
+ const message = notify.mock.calls[0][0];
100
+ expect(message).toContain(
101
+ "openrouter: or-1, or-2, or-3, or-4, or-5, 2 more",
102
+ );
103
+ expect(message).not.toContain("or-6");
104
+ expect(message).not.toContain("or-7");
105
+ expect(message).toContain("synthetic: syn-2, syn-3");
106
+ });
107
+
108
+ test("does not warn when all checked provider models exist", async () => {
109
+ fetchModels.mockResolvedValue([
110
+ { providerId: "synthetic", id: "foo" },
111
+ { providerId: "synthetic", id: "bar" },
112
+ ]);
113
+
114
+ const notify = await check([
115
+ model("synthetic", "foo"),
116
+ model("synthetic", "bar"),
117
+ ]);
118
+
119
+ expect(notify).not.toHaveBeenCalled();
120
+ });
121
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { getApiProvider } from "@mariozechner/pi-ai";
8
8
  import { configLoader } from "../lib/config";
9
- import { fetchGatewayModelIds } from "../lib/gateway";
9
+ import { fetchGatewayModels } from "../lib/gateway";
10
10
  import type {
11
11
  Api,
12
12
  AssistantMessageEventStream,
@@ -27,6 +27,8 @@ const APERTURE_PROVENANCE_HEADERS = {
27
27
  "X-Title": "npm:@aliou/pi-ts-aperture",
28
28
  };
29
29
 
30
+ const MAX_MISSING_MODELS_PER_PROVIDER = 5;
31
+
30
32
  function resolveProviderHeaders(models: Model<Api>[]): Record<string, string> {
31
33
  const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
32
34
  return {
@@ -87,23 +89,43 @@ export class ApertureRuntime {
87
89
  const config = configLoader.getConfig();
88
90
  if (config.checkGatewayModels.length === 0) return;
89
91
 
90
- const gatewayModelIds = await fetchGatewayModelIds(gatewayUrl);
91
- if (gatewayModelIds.length === 0) return;
92
+ const gatewayModels = await fetchGatewayModels(gatewayUrl);
93
+ if (gatewayModels.length === 0) return;
92
94
 
93
95
  const allModels = deps.getModels();
94
96
  const checkedProviders = new Set(config.checkGatewayModels);
97
+ const gatewayModelKeys = new Set(
98
+ gatewayModels.map((m) => `${m.providerId}:${m.id}`),
99
+ );
95
100
 
96
101
  const routedModels = allModels.filter((m) =>
97
102
  checkedProviders.has(m.provider),
98
103
  );
99
104
  const missingModels = routedModels.filter(
100
- (m) => !gatewayModelIds.includes(m.id),
105
+ (m) => !gatewayModelKeys.has(`${m.provider}:${m.id}`),
101
106
  );
102
107
 
103
108
  if (missingModels.length > 0) {
104
- const ids = missingModels.map((m) => m.id).join(", ");
109
+ const missingByProvider = new Map<string, Model<Api>[]>();
110
+ for (const model of missingModels) {
111
+ const providerModels = missingByProvider.get(model.provider) ?? [];
112
+ providerModels.push(model);
113
+ missingByProvider.set(model.provider, providerModels);
114
+ }
115
+
116
+ const summary = Array.from(missingByProvider.entries())
117
+ .map(([provider, models]) => {
118
+ const shownModels = models
119
+ .slice(0, MAX_MISSING_MODELS_PER_PROVIDER)
120
+ .map((m) => m.id);
121
+ const remainingCount = models.length - shownModels.length;
122
+ const more = remainingCount > 0 ? `, ${remainingCount} more` : "";
123
+ return `${provider}: ${shownModels.join(", ")}${more}`;
124
+ })
125
+ .join("; ");
126
+
105
127
  deps.notify(
106
- `[aperture] models not available on gateway: ${ids}. Add them to the gateway configuration.`,
128
+ `[aperture] models not available on gateway: ${summary}. Add them to the gateway configuration.`,
107
129
  "warning",
108
130
  );
109
131
  }
@@ -26,7 +26,14 @@ export async function checkApertureHealth(
26
26
  }
27
27
  }
28
28
 
29
- export async function fetchGatewayModelIds(baseUrl: string): Promise<string[]> {
29
+ export interface GatewayModel {
30
+ id: string;
31
+ providerId: string;
32
+ }
33
+
34
+ export async function fetchGatewayModels(
35
+ baseUrl: string,
36
+ ): Promise<GatewayModel[]> {
30
37
  const url = `${baseUrl.replace(/\/+$/, "")}/v1/models`;
31
38
  try {
32
39
  const res = await fetch(url, {
@@ -34,8 +41,20 @@ export async function fetchGatewayModelIds(baseUrl: string): Promise<string[]> {
34
41
  signal: AbortSignal.timeout(5000),
35
42
  });
36
43
  if (!res.ok) return [];
37
- const body = (await res.json()) as { data?: { id: string }[] };
38
- return body.data?.map((m) => m.id) ?? [];
44
+ const body = (await res.json()) as {
45
+ data?: {
46
+ id: string;
47
+ metadata?: { provider?: { id?: string } };
48
+ }[];
49
+ };
50
+ return (
51
+ body.data
52
+ ?.map((m) => ({
53
+ id: m.id,
54
+ providerId: m.metadata?.provider?.id ?? "",
55
+ }))
56
+ .filter((m) => m.providerId.length > 0) ?? []
57
+ );
39
58
  } catch {
40
59
  return [];
41
60
  }