@aliou/pi-neuralwatt 0.6.3 → 0.7.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 +3 -2
- package/schema.json +4 -0
- package/src/config.ts +25 -4
- package/src/extensions/command-quotas/command.ts +1 -1
- package/src/extensions/provider/index.ts +115 -20
- package/src/extensions/provider/models/hidden.ts +127 -0
- package/src/extensions/provider/models/index.ts +23 -0
- package/src/extensions/provider/models/legacy.ts +37 -0
- package/src/extensions/provider/{models.ts → models/public-models.ts} +25 -112
- package/src/extensions/provider/quota-store.ts +1 -1
- package/src/extensions/provider/rate-limit-error.ts +150 -0
- package/src/extensions/provider/sse-quotas.ts +84 -0
- package/src/extensions/provider/stream-simple.ts +138 -0
- package/src/{utils/quotas.ts → lib/neuralwatt-api.ts} +56 -7
- package/src/types/models-api.ts +53 -0
- package/src/types/quota-events.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-neuralwatt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@aliou/pi-utils-settings": "^0.17.0",
|
|
36
|
-
"@aliou/pi-utils-ui": "^0.4.0"
|
|
36
|
+
"@aliou/pi-utils-ui": "^0.4.0",
|
|
37
|
+
"@earendil-works/pi-ai": "$@earendil-works/pi-coding-agent"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
40
|
"@earendil-works/pi-coding-agent": "*",
|
package/schema.json
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
"NeuralwattConfig": {
|
|
6
6
|
"additionalProperties": false,
|
|
7
7
|
"properties": {
|
|
8
|
+
"includeHiddenModels": {
|
|
9
|
+
"description": "Include hidden Neuralwatt models discovered via the authenticated API.",
|
|
10
|
+
"type": "boolean"
|
|
11
|
+
},
|
|
8
12
|
"includeLegacyModelIds": {
|
|
9
13
|
"description": "Include legacy Neuralwatt model IDs in the model picker.",
|
|
10
14
|
"type": "boolean"
|
package/src/config.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface NeuralwattConfig {
|
|
|
30
30
|
subBarIntegration?: boolean;
|
|
31
31
|
/** Include legacy Neuralwatt model IDs in the model picker. */
|
|
32
32
|
includeLegacyModelIds?: boolean;
|
|
33
|
+
/** Include hidden Neuralwatt models discovered via the authenticated API. */
|
|
34
|
+
includeHiddenModels?: boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface ResolvedNeuralwattConfig {
|
|
@@ -37,6 +39,7 @@ export interface ResolvedNeuralwattConfig {
|
|
|
37
39
|
quotaWarnings: boolean;
|
|
38
40
|
subBarIntegration: boolean;
|
|
39
41
|
includeLegacyModelIds: boolean;
|
|
42
|
+
includeHiddenModels: boolean;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const DEFAULTS: ResolvedNeuralwattConfig = {
|
|
@@ -44,6 +47,7 @@ const DEFAULTS: ResolvedNeuralwattConfig = {
|
|
|
44
47
|
quotaWarnings: true,
|
|
45
48
|
subBarIntegration: true,
|
|
46
49
|
includeLegacyModelIds: false,
|
|
50
|
+
includeHiddenModels: false,
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
export const configLoader = new ConfigLoader<
|
|
@@ -157,19 +161,36 @@ export function registerNeuralwattSettings(
|
|
|
157
161
|
: "ignore",
|
|
158
162
|
values: ["include", "ignore"],
|
|
159
163
|
},
|
|
164
|
+
{
|
|
165
|
+
id: "includeHiddenModels",
|
|
166
|
+
label: "Hidden models",
|
|
167
|
+
description:
|
|
168
|
+
"Include Neuralwatt models that are accessible via API key but not advertised in the public model list",
|
|
169
|
+
currentValue:
|
|
170
|
+
(tabConfig?.includeHiddenModels ?? resolved.includeHiddenModels)
|
|
171
|
+
? "include"
|
|
172
|
+
: "ignore",
|
|
173
|
+
values: ["include", "ignore"],
|
|
174
|
+
},
|
|
160
175
|
],
|
|
161
176
|
},
|
|
162
177
|
];
|
|
163
178
|
},
|
|
164
179
|
onSettingChange: (id, newValue, config) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
180
|
+
// Non-feature toggles are handled first so they are not blocked by the
|
|
181
|
+
// loaded-features guard (they are managed directly by the provider).
|
|
169
182
|
if (id === "includeLegacyModelIds") {
|
|
170
183
|
return { ...config, includeLegacyModelIds: newValue === "include" };
|
|
171
184
|
}
|
|
172
185
|
|
|
186
|
+
if (id === "includeHiddenModels") {
|
|
187
|
+
return { ...config, includeHiddenModels: newValue === "include" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!getLoadedFeatures().has(id as NeuralwattFeatureId)) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
173
194
|
const enabled = newValue === "enabled";
|
|
174
195
|
switch (id) {
|
|
175
196
|
case "quotaCommand":
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
getAgentDir,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { getNeuralwattApiKey } from "../../lib/env";
|
|
7
|
-
import { fetchQuotas } from "../../
|
|
7
|
+
import { fetchQuotas } from "../../lib/neuralwatt-api";
|
|
8
8
|
import { QuotasComponent } from "./components/quotas-display";
|
|
9
9
|
|
|
10
10
|
function missingAuthMessage(): string {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { getApiProvider } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ProviderModelConfig,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import {
|
|
3
7
|
configLoader,
|
|
4
8
|
emitConfigUpdated,
|
|
@@ -9,23 +13,35 @@ import {
|
|
|
9
13
|
registerNeuralwattSettings,
|
|
10
14
|
} from "../../config";
|
|
11
15
|
import { getNeuralwattApiKey } from "../../lib/env";
|
|
16
|
+
import { fetchQuotas } from "../../lib/neuralwatt-api";
|
|
12
17
|
import type { NeuralwattQuotas } from "../../types/quota-api";
|
|
13
18
|
import {
|
|
14
19
|
NEURALWATT_QUOTAS_REQUEST_EVENT,
|
|
15
20
|
NEURALWATT_QUOTAS_UPDATED_EVENT,
|
|
16
21
|
type NeuralwattQuotasUpdatedPayload,
|
|
17
22
|
} from "../../types/quota-events";
|
|
18
|
-
import { fetchQuotas } from "../../utils/quotas";
|
|
19
23
|
import { normalizeNeuralwattContextOverflowError } from "./context-overflow";
|
|
20
|
-
import { getNeuralwattModels } from "./models";
|
|
24
|
+
import { getNeuralwattModels, loadHiddenModels } from "./models";
|
|
21
25
|
import { buildQuotasFromHeaders, fetchRequestedQuotas } from "./quota-store";
|
|
26
|
+
import {
|
|
27
|
+
type NeuralwattRateLimitInfo,
|
|
28
|
+
normalizeNeuralwattRateLimitError,
|
|
29
|
+
parseRateLimitHeaders,
|
|
30
|
+
} from "./rate-limit-error";
|
|
31
|
+
import { updateQuotasFromSseComment } from "./sse-quotas";
|
|
32
|
+
import { wrapNeuralwattStreamSimple } from "./stream-simple";
|
|
22
33
|
|
|
23
34
|
const HEADER_EMIT_THROTTLE_MS = 5_000;
|
|
24
35
|
|
|
25
|
-
function registerNeuralwattProvider(
|
|
26
|
-
|
|
36
|
+
function registerNeuralwattProvider(
|
|
37
|
+
pi: ExtensionAPI,
|
|
38
|
+
onSseQuota: (line: string) => void,
|
|
39
|
+
hiddenModels: ProviderModelConfig[] = [],
|
|
40
|
+
): void {
|
|
41
|
+
const { includeLegacyModelIds, includeHiddenModels } =
|
|
42
|
+
configLoader.getConfig();
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
const config: Parameters<ExtensionAPI["registerProvider"]>[1] = {
|
|
29
45
|
baseUrl: "https://api.neuralwatt.com/v1",
|
|
30
46
|
apiKey: "$NEURALWATT_API_KEY",
|
|
31
47
|
api: "openai-completions",
|
|
@@ -34,16 +50,42 @@ function registerNeuralwattProvider(pi: ExtensionAPI): void {
|
|
|
34
50
|
Referer: "https://pi.dev",
|
|
35
51
|
"X-Title": "npm:@aliou/pi-neuralwatt",
|
|
36
52
|
},
|
|
37
|
-
models:
|
|
38
|
-
includeLegacyModelIds,
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
models: [
|
|
54
|
+
...getNeuralwattModels({ includeLegacyModelIds }),
|
|
55
|
+
...(includeHiddenModels ? hiddenModels : []),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const provider = getApiProvider("openai-completions");
|
|
60
|
+
const baseStreamSimple = provider?.streamSimple;
|
|
61
|
+
if (baseStreamSimple) {
|
|
62
|
+
config.streamSimple = wrapNeuralwattStreamSimple(
|
|
63
|
+
baseStreamSimple as never,
|
|
64
|
+
onSseQuota,
|
|
65
|
+
) as never;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pi.registerProvider("neuralwatt", config);
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
export default async function (pi: ExtensionAPI) {
|
|
44
72
|
await configLoader.load();
|
|
45
73
|
|
|
46
|
-
|
|
74
|
+
let latestQuotas: NeuralwattQuotas | undefined;
|
|
75
|
+
let hiddenModels: ProviderModelConfig[] = [];
|
|
76
|
+
let hiddenModelsLoaded = false;
|
|
77
|
+
|
|
78
|
+
const handleSseQuota = (line: string) => {
|
|
79
|
+
const quotas = updateQuotasFromSseComment(latestQuotas, line);
|
|
80
|
+
if (!quotas || quotas === latestQuotas) return;
|
|
81
|
+
latestQuotas = quotas;
|
|
82
|
+
pi.events.emit(NEURALWATT_QUOTAS_UPDATED_EVENT, {
|
|
83
|
+
quotas,
|
|
84
|
+
source: "sse",
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
registerNeuralwattProvider(pi, handleSseQuota);
|
|
47
89
|
|
|
48
90
|
const loadedFeatures = new Set<NeuralwattFeatureId>();
|
|
49
91
|
|
|
@@ -53,7 +95,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
53
95
|
});
|
|
54
96
|
|
|
55
97
|
pi.events.on(NEURALWATT_CONFIG_UPDATED_EVENT, () => {
|
|
56
|
-
registerNeuralwattProvider(pi);
|
|
98
|
+
registerNeuralwattProvider(pi, handleSseQuota, hiddenModels);
|
|
57
99
|
});
|
|
58
100
|
|
|
59
101
|
let lastHeaderEmitAt = 0;
|
|
@@ -67,20 +109,67 @@ export default async function (pi: ExtensionAPI) {
|
|
|
67
109
|
if (source === "header" && now - lastHeaderEmitAt < HEADER_EMIT_THROTTLE_MS)
|
|
68
110
|
return;
|
|
69
111
|
if (source === "header") lastHeaderEmitAt = now;
|
|
112
|
+
latestQuotas = quotas;
|
|
70
113
|
pi.events.emit(NEURALWATT_QUOTAS_UPDATED_EVENT, { quotas, source });
|
|
71
114
|
}
|
|
72
115
|
|
|
116
|
+
// Stored rate-limit info from the most recent 429 response.
|
|
117
|
+
// Used in message_end to rewrite the generic error text with
|
|
118
|
+
// actionable details from Neuralwatt's response headers.
|
|
119
|
+
let pendingRateLimitInfo: NeuralwattRateLimitInfo | undefined;
|
|
120
|
+
|
|
73
121
|
pi.on("message_end", (event, ctx) => {
|
|
74
|
-
|
|
122
|
+
// Rewrite rate-limit errors with layer-specific details
|
|
123
|
+
if (
|
|
124
|
+
pendingRateLimitInfo &&
|
|
125
|
+
event.message.role === "assistant" &&
|
|
126
|
+
event.message.stopReason === "error" &&
|
|
127
|
+
(event.message.provider === "neuralwatt" ||
|
|
128
|
+
ctx.model?.provider === "neuralwatt")
|
|
129
|
+
) {
|
|
130
|
+
const message = normalizeNeuralwattRateLimitError(
|
|
131
|
+
event.message,
|
|
132
|
+
pendingRateLimitInfo,
|
|
133
|
+
);
|
|
134
|
+
pendingRateLimitInfo = undefined;
|
|
135
|
+
return { message };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
event.message.role === "assistant" &&
|
|
140
|
+
event.message.stopReason === "error" &&
|
|
141
|
+
(event.message.provider === "neuralwatt" ||
|
|
142
|
+
ctx.model?.provider === "neuralwatt") &&
|
|
143
|
+
event.message.errorMessage?.includes("429")
|
|
144
|
+
) {
|
|
145
|
+
return {
|
|
146
|
+
message: normalizeNeuralwattRateLimitError(event.message, {
|
|
147
|
+
layer: "unknown",
|
|
148
|
+
detail:
|
|
149
|
+
"Neuralwatt rate limit reached, but Pi did not receive layer-specific rate-limit headers. Retry shortly.",
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Rewrite context overflow errors for Pi's native compaction
|
|
155
|
+
const overflowMessage = normalizeNeuralwattContextOverflowError(
|
|
75
156
|
event.message,
|
|
76
157
|
ctx.model?.provider,
|
|
77
158
|
);
|
|
78
|
-
if (!
|
|
79
|
-
return { message };
|
|
159
|
+
if (!overflowMessage) return;
|
|
160
|
+
return { message: overflowMessage };
|
|
80
161
|
});
|
|
81
162
|
|
|
82
163
|
pi.on("after_provider_response", (event, ctx) => {
|
|
83
164
|
if (ctx.model?.provider !== "neuralwatt") return;
|
|
165
|
+
|
|
166
|
+
// Capture rate-limit headers from 429 responses for message_end rewriting
|
|
167
|
+
if (event.status === 429) {
|
|
168
|
+
pendingRateLimitInfo = parseRateLimitHeaders(event.headers);
|
|
169
|
+
} else {
|
|
170
|
+
pendingRateLimitInfo = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
84
173
|
const quotas = buildQuotasFromHeaders(event.headers);
|
|
85
174
|
if (!quotas) return;
|
|
86
175
|
emitQuotas(quotas, "header");
|
|
@@ -91,11 +180,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
91
180
|
quotaRequestInFlight = true;
|
|
92
181
|
try {
|
|
93
182
|
const quotas = await fetchRequestedQuotas(data);
|
|
94
|
-
if (quotas)
|
|
95
|
-
pi.events.emit(NEURALWATT_QUOTAS_UPDATED_EVENT, {
|
|
96
|
-
quotas,
|
|
97
|
-
source: "api",
|
|
98
|
-
});
|
|
183
|
+
if (quotas) emitQuotas(quotas, "api");
|
|
99
184
|
} finally {
|
|
100
185
|
quotaRequestInFlight = false;
|
|
101
186
|
}
|
|
@@ -107,6 +192,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
107
192
|
});
|
|
108
193
|
|
|
109
194
|
pi.on("session_start", async (_event, ctx) => {
|
|
195
|
+
pendingRateLimitInfo = undefined;
|
|
110
196
|
for (const message of configLoader.drainMessages()) {
|
|
111
197
|
ctx.ui.notify(message, "warning");
|
|
112
198
|
}
|
|
@@ -115,6 +201,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
115
201
|
pi.events.emit(NEURALWATT_EXTENSIONS_REQUEST_EVENT, undefined);
|
|
116
202
|
emitConfigUpdated(pi);
|
|
117
203
|
|
|
204
|
+
if (!hiddenModelsLoaded && configLoader.getConfig().includeHiddenModels) {
|
|
205
|
+
hiddenModelsLoaded = true;
|
|
206
|
+
const fetched = await loadHiddenModels(ctx.modelRegistry.authStorage);
|
|
207
|
+
if (fetched.length > 0) {
|
|
208
|
+
hiddenModels = fetched;
|
|
209
|
+
registerNeuralwattProvider(pi, handleSseQuota, hiddenModels);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
118
213
|
if (ctx.model?.provider !== "neuralwatt") return;
|
|
119
214
|
const apiKey = await getNeuralwattApiKey(ctx.modelRegistry.authStorage);
|
|
120
215
|
if (!apiKey) return;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthStorage,
|
|
3
|
+
ProviderModelConfig,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { getNeuralwattApiKey } from "../../../lib/env";
|
|
6
|
+
import { fetchNeuralwattModels } from "../../../lib/neuralwatt-api";
|
|
7
|
+
import type { NeuralwattApiModel } from "../../../types/models-api";
|
|
8
|
+
import { NEURALWATT_MODELS } from "./public-models";
|
|
9
|
+
|
|
10
|
+
// Per-ID overrides for known hidden models. The authenticated /v1/models endpoint
|
|
11
|
+
// exposes pricing and capabilities, but some Pi-specific behavior (thinking levels,
|
|
12
|
+
// compat flags) has to be supplied by hand.
|
|
13
|
+
const HIDDEN_MODEL_OVERRIDES: Partial<
|
|
14
|
+
Record<string, Partial<ProviderModelConfig>>
|
|
15
|
+
> = {
|
|
16
|
+
"glm-5.2-short": {
|
|
17
|
+
thinkingLevelMap: {
|
|
18
|
+
minimal: null,
|
|
19
|
+
low: null,
|
|
20
|
+
medium: null,
|
|
21
|
+
high: "high",
|
|
22
|
+
xhigh: "max",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function buildHiddenModel(apiModel: NeuralwattApiModel): ProviderModelConfig {
|
|
28
|
+
const meta = apiModel.metadata;
|
|
29
|
+
const reasoning = meta?.capabilities.reasoning ?? false;
|
|
30
|
+
const override = HIDDEN_MODEL_OVERRIDES[apiModel.id];
|
|
31
|
+
|
|
32
|
+
const compat: NonNullable<ProviderModelConfig["compat"]> = {
|
|
33
|
+
supportsDeveloperRole: false,
|
|
34
|
+
maxTokensField: "max_tokens",
|
|
35
|
+
};
|
|
36
|
+
if (reasoning) {
|
|
37
|
+
compat.requiresReasoningContentOnAssistantMessages = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const model: ProviderModelConfig = {
|
|
41
|
+
id: apiModel.id,
|
|
42
|
+
name: meta?.display_name ?? apiModel.id,
|
|
43
|
+
reasoning,
|
|
44
|
+
input: (meta?.capabilities.vision ? ["text", "image"] : ["text"]) as (
|
|
45
|
+
| "text"
|
|
46
|
+
| "image"
|
|
47
|
+
)[],
|
|
48
|
+
cost: {
|
|
49
|
+
input: meta?.pricing.input_per_million ?? 0,
|
|
50
|
+
output: meta?.pricing.output_per_million ?? 0,
|
|
51
|
+
cacheRead: meta?.pricing.cached_input_per_million ?? 0,
|
|
52
|
+
cacheWrite: meta?.pricing.cached_output_per_million ?? 0,
|
|
53
|
+
},
|
|
54
|
+
contextWindow: apiModel.max_model_len,
|
|
55
|
+
maxTokens: meta?.limits.max_output_tokens ?? 65536,
|
|
56
|
+
compat,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (reasoning) {
|
|
60
|
+
model.thinkingLevelMap = override?.thinkingLevelMap ?? {
|
|
61
|
+
minimal: null,
|
|
62
|
+
low: null,
|
|
63
|
+
medium: "medium",
|
|
64
|
+
high: null,
|
|
65
|
+
xhigh: null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (override) {
|
|
70
|
+
return applyHiddenOverride(model, override);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return model;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyHiddenOverride(
|
|
77
|
+
model: ProviderModelConfig,
|
|
78
|
+
override: Partial<ProviderModelConfig>,
|
|
79
|
+
): ProviderModelConfig {
|
|
80
|
+
const result: ProviderModelConfig = { ...model };
|
|
81
|
+
|
|
82
|
+
if (override.name !== undefined) result.name = override.name;
|
|
83
|
+
if (override.reasoning !== undefined) result.reasoning = override.reasoning;
|
|
84
|
+
if (override.input !== undefined) result.input = override.input;
|
|
85
|
+
if (override.thinkingLevelMap !== undefined) {
|
|
86
|
+
result.thinkingLevelMap = override.thinkingLevelMap;
|
|
87
|
+
}
|
|
88
|
+
if (override.contextWindow !== undefined) {
|
|
89
|
+
result.contextWindow = override.contextWindow;
|
|
90
|
+
}
|
|
91
|
+
if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
|
|
92
|
+
if (override.cost !== undefined) {
|
|
93
|
+
result.cost = { ...model.cost, ...override.cost };
|
|
94
|
+
}
|
|
95
|
+
if (override.compat !== undefined) {
|
|
96
|
+
result.compat = { ...model.compat, ...override.compat };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Load hidden models from the authenticated /v1/models endpoint.
|
|
104
|
+
*
|
|
105
|
+
* Hidden models are any models returned by the API that are not already part of
|
|
106
|
+
* the public hardcoded list. If the API key is missing or the request fails, an
|
|
107
|
+
* empty array is returned silently.
|
|
108
|
+
*/
|
|
109
|
+
export async function loadHiddenModels(
|
|
110
|
+
authStorage: AuthStorage,
|
|
111
|
+
): Promise<ProviderModelConfig[]> {
|
|
112
|
+
const apiKey = await getNeuralwattApiKey(authStorage);
|
|
113
|
+
if (!apiKey) return [];
|
|
114
|
+
|
|
115
|
+
const result = await fetchNeuralwattModels(apiKey);
|
|
116
|
+
if (!result.success) return [];
|
|
117
|
+
|
|
118
|
+
const publicIds = new Set(NEURALWATT_MODELS.map((model) => model.id));
|
|
119
|
+
|
|
120
|
+
return result.data
|
|
121
|
+
.filter(
|
|
122
|
+
(model) =>
|
|
123
|
+
!model.metadata?.deprecated && !model.metadata?.pricing.pricing_tbd,
|
|
124
|
+
)
|
|
125
|
+
.filter((model) => !publicIds.has(model.id))
|
|
126
|
+
.map(buildHiddenModel);
|
|
127
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { buildLegacyNeuralwattModels } from "./legacy";
|
|
3
|
+
import { NEURALWATT_MODELS } from "./public-models";
|
|
4
|
+
|
|
5
|
+
export { loadHiddenModels } from "./hidden";
|
|
6
|
+
export {
|
|
7
|
+
buildLegacyNeuralwattModels,
|
|
8
|
+
LEGACY_MODEL_ALIAS_MAP,
|
|
9
|
+
LEGACY_NEURALWATT_MODEL_IDS,
|
|
10
|
+
} from "./legacy";
|
|
11
|
+
export { NEURALWATT_MODELS } from "./public-models";
|
|
12
|
+
|
|
13
|
+
export function getNeuralwattModels(options?: {
|
|
14
|
+
includeLegacyModelIds?: boolean;
|
|
15
|
+
}): ProviderModelConfig[] {
|
|
16
|
+
const models: ProviderModelConfig[] = [...NEURALWATT_MODELS];
|
|
17
|
+
|
|
18
|
+
if (options?.includeLegacyModelIds) {
|
|
19
|
+
models.push(...buildLegacyNeuralwattModels());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return models;
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { NEURALWATT_MODELS } from "./public-models";
|
|
3
|
+
|
|
4
|
+
// Legacy model IDs that should resolve to a canonical public model.
|
|
5
|
+
// These are phased out over time and are only included when `includeLegacyModelIds` is enabled.
|
|
6
|
+
export const LEGACY_MODEL_ALIAS_MAP = {
|
|
7
|
+
"glm-5.1": "glm-5.2",
|
|
8
|
+
"glm-5.1-fast": "glm-5.2-fast",
|
|
9
|
+
"zai-org/GLM-5.1-FP8": "glm-5.2",
|
|
10
|
+
"moonshotai/Kimi-K2.6": "kimi-k2.6",
|
|
11
|
+
"Qwen/Qwen3.5-397B-A17B-FP8": "qwen3.5-397b",
|
|
12
|
+
"Qwen/Qwen3.6-35B-A3B": "qwen3.6-35b",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const LEGACY_NEURALWATT_MODEL_IDS = new Set<string>(
|
|
16
|
+
Object.keys(LEGACY_MODEL_ALIAS_MAP),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function buildLegacyNeuralwattModels(): ProviderModelConfig[] {
|
|
20
|
+
return Object.entries(LEGACY_MODEL_ALIAS_MAP).map(
|
|
21
|
+
([legacyId, canonicalId]) => {
|
|
22
|
+
const canonical = NEURALWATT_MODELS.find(
|
|
23
|
+
(model) => model.id === canonicalId,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!canonical) {
|
|
27
|
+
throw new Error(`Missing canonical model for legacy alias ${legacyId}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...canonical,
|
|
32
|
+
id: legacyId,
|
|
33
|
+
name: `${canonical.name} (legacy ID)`,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -1,49 +1,28 @@
|
|
|
1
|
-
// Hardcoded models from Neuralwatt API
|
|
2
|
-
// Source: https://api.neuralwatt.com/v1/models
|
|
3
|
-
// Pricing, capabilities, and limits from metadata fields in /v1/models
|
|
4
|
-
|
|
5
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
6
2
|
|
|
3
|
+
// Public models returned by https://api.neuralwatt.com/v1/models (unauthenticated view).
|
|
4
|
+
// Pricing, capabilities, and limits are sourced from the API metadata fields.
|
|
7
5
|
export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
8
|
-
// GLM-5
|
|
9
|
-
{
|
|
10
|
-
id: "glm-5-fast",
|
|
11
|
-
name: "GLM-5 Fast",
|
|
12
|
-
reasoning: false,
|
|
13
|
-
input: ["text"],
|
|
14
|
-
cost: {
|
|
15
|
-
input: 1.1,
|
|
16
|
-
output: 3.6,
|
|
17
|
-
cacheRead: 0,
|
|
18
|
-
cacheWrite: 0,
|
|
19
|
-
},
|
|
20
|
-
contextWindow: 202736,
|
|
21
|
-
maxTokens: 65536,
|
|
22
|
-
compat: {
|
|
23
|
-
supportsDeveloperRole: false,
|
|
24
|
-
maxTokensField: "max_tokens",
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
// GLM-5.1 - ZhipuAI
|
|
6
|
+
// GLM-5.2 - ZhipuAI
|
|
28
7
|
{
|
|
29
|
-
id: "glm-5.
|
|
30
|
-
name: "GLM-5.
|
|
8
|
+
id: "glm-5.2",
|
|
9
|
+
name: "GLM-5.2",
|
|
31
10
|
reasoning: true,
|
|
32
11
|
input: ["text"],
|
|
33
12
|
cost: {
|
|
34
|
-
input: 1.
|
|
35
|
-
output:
|
|
36
|
-
cacheRead: 0,
|
|
13
|
+
input: 1.45,
|
|
14
|
+
output: 4.5,
|
|
15
|
+
cacheRead: 0.3625,
|
|
37
16
|
cacheWrite: 0,
|
|
38
17
|
},
|
|
39
|
-
contextWindow:
|
|
18
|
+
contextWindow: 1048560,
|
|
40
19
|
maxTokens: 65536,
|
|
41
20
|
thinkingLevelMap: {
|
|
42
21
|
minimal: null,
|
|
43
22
|
low: null,
|
|
44
|
-
medium:
|
|
45
|
-
high:
|
|
46
|
-
xhigh:
|
|
23
|
+
medium: null,
|
|
24
|
+
high: "high",
|
|
25
|
+
xhigh: "max",
|
|
47
26
|
},
|
|
48
27
|
compat: {
|
|
49
28
|
supportsDeveloperRole: false,
|
|
@@ -51,53 +30,23 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
51
30
|
requiresReasoningContentOnAssistantMessages: true,
|
|
52
31
|
},
|
|
53
32
|
},
|
|
54
|
-
// GLM-5.
|
|
33
|
+
// GLM-5.2 Fast - ZhipuAI
|
|
55
34
|
{
|
|
56
|
-
id: "glm-5.
|
|
57
|
-
name: "GLM-5.
|
|
35
|
+
id: "glm-5.2-fast",
|
|
36
|
+
name: "GLM-5.2 Fast",
|
|
58
37
|
reasoning: false,
|
|
59
38
|
input: ["text"],
|
|
60
|
-
cost: {
|
|
61
|
-
input: 1.1,
|
|
62
|
-
output: 3.6,
|
|
63
|
-
cacheRead: 0,
|
|
64
|
-
cacheWrite: 0,
|
|
65
|
-
},
|
|
66
|
-
contextWindow: 202736,
|
|
67
|
-
maxTokens: 65536,
|
|
68
|
-
compat: {
|
|
69
|
-
supportsDeveloperRole: false,
|
|
70
|
-
maxTokensField: "max_tokens",
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
// GLM-5.2 - ZhipuAI (test canary)
|
|
74
|
-
{
|
|
75
|
-
id: "glm-5.2",
|
|
76
|
-
name: "GLM-5.2",
|
|
77
|
-
reasoning: true,
|
|
78
|
-
input: ["text"],
|
|
79
39
|
cost: {
|
|
80
40
|
input: 1.45,
|
|
81
41
|
output: 4.5,
|
|
82
|
-
cacheRead: 0,
|
|
42
|
+
cacheRead: 0.3625,
|
|
83
43
|
cacheWrite: 0,
|
|
84
44
|
},
|
|
85
45
|
contextWindow: 1048560,
|
|
86
46
|
maxTokens: 65536,
|
|
87
|
-
// GLM-5.2 has two native reasoning depths (high, max) plus thinking-off.
|
|
88
|
-
// Pi levels below high disable thinking; high -> high, xhigh -> max.
|
|
89
|
-
// See https://portal.neuralwatt.com/docs/api/chat-completions#reasoning-effort
|
|
90
|
-
thinkingLevelMap: {
|
|
91
|
-
minimal: null,
|
|
92
|
-
low: null,
|
|
93
|
-
medium: null,
|
|
94
|
-
high: "high",
|
|
95
|
-
xhigh: "max",
|
|
96
|
-
},
|
|
97
47
|
compat: {
|
|
98
48
|
supportsDeveloperRole: false,
|
|
99
49
|
maxTokensField: "max_tokens",
|
|
100
|
-
requiresReasoningContentOnAssistantMessages: true,
|
|
101
50
|
},
|
|
102
51
|
},
|
|
103
52
|
// Kimi K2.5 - MoonshotAI
|
|
@@ -109,7 +58,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
109
58
|
cost: {
|
|
110
59
|
input: 0.52,
|
|
111
60
|
output: 2.59,
|
|
112
|
-
cacheRead: 0,
|
|
61
|
+
cacheRead: 0.13,
|
|
113
62
|
cacheWrite: 0,
|
|
114
63
|
},
|
|
115
64
|
contextWindow: 262128,
|
|
@@ -136,7 +85,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
136
85
|
cost: {
|
|
137
86
|
input: 0.52,
|
|
138
87
|
output: 2.59,
|
|
139
|
-
cacheRead: 0,
|
|
88
|
+
cacheRead: 0.13,
|
|
140
89
|
cacheWrite: 0,
|
|
141
90
|
},
|
|
142
91
|
contextWindow: 262128,
|
|
@@ -155,7 +104,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
155
104
|
cost: {
|
|
156
105
|
input: 0.69,
|
|
157
106
|
output: 3.22,
|
|
158
|
-
cacheRead: 0,
|
|
107
|
+
cacheRead: 0.1725,
|
|
159
108
|
cacheWrite: 0,
|
|
160
109
|
},
|
|
161
110
|
contextWindow: 262128,
|
|
@@ -182,7 +131,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
182
131
|
cost: {
|
|
183
132
|
input: 0.69,
|
|
184
133
|
output: 3.22,
|
|
185
|
-
cacheRead: 0,
|
|
134
|
+
cacheRead: 0.1725,
|
|
186
135
|
cacheWrite: 0,
|
|
187
136
|
},
|
|
188
137
|
contextWindow: 262128,
|
|
@@ -201,7 +150,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
201
150
|
cost: {
|
|
202
151
|
input: 0.69,
|
|
203
152
|
output: 4.14,
|
|
204
|
-
cacheRead: 0,
|
|
153
|
+
cacheRead: 0.1725,
|
|
205
154
|
cacheWrite: 0,
|
|
206
155
|
},
|
|
207
156
|
contextWindow: 262128,
|
|
@@ -228,7 +177,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
228
177
|
cost: {
|
|
229
178
|
input: 0.69,
|
|
230
179
|
output: 4.14,
|
|
231
|
-
cacheRead: 0,
|
|
180
|
+
cacheRead: 0.1725,
|
|
232
181
|
cacheWrite: 0,
|
|
233
182
|
},
|
|
234
183
|
contextWindow: 262128,
|
|
@@ -247,7 +196,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
247
196
|
cost: {
|
|
248
197
|
input: 0.29,
|
|
249
198
|
output: 1.15,
|
|
250
|
-
cacheRead: 0,
|
|
199
|
+
cacheRead: 0.0725,
|
|
251
200
|
cacheWrite: 0,
|
|
252
201
|
},
|
|
253
202
|
contextWindow: 131056,
|
|
@@ -274,7 +223,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
274
223
|
cost: {
|
|
275
224
|
input: 0.95,
|
|
276
225
|
output: 4.0,
|
|
277
|
-
cacheRead: 0,
|
|
226
|
+
cacheRead: 0.2375,
|
|
278
227
|
cacheWrite: 0,
|
|
279
228
|
},
|
|
280
229
|
contextWindow: 262128,
|
|
@@ -302,7 +251,7 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
302
251
|
cost: {
|
|
303
252
|
input: 0.29,
|
|
304
253
|
output: 1.15,
|
|
305
|
-
cacheRead: 0,
|
|
254
|
+
cacheRead: 0.0725,
|
|
306
255
|
cacheWrite: 0,
|
|
307
256
|
},
|
|
308
257
|
contextWindow: 131056,
|
|
@@ -313,39 +262,3 @@ export const NEURALWATT_MODELS: ProviderModelConfig[] = [
|
|
|
313
262
|
},
|
|
314
263
|
},
|
|
315
264
|
];
|
|
316
|
-
|
|
317
|
-
const LEGACY_MODEL_ALIAS_MAP = {
|
|
318
|
-
"zai-org/GLM-5.1-FP8": "glm-5.1",
|
|
319
|
-
"moonshotai/Kimi-K2.6": "kimi-k2.6",
|
|
320
|
-
"Qwen/Qwen3.5-397B-A17B-FP8": "qwen3.5-397b",
|
|
321
|
-
"Qwen/Qwen3.6-35B-A3B": "qwen3.6-35b",
|
|
322
|
-
} as const;
|
|
323
|
-
|
|
324
|
-
export const LEGACY_NEURALWATT_MODEL_IDS = new Set<string>(
|
|
325
|
-
Object.keys(LEGACY_MODEL_ALIAS_MAP),
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
const LEGACY_NEURALWATT_MODELS: ProviderModelConfig[] = Object.entries(
|
|
329
|
-
LEGACY_MODEL_ALIAS_MAP,
|
|
330
|
-
).map(([legacyId, canonicalId]) => {
|
|
331
|
-
const canonical = NEURALWATT_MODELS.find((model) => model.id === canonicalId);
|
|
332
|
-
|
|
333
|
-
if (!canonical) {
|
|
334
|
-
throw new Error(`Missing canonical model for legacy alias ${legacyId}`);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
...canonical,
|
|
339
|
-
id: legacyId,
|
|
340
|
-
name: `${canonical.name} (legacy ID)`,
|
|
341
|
-
};
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
export function getNeuralwattModels(options?: {
|
|
345
|
-
includeLegacyModelIds?: boolean;
|
|
346
|
-
}): ProviderModelConfig[] {
|
|
347
|
-
if (options?.includeLegacyModelIds)
|
|
348
|
-
return [...NEURALWATT_MODELS, ...LEGACY_NEURALWATT_MODELS];
|
|
349
|
-
|
|
350
|
-
return NEURALWATT_MODELS;
|
|
351
|
-
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getNeuralwattApiKey } from "../../lib/env";
|
|
3
|
+
import { fetchQuotas } from "../../lib/neuralwatt-api";
|
|
3
4
|
import type { NeuralwattQuotas } from "../../types/quota-api";
|
|
4
5
|
import { parseQuotaHeaders } from "../../types/quota-events";
|
|
5
|
-
import { fetchQuotas } from "../../utils/quotas";
|
|
6
6
|
|
|
7
7
|
export function buildQuotasFromHeaders(
|
|
8
8
|
headers: Record<string, string>,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
interface AssistantErrorLike {
|
|
2
|
+
role: string;
|
|
3
|
+
stopReason?: string;
|
|
4
|
+
provider?: string;
|
|
5
|
+
errorMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parsed rate-limit info from Neuralwatt 429 response headers.
|
|
10
|
+
*
|
|
11
|
+
* Neuralwatt applies three independent rate-limit layers plus a legacy RPM
|
|
12
|
+
* layer. Each sets unique headers so the client can tell which layer
|
|
13
|
+
* triggered the rejection.
|
|
14
|
+
*
|
|
15
|
+
* @see https://portal.neuralwatt.com/docs/guides/rate-limits
|
|
16
|
+
*/
|
|
17
|
+
export interface NeuralwattRateLimitInfo {
|
|
18
|
+
/** Which rate-limit layer triggered the 429 */
|
|
19
|
+
layer: "concurrent" | "tpm" | "admission" | "rpm" | "unknown";
|
|
20
|
+
/** Seconds the server recommends waiting before retrying */
|
|
21
|
+
retryAfter?: number;
|
|
22
|
+
/** Human-readable details (varies per layer) */
|
|
23
|
+
detail: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Case-insensitive header lookup */
|
|
27
|
+
function getHeader(
|
|
28
|
+
headers: Record<string, string>,
|
|
29
|
+
name: string,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const entry = Object.entries(headers).find(
|
|
32
|
+
([key]) => key.toLowerCase() === name.toLowerCase(),
|
|
33
|
+
);
|
|
34
|
+
return entry?.[1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse Neuralwatt rate-limit headers from a 429 response.
|
|
39
|
+
*
|
|
40
|
+
* Returns `undefined` if no rate-limit-specific headers are found (e.g. the
|
|
41
|
+
* 429 came from a different proxy or middleware that doesn't set these
|
|
42
|
+
* headers).
|
|
43
|
+
*/
|
|
44
|
+
export function parseRateLimitHeaders(
|
|
45
|
+
headers: Record<string, string>,
|
|
46
|
+
): NeuralwattRateLimitInfo | undefined {
|
|
47
|
+
const retryAfterRaw = getHeader(headers, "Retry-After");
|
|
48
|
+
const retryAfter = retryAfterRaw
|
|
49
|
+
? Number.parseInt(retryAfterRaw, 10)
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
// 1. Concurrent-request limit
|
|
53
|
+
const concurrentDimension = getHeader(
|
|
54
|
+
headers,
|
|
55
|
+
"X-Concurrent-Limit-Dimension",
|
|
56
|
+
);
|
|
57
|
+
if (concurrentDimension) {
|
|
58
|
+
const active = getHeader(headers, "X-Concurrent-Limit-Active") ?? "?";
|
|
59
|
+
const max = getHeader(headers, "X-Concurrent-Limit-Max") ?? "?";
|
|
60
|
+
return {
|
|
61
|
+
layer: "concurrent",
|
|
62
|
+
retryAfter,
|
|
63
|
+
detail: `Concurrent request limit reached (${active}/${max} active, ${concurrentDimension}-scoped). Wait for an in-flight request to complete before retrying.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Input TPM limit
|
|
68
|
+
const tpmDimension = getHeader(headers, "X-TPM-Limit-Dimension");
|
|
69
|
+
if (tpmDimension) {
|
|
70
|
+
const tokens = getHeader(headers, "X-TPM-Limit-Tokens") ?? "?";
|
|
71
|
+
const max = getHeader(headers, "X-TPM-Limit-Max") ?? "?";
|
|
72
|
+
return {
|
|
73
|
+
layer: "tpm",
|
|
74
|
+
retryAfter,
|
|
75
|
+
detail: `Input token rate exceeded (${tokens}/${max} tokens/min, ${tpmDimension}-scoped). Wait before sending more requests.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. Admission control
|
|
80
|
+
const admissionDimension = getHeader(headers, "X-Admission-Dimension");
|
|
81
|
+
if (admissionDimension) {
|
|
82
|
+
const inFlight = getHeader(headers, "X-Admission-InFlight") ?? "?";
|
|
83
|
+
const threshold = getHeader(headers, "X-Admission-Threshold") ?? "?";
|
|
84
|
+
return {
|
|
85
|
+
layer: "admission",
|
|
86
|
+
retryAfter,
|
|
87
|
+
detail: `Backend at capacity (${inFlight}/${threshold} in-flight tokens, ${admissionDimension}-scoped). The server is busy — retry shortly.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Legacy RPM limit
|
|
92
|
+
const rpmLimit = getHeader(headers, "X-RateLimit-Limit");
|
|
93
|
+
if (rpmLimit) {
|
|
94
|
+
const remaining = getHeader(headers, "X-RateLimit-Remaining") ?? "?";
|
|
95
|
+
return {
|
|
96
|
+
layer: "rpm",
|
|
97
|
+
retryAfter,
|
|
98
|
+
detail: `Requests per minute exceeded (${remaining}/${rpmLimit} remaining). Wait before sending more requests.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Generic 429 with Retry-After but no layer-specific headers
|
|
103
|
+
if (retryAfter !== undefined) {
|
|
104
|
+
return {
|
|
105
|
+
layer: "unknown",
|
|
106
|
+
retryAfter,
|
|
107
|
+
detail: "Rate limited by the server. Wait before retrying.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a user-facing error message from a NeuralwattRateLimitInfo.
|
|
116
|
+
*/
|
|
117
|
+
function formatRateLimitError(info: NeuralwattRateLimitInfo): string {
|
|
118
|
+
const parts = [info.detail];
|
|
119
|
+
|
|
120
|
+
if (info.retryAfter !== undefined && info.retryAfter > 0) {
|
|
121
|
+
if (info.retryAfter < 60) {
|
|
122
|
+
parts.push(`Retry-After: ${info.retryAfter}s.`);
|
|
123
|
+
} else {
|
|
124
|
+
const mins = Math.ceil(info.retryAfter / 60);
|
|
125
|
+
parts.push(`Retry-After: ~${mins} min.`);
|
|
126
|
+
}
|
|
127
|
+
} else if (info.retryAfter === 0 && info.layer === "concurrent") {
|
|
128
|
+
// Retry-After: 0 means: retry as soon as a slot frees
|
|
129
|
+
parts.push("Retry immediately after an in-flight request completes.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return `429 rate limit: ${parts.join(" ")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize Neuralwatt rate-limit errors so the user sees which layer
|
|
137
|
+
* triggered the 429 and what to do about it.
|
|
138
|
+
*
|
|
139
|
+
* Without this, Pi shows a generic "Too Many Requests" because the
|
|
140
|
+
* Neuralwatt 429 response body is empty — all diagnostics are in the
|
|
141
|
+
* response headers.
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeNeuralwattRateLimitError<
|
|
144
|
+
TMessage extends AssistantErrorLike,
|
|
145
|
+
>(message: TMessage, rateLimitInfo: NeuralwattRateLimitInfo): TMessage {
|
|
146
|
+
return {
|
|
147
|
+
...message,
|
|
148
|
+
errorMessage: formatRateLimitError(rateLimitInfo),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { NeuralwattQuotas } from "../../types/quota-api";
|
|
2
|
+
|
|
3
|
+
const JOULES_PER_KWH = 3_600_000;
|
|
4
|
+
|
|
5
|
+
export function updateQuotasFromSseComment(
|
|
6
|
+
quotas: NeuralwattQuotas | undefined,
|
|
7
|
+
line: string,
|
|
8
|
+
): NeuralwattQuotas | undefined {
|
|
9
|
+
if (!quotas) return;
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
const next = structuredClone(quotas);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (trimmed.startsWith(": energy ")) {
|
|
15
|
+
const energy = JSON.parse(trimmed.slice(9)) as { energy_joules?: number };
|
|
16
|
+
const energyKwh = (energy.energy_joules ?? 0) / JOULES_PER_KWH;
|
|
17
|
+
if (energyKwh <= 0) return quotas;
|
|
18
|
+
next.usage.current_month.energy_kwh += energyKwh;
|
|
19
|
+
next.usage.lifetime.energy_kwh += energyKwh;
|
|
20
|
+
if (next.subscription) {
|
|
21
|
+
next.subscription.kwh_used += energyKwh;
|
|
22
|
+
next.subscription.kwh_remaining = Math.max(
|
|
23
|
+
0,
|
|
24
|
+
next.subscription.kwh_remaining - energyKwh,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
next.snapshot_at = new Date().toISOString();
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (trimmed.startsWith(": cost ")) {
|
|
32
|
+
const cost = JSON.parse(trimmed.slice(7)) as {
|
|
33
|
+
request_cost_usd?: number;
|
|
34
|
+
};
|
|
35
|
+
const requestCostUsd = cost.request_cost_usd ?? 0;
|
|
36
|
+
if (requestCostUsd <= 0) return quotas;
|
|
37
|
+
next.balance.credits_remaining_usd = Math.max(
|
|
38
|
+
0,
|
|
39
|
+
next.balance.credits_remaining_usd - requestCostUsd,
|
|
40
|
+
);
|
|
41
|
+
next.balance.credits_used_usd += requestCostUsd;
|
|
42
|
+
next.usage.current_month.cost_usd += requestCostUsd;
|
|
43
|
+
next.usage.lifetime.cost_usd += requestCostUsd;
|
|
44
|
+
next.snapshot_at = new Date().toISOString();
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return quotas;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return quotas;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readQuotaCommentsFromTee(
|
|
55
|
+
body: ReadableStream<Uint8Array>,
|
|
56
|
+
onComment: (line: string) => void,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const reader = body.getReader();
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
let buffer = "";
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done) break;
|
|
66
|
+
buffer += decoder.decode(value, { stream: true });
|
|
67
|
+
const lines = buffer.split("\n");
|
|
68
|
+
buffer = lines.pop() ?? "";
|
|
69
|
+
for (const line of lines) onComment(line);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const final = decoder.decode(new Uint8Array(0), { stream: false });
|
|
73
|
+
const remaining = (buffer + final).trim();
|
|
74
|
+
if (remaining) onComment(remaining);
|
|
75
|
+
} catch {
|
|
76
|
+
// The SDK side may abort the tee; quota comments are best-effort.
|
|
77
|
+
} finally {
|
|
78
|
+
try {
|
|
79
|
+
reader.releaseLock();
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Neuralwatt is OpenAI-compatible, but the OpenAI SDK throws on non-2xx
|
|
2
|
+
// responses before Pi's after_provider_response hook can see the raw headers.
|
|
3
|
+
// We wrap the built-in openai-completions streamSimple so 429 rate-limit
|
|
4
|
+
// headers can be captured before the SDK turns them into a generic error, while
|
|
5
|
+
// still delegating normal streaming behavior to Pi's provider implementation.
|
|
6
|
+
//
|
|
7
|
+
// The SSE tee used for live quota comments is inspired by:
|
|
8
|
+
// https://github.com/monotykamary/pi-neuralwatt-provider
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AssistantMessageEventStream,
|
|
12
|
+
type Context,
|
|
13
|
+
createAssistantMessageEventStream,
|
|
14
|
+
type Model,
|
|
15
|
+
type SimpleStreamOptions,
|
|
16
|
+
} from "@earendil-works/pi-ai";
|
|
17
|
+
import {
|
|
18
|
+
type NeuralwattRateLimitInfo,
|
|
19
|
+
normalizeNeuralwattRateLimitError,
|
|
20
|
+
parseRateLimitHeaders,
|
|
21
|
+
} from "./rate-limit-error";
|
|
22
|
+
import { readQuotaCommentsFromTee } from "./sse-quotas";
|
|
23
|
+
|
|
24
|
+
export type AnyStreamSimple = (
|
|
25
|
+
model: Model<string>,
|
|
26
|
+
context: Context,
|
|
27
|
+
options?: SimpleStreamOptions,
|
|
28
|
+
) => AssistantMessageEventStream;
|
|
29
|
+
|
|
30
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
31
|
+
const record: Record<string, string> = {};
|
|
32
|
+
headers.forEach((value, key) => {
|
|
33
|
+
record[key] = value;
|
|
34
|
+
});
|
|
35
|
+
return record;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isProviderChatCompletionsUrl(
|
|
39
|
+
input: RequestInfo | URL,
|
|
40
|
+
providerOrigin: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
const rawUrl =
|
|
43
|
+
typeof input === "string"
|
|
44
|
+
? input
|
|
45
|
+
: input instanceof URL
|
|
46
|
+
? input.toString()
|
|
47
|
+
: input.url;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(rawUrl);
|
|
51
|
+
return (
|
|
52
|
+
url.origin === providerOrigin &&
|
|
53
|
+
url.pathname.endsWith("/chat/completions")
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function forwardStream(
|
|
61
|
+
stream: AssistantMessageEventStream,
|
|
62
|
+
outer: AssistantMessageEventStream,
|
|
63
|
+
getRateLimitInfo: () => NeuralwattRateLimitInfo | undefined,
|
|
64
|
+
restoreFetch: () => void,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
for await (const event of stream) {
|
|
68
|
+
const rateLimitInfo = getRateLimitInfo();
|
|
69
|
+
if (event.type === "error" && rateLimitInfo) {
|
|
70
|
+
outer.push({
|
|
71
|
+
...event,
|
|
72
|
+
error: normalizeNeuralwattRateLimitError(event.error, rateLimitInfo),
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
outer.push(event);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
restoreFetch();
|
|
80
|
+
outer.end();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function wrapNeuralwattStreamSimple(
|
|
85
|
+
base: AnyStreamSimple,
|
|
86
|
+
onSseQuota: (line: string) => void,
|
|
87
|
+
): AnyStreamSimple {
|
|
88
|
+
return (model, context, options = {}) => {
|
|
89
|
+
let rateLimitInfo: NeuralwattRateLimitInfo | undefined;
|
|
90
|
+
let sseQuotaTask: Promise<void> | undefined;
|
|
91
|
+
const outer = createAssistantMessageEventStream();
|
|
92
|
+
const providerOrigin = new URL(
|
|
93
|
+
model.baseUrl ?? "https://api.neuralwatt.com/v1",
|
|
94
|
+
).origin;
|
|
95
|
+
const originalFetch = globalThis.fetch;
|
|
96
|
+
const wrappedFetch: typeof fetch = async (input, init) => {
|
|
97
|
+
const response = await originalFetch(input, init);
|
|
98
|
+
|
|
99
|
+
if (!isProviderChatCompletionsUrl(input, providerOrigin)) return response;
|
|
100
|
+
|
|
101
|
+
const headers = headersToRecord(response.headers);
|
|
102
|
+
if (response.status === 429) {
|
|
103
|
+
rateLimitInfo = parseRateLimitHeaders(headers);
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (response.ok && response.body) {
|
|
108
|
+
const [sdkBody, quotaBody] = response.body.tee();
|
|
109
|
+
sseQuotaTask = readQuotaCommentsFromTee(quotaBody, onSseQuota);
|
|
110
|
+
return new Response(sdkBody, {
|
|
111
|
+
headers: response.headers,
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return response;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
globalThis.fetch = wrappedFetch;
|
|
121
|
+
|
|
122
|
+
const restoreFetch = () => {
|
|
123
|
+
if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
|
|
124
|
+
sseQuotaTask?.catch(() => {});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const stream = base(model, context, options);
|
|
128
|
+
const originalOuterEnd = outer.end.bind(outer);
|
|
129
|
+
outer.end = (result?: Parameters<typeof originalOuterEnd>[0]) => {
|
|
130
|
+
restoreFetch();
|
|
131
|
+
originalOuterEnd(result);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
void forwardStream(stream, outer, () => rateLimitInfo, restoreFetch);
|
|
135
|
+
|
|
136
|
+
return outer;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NeuralwattApiModel,
|
|
3
|
+
NeuralwattApiModelsResponse,
|
|
4
|
+
} from "../types/models-api";
|
|
1
5
|
import type { NeuralwattQuotas } from "../types/quota-api";
|
|
2
6
|
import type { QuotasResult } from "../types/quota-events";
|
|
3
7
|
|
|
8
|
+
const BASE_URL = "https://api.neuralwatt.com/v1";
|
|
4
9
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
5
10
|
|
|
6
11
|
function isTimeoutReason(reason: unknown): boolean {
|
|
@@ -10,6 +15,55 @@ function isTimeoutReason(reason: unknown): boolean {
|
|
|
10
15
|
);
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
function combineSignals(signal?: AbortSignal): AbortSignal {
|
|
19
|
+
const signals: AbortSignal[] = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
|
|
20
|
+
if (signal) signals.push(signal);
|
|
21
|
+
return AbortSignal.any(signals);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function neuralwattFetch(
|
|
25
|
+
path: string,
|
|
26
|
+
apiKey: string,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
headers?: Record<string, string>,
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
return fetch(`${BASE_URL}${path}`, {
|
|
31
|
+
headers: { Authorization: `Bearer ${apiKey}`, ...headers },
|
|
32
|
+
signal: combineSignals(signal),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type NeuralwattModelsResult =
|
|
37
|
+
| { success: true; data: NeuralwattApiModel[] }
|
|
38
|
+
| { success: false };
|
|
39
|
+
|
|
40
|
+
export async function fetchNeuralwattModels(
|
|
41
|
+
apiKey: string,
|
|
42
|
+
signal?: AbortSignal,
|
|
43
|
+
): Promise<NeuralwattModelsResult> {
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
return { success: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const combined = combineSignals(signal);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const response = await neuralwattFetch("/models", apiKey, combined, {
|
|
52
|
+
Referer: "https://pi.dev",
|
|
53
|
+
"X-Title": "npm:@aliou/pi-neuralwatt",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
return { success: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data: NeuralwattApiModelsResponse = await response.json();
|
|
61
|
+
return { success: true, data: data.data };
|
|
62
|
+
} catch {
|
|
63
|
+
return { success: false };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
13
67
|
export async function fetchQuotas(
|
|
14
68
|
apiKey: string,
|
|
15
69
|
signal?: AbortSignal,
|
|
@@ -21,15 +75,10 @@ export async function fetchQuotas(
|
|
|
21
75
|
};
|
|
22
76
|
}
|
|
23
77
|
|
|
24
|
-
const
|
|
25
|
-
if (signal) signals.push(signal);
|
|
26
|
-
const combined = AbortSignal.any(signals);
|
|
78
|
+
const combined = combineSignals(signal);
|
|
27
79
|
|
|
28
80
|
try {
|
|
29
|
-
const response = await
|
|
30
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
31
|
-
signal: combined,
|
|
32
|
-
});
|
|
81
|
+
const response = await neuralwattFetch("/quota", apiKey, combined);
|
|
33
82
|
|
|
34
83
|
if (!response.ok) {
|
|
35
84
|
let message = response.statusText;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface NeuralwattApiModelPricing {
|
|
2
|
+
input_per_million: number;
|
|
3
|
+
output_per_million: number;
|
|
4
|
+
cached_input_per_million: number | null;
|
|
5
|
+
cached_output_per_million: number | null;
|
|
6
|
+
currency: string;
|
|
7
|
+
pricing_tbd: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NeuralwattApiModelCapabilities {
|
|
11
|
+
tools: boolean;
|
|
12
|
+
json_mode: boolean;
|
|
13
|
+
vision: boolean;
|
|
14
|
+
reasoning: boolean;
|
|
15
|
+
reasoning_effort: boolean;
|
|
16
|
+
streaming: boolean;
|
|
17
|
+
system_role: boolean;
|
|
18
|
+
developer_role: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NeuralwattApiModelLimits {
|
|
22
|
+
max_context_length: number;
|
|
23
|
+
max_output_tokens: number | null;
|
|
24
|
+
max_images: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NeuralwattApiModelMetadata {
|
|
28
|
+
display_name: string;
|
|
29
|
+
description: string | null;
|
|
30
|
+
provider: string;
|
|
31
|
+
huggingface_id: string | null;
|
|
32
|
+
pricing: NeuralwattApiModelPricing;
|
|
33
|
+
capabilities: NeuralwattApiModelCapabilities;
|
|
34
|
+
limits: NeuralwattApiModelLimits;
|
|
35
|
+
deprecated: boolean;
|
|
36
|
+
deprecated_message: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NeuralwattApiModel {
|
|
40
|
+
id: string;
|
|
41
|
+
object: string;
|
|
42
|
+
created: number;
|
|
43
|
+
owned_by: string;
|
|
44
|
+
root?: string;
|
|
45
|
+
parent?: string | null;
|
|
46
|
+
max_model_len: number;
|
|
47
|
+
metadata?: NeuralwattApiModelMetadata;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NeuralwattApiModelsResponse {
|
|
51
|
+
object: "list";
|
|
52
|
+
data: NeuralwattApiModel[];
|
|
53
|
+
}
|