@aliou/pi-neuralwatt 0.16.3 → 0.17.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/extensions/provider/api/anthropic-messages.ts +6 -6
- package/extensions/provider/api/openai-completions.ts +6 -6
- package/extensions/provider/api/system-one.ts +242 -0
- package/extensions/provider/api/types.ts +6 -6
- package/extensions/provider/commands/settings/api-base-url-editor.ts +129 -0
- package/extensions/provider/commands/settings/index.ts +42 -6
- package/extensions/provider/index.ts +26 -17
- package/extensions/provider/models/build.ts +19 -3
- package/extensions/provider/models/catalog.ts +68 -8
- package/extensions/provider/models/public-models.ts +10 -2
- package/extensions/provider/models/refresh.ts +14 -10
- package/extensions/provider/provider.ts +15 -2
- package/extensions/provider/stream-simple.ts +34 -65
- package/package.json +7 -6
- package/schema.json +4 -0
- package/src/config/defaults.ts +3 -0
- package/src/config/loader.ts +25 -0
- package/src/config/types.ts +9 -0
- package/src/lib/neuralwatt-api.ts +2 -2
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { StreamOptions } from "@earendil-works/pi-ai";
|
|
2
2
|
import { stream, streamSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import { configuredApiBaseUrl } from "../../../src/config/loader";
|
|
3
4
|
import {
|
|
4
|
-
NEURALWATT_BASE_URL,
|
|
5
5
|
NEURALWATT_PROVIDER_ID,
|
|
6
6
|
NEURALWATT_REQUEST_HEADERS,
|
|
7
7
|
} from "../constants";
|
|
8
8
|
import { buildAnthropicThinkingLevelMap } from "../models/build";
|
|
9
|
-
import type {
|
|
9
|
+
import type { NeuralwattChatModel } from "../models/catalog";
|
|
10
10
|
import type { AnyStreamSimple } from "../stream-simple";
|
|
11
11
|
import type { NeuralwattApiHandler } from "./types";
|
|
12
12
|
|
|
@@ -61,7 +61,7 @@ function toMessagesBaseUrl(baseUrl: string): string {
|
|
|
61
61
|
return baseUrl.replace(/\/v1\/?$/, "");
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function stampAnthropicModels(models:
|
|
64
|
+
function stampAnthropicModels(models: NeuralwattChatModel[]) {
|
|
65
65
|
return models.map((model) => {
|
|
66
66
|
const { reasoningContract, ...compiled } = model;
|
|
67
67
|
// No retained contract: the identity map is the alias-free special case.
|
|
@@ -77,7 +77,7 @@ function stampAnthropicModels(models: NeuralwattModel[]) {
|
|
|
77
77
|
...compiled,
|
|
78
78
|
api: "anthropic-messages" as const,
|
|
79
79
|
provider: NEURALWATT_PROVIDER_ID,
|
|
80
|
-
baseUrl: toMessagesBaseUrl(model.baseUrl ??
|
|
80
|
+
baseUrl: toMessagesBaseUrl(model.baseUrl ?? configuredApiBaseUrl()),
|
|
81
81
|
headers: NEURALWATT_REQUEST_HEADERS,
|
|
82
82
|
compat: {
|
|
83
83
|
forceAdaptiveThinking: true,
|
|
@@ -105,10 +105,10 @@ export function createAnthropicMessagesApi(options?: {
|
|
|
105
105
|
stream: (model, context, streamOptions) =>
|
|
106
106
|
stream(model, context, withReasoning(streamOptions) as never),
|
|
107
107
|
streamSimple: (model, context, simpleOptions) =>
|
|
108
|
-
(options?.streamSimple ?? streamSimple)(
|
|
108
|
+
((options?.streamSimple ?? streamSimple) as AnyStreamSimple)(
|
|
109
109
|
model,
|
|
110
110
|
context,
|
|
111
|
-
withReasoning(simpleOptions)
|
|
111
|
+
withReasoning(simpleOptions),
|
|
112
112
|
),
|
|
113
113
|
};
|
|
114
114
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { StreamOptions } from "@earendil-works/pi-ai";
|
|
2
2
|
import { stream, streamSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import { configuredApiBaseUrl } from "../../../src/config/loader";
|
|
3
4
|
import {
|
|
4
|
-
NEURALWATT_BASE_URL,
|
|
5
5
|
NEURALWATT_PROVIDER_ID,
|
|
6
6
|
NEURALWATT_REQUEST_HEADERS,
|
|
7
7
|
} from "../constants";
|
|
8
|
-
import type {
|
|
8
|
+
import type { NeuralwattChatModel } from "../models/catalog";
|
|
9
9
|
import type { AnyStreamSimple } from "../stream-simple";
|
|
10
10
|
import type { NeuralwattApiHandler } from "./types";
|
|
11
11
|
|
|
@@ -60,24 +60,24 @@ export function createOpenAiCompletionsApi(options?: {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
return {
|
|
63
|
-
stampModels: (models:
|
|
63
|
+
stampModels: (models: NeuralwattChatModel[]) =>
|
|
64
64
|
models.map((model) => {
|
|
65
65
|
const { reasoningContract: _reasoningContract, ...compiled } = model;
|
|
66
66
|
return {
|
|
67
67
|
...compiled,
|
|
68
68
|
api: "openai-completions" as const,
|
|
69
69
|
provider: NEURALWATT_PROVIDER_ID,
|
|
70
|
-
baseUrl: model.baseUrl ??
|
|
70
|
+
baseUrl: model.baseUrl ?? configuredApiBaseUrl(),
|
|
71
71
|
headers: NEURALWATT_REQUEST_HEADERS,
|
|
72
72
|
};
|
|
73
73
|
}),
|
|
74
74
|
stream: (model, context, streamOptions) =>
|
|
75
75
|
stream(model, context, withReasoningReplay(streamOptions) as never),
|
|
76
76
|
streamSimple: (model, context, simpleOptions) =>
|
|
77
|
-
(options?.streamSimple ?? streamSimple)(
|
|
77
|
+
((options?.streamSimple ?? streamSimple) as AnyStreamSimple)(
|
|
78
78
|
model,
|
|
79
79
|
context,
|
|
80
|
-
withReasoningReplay(simpleOptions)
|
|
80
|
+
withReasoningReplay(simpleOptions),
|
|
81
81
|
),
|
|
82
82
|
};
|
|
83
83
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClassifierAnswer,
|
|
3
|
+
ClassifierApi,
|
|
4
|
+
ClassifierContext,
|
|
5
|
+
ClassifierFunction,
|
|
6
|
+
ClassifierModel,
|
|
7
|
+
ClassifierOptions,
|
|
8
|
+
ClassifierResult,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
import { calculateCost } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { Static } from "typebox";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
import { Check, Parse, ParseError } from "typebox/value";
|
|
14
|
+
import { configuredApiBaseUrl } from "../../../src/config/loader";
|
|
15
|
+
import {
|
|
16
|
+
NEURALWATT_PROVIDER_ID,
|
|
17
|
+
NEURALWATT_REQUEST_HEADERS,
|
|
18
|
+
} from "../constants";
|
|
19
|
+
import {
|
|
20
|
+
NEURALWATT_SYSTEM_ONE_API,
|
|
21
|
+
type NeuralwattClassifierModel,
|
|
22
|
+
} from "../models/build";
|
|
23
|
+
|
|
24
|
+
const UsageSchema = Type.Object({
|
|
25
|
+
input_tokens: Type.Integer({ minimum: 0 }),
|
|
26
|
+
output_tokens: Type.Integer({ minimum: 0 }),
|
|
27
|
+
});
|
|
28
|
+
const AnswerSchemas = {
|
|
29
|
+
choice: Type.Object({
|
|
30
|
+
type: Type.Literal("choice"),
|
|
31
|
+
choice: Type.String(),
|
|
32
|
+
probabilities: Type.Record(Type.String(), Type.Number()),
|
|
33
|
+
confidence: Type.Number(),
|
|
34
|
+
}),
|
|
35
|
+
score: Type.Object({
|
|
36
|
+
type: Type.Literal("score"),
|
|
37
|
+
score: Type.Number(),
|
|
38
|
+
confidence: Type.Number(),
|
|
39
|
+
}),
|
|
40
|
+
bool: Type.Object({ type: Type.Literal("noul"), noul: Type.Number() }),
|
|
41
|
+
};
|
|
42
|
+
const ErrorSchema = Type.Union([
|
|
43
|
+
Type.Object({
|
|
44
|
+
error: Type.Object({
|
|
45
|
+
message: Type.String(),
|
|
46
|
+
type: Type.String(),
|
|
47
|
+
code: Type.String(),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
Type.Object({ detail: Type.String() }),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function responseSchema(context: ClassifierContext) {
|
|
54
|
+
return Type.Object({
|
|
55
|
+
answers: Type.Object(
|
|
56
|
+
Object.fromEntries(
|
|
57
|
+
Object.entries(context.questions).map(([id, question]) => [
|
|
58
|
+
id,
|
|
59
|
+
AnswerSchemas[question.type],
|
|
60
|
+
]),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
usage: Type.Optional(UsageSchema),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type SystemOneResponse = Static<ReturnType<typeof responseSchema>>;
|
|
68
|
+
|
|
69
|
+
function mapAnswer(
|
|
70
|
+
value: SystemOneResponse["answers"][string],
|
|
71
|
+
): ClassifierAnswer {
|
|
72
|
+
if (value.type === "noul") return { type: "bool", probability: value.noul };
|
|
73
|
+
if (value.type === "score")
|
|
74
|
+
return { type: "score", score: value.score, confidence: value.confidence };
|
|
75
|
+
return {
|
|
76
|
+
type: "choice",
|
|
77
|
+
choice: value.choice,
|
|
78
|
+
probabilities: value.probabilities,
|
|
79
|
+
confidence: value.confidence,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mapUsage(
|
|
84
|
+
model: ClassifierModel<ClassifierApi>,
|
|
85
|
+
value: SystemOneResponse["usage"],
|
|
86
|
+
): ClassifierResult["usage"] {
|
|
87
|
+
if (!value) return undefined;
|
|
88
|
+
const { input_tokens: input, output_tokens: output } = value;
|
|
89
|
+
const usage = {
|
|
90
|
+
input,
|
|
91
|
+
output,
|
|
92
|
+
cacheRead: 0,
|
|
93
|
+
cacheWrite: 0,
|
|
94
|
+
totalTokens: input + output,
|
|
95
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
96
|
+
};
|
|
97
|
+
calculateCost(model, usage);
|
|
98
|
+
return usage;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function billedUsageOnInvalidResponse(
|
|
102
|
+
model: ClassifierModel<ClassifierApi>,
|
|
103
|
+
error: ParseError,
|
|
104
|
+
) {
|
|
105
|
+
const usage = (error.cause.value as { usage?: unknown } | null)?.usage;
|
|
106
|
+
return Check(UsageSchema, usage) ? mapUsage(model, usage) : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatRequest(
|
|
110
|
+
model: ClassifierModel<ClassifierApi>,
|
|
111
|
+
context: ClassifierContext,
|
|
112
|
+
) {
|
|
113
|
+
return {
|
|
114
|
+
model: model.id,
|
|
115
|
+
state: context.state,
|
|
116
|
+
questions: Object.fromEntries(
|
|
117
|
+
Object.entries(context.questions).map(([id, question]) => [
|
|
118
|
+
id,
|
|
119
|
+
question.type === "bool" ? { ...question, type: "noul" } : question,
|
|
120
|
+
]),
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requestHeaders(
|
|
126
|
+
model: ClassifierModel<ClassifierApi>,
|
|
127
|
+
options: ClassifierOptions,
|
|
128
|
+
) {
|
|
129
|
+
const headers = new Headers({
|
|
130
|
+
authorization: `Bearer ${options.apiKey || "-"}`,
|
|
131
|
+
"content-type": "application/json",
|
|
132
|
+
});
|
|
133
|
+
const overrides = [model.headers, options.headers].flatMap((source) =>
|
|
134
|
+
Object.entries(source ?? {}),
|
|
135
|
+
);
|
|
136
|
+
for (const [name, value] of overrides) {
|
|
137
|
+
if (value === null) headers.delete(name);
|
|
138
|
+
else headers.set(name, value);
|
|
139
|
+
}
|
|
140
|
+
return Object.fromEntries(headers);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function requestSignal({ signal, timeoutMs }: ClassifierOptions) {
|
|
144
|
+
if (timeoutMs === undefined) return signal;
|
|
145
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
146
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function request(
|
|
150
|
+
model: ClassifierModel<ClassifierApi>,
|
|
151
|
+
context: ClassifierContext,
|
|
152
|
+
options: ClassifierOptions,
|
|
153
|
+
) {
|
|
154
|
+
if (model.api !== NEURALWATT_SYSTEM_ONE_API) {
|
|
155
|
+
throw new Error(`Unsupported classifier API: ${model.api}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (context.images?.length) {
|
|
159
|
+
throw new Error("Neuralwatt classifier does not support image input");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
options.signal?.throwIfAborted();
|
|
163
|
+
|
|
164
|
+
const rawPayload = formatRequest(model, context);
|
|
165
|
+
const payload = (await options.onPayload?.(rawPayload, model)) ?? rawPayload;
|
|
166
|
+
|
|
167
|
+
const url = new URL("systemone", `${model.baseUrl.replace(/\/+$/u, "")}/`);
|
|
168
|
+
|
|
169
|
+
const requestFetch = options.fetch ?? globalThis.fetch;
|
|
170
|
+
|
|
171
|
+
const response = await requestFetch(url, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: requestHeaders(model, options),
|
|
174
|
+
body: JSON.stringify(payload),
|
|
175
|
+
signal: requestSignal(options),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await options.onResponse?.(
|
|
179
|
+
{ status: response.status, headers: Object.fromEntries(response.headers) },
|
|
180
|
+
model,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const body: unknown = await response.json();
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const failure = Parse(ErrorSchema, body);
|
|
186
|
+
const message = "error" in failure ? failure.error.message : failure.detail;
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Neuralwatt System One API returned ${response.status}: ${message}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Parse(responseSchema(context), body);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const classify: ClassifierFunction = async (
|
|
196
|
+
model,
|
|
197
|
+
context,
|
|
198
|
+
options = {},
|
|
199
|
+
) => {
|
|
200
|
+
const result: ClassifierResult = {
|
|
201
|
+
api: model.api,
|
|
202
|
+
provider: model.provider,
|
|
203
|
+
model: model.id,
|
|
204
|
+
answers: {},
|
|
205
|
+
stopReason: "stop",
|
|
206
|
+
timestamp: Date.now(),
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
const body = await request(model, context, options);
|
|
210
|
+
result.usage = mapUsage(model, body.usage);
|
|
211
|
+
result.answers = Object.fromEntries(
|
|
212
|
+
Object.keys(context.questions).map((id) => [
|
|
213
|
+
id,
|
|
214
|
+
mapAnswer(body.answers[id]),
|
|
215
|
+
]),
|
|
216
|
+
);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
result.stopReason = options.signal?.aborted ? "aborted" : "error";
|
|
219
|
+
if (error instanceof ParseError) {
|
|
220
|
+
result.usage = billedUsageOnInvalidResponse(model, error);
|
|
221
|
+
result.errorMessage = error.cause.errors
|
|
222
|
+
.map(({ instancePath, message }) => `${instancePath}: ${message}`)
|
|
223
|
+
.join("; ");
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
result.errorMessage =
|
|
227
|
+
error instanceof Error ? error.message : String(error);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export function stampClassifierModels(
|
|
233
|
+
models: NeuralwattClassifierModel[],
|
|
234
|
+
): ClassifierModel<ClassifierApi>[] {
|
|
235
|
+
return models.map((model) => ({
|
|
236
|
+
...model,
|
|
237
|
+
api: NEURALWATT_SYSTEM_ONE_API,
|
|
238
|
+
provider: NEURALWATT_PROVIDER_ID,
|
|
239
|
+
baseUrl: model.baseUrl ?? configuredApiBaseUrl(),
|
|
240
|
+
headers: NEURALWATT_REQUEST_HEADERS,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Api,
|
|
3
3
|
AssistantMessageEventStream,
|
|
4
|
-
Context,
|
|
5
4
|
Model,
|
|
6
5
|
SimpleStreamOptions,
|
|
7
6
|
StreamOptions,
|
|
7
|
+
TranscriptContext,
|
|
8
8
|
} from "@earendil-works/pi-ai";
|
|
9
|
-
import type {
|
|
9
|
+
import type { NeuralwattChatModel } from "../models/catalog";
|
|
10
10
|
|
|
11
|
-
/** One Neuralwatt API surface: model stamping plus submission plumbing. */
|
|
11
|
+
/** One Neuralwatt API surface: model stamping plus chat submission plumbing. */
|
|
12
12
|
export interface NeuralwattApiHandler {
|
|
13
|
-
stampModels(models:
|
|
13
|
+
stampModels(models: NeuralwattChatModel[]): Model<Api>[];
|
|
14
14
|
stream(
|
|
15
15
|
model: Model<Api>,
|
|
16
|
-
context:
|
|
16
|
+
context: TranscriptContext,
|
|
17
17
|
options?: StreamOptions,
|
|
18
18
|
): AssistantMessageEventStream;
|
|
19
19
|
streamSimple(
|
|
20
20
|
model: Model<Api>,
|
|
21
|
-
context:
|
|
21
|
+
context: TranscriptContext,
|
|
22
22
|
options?: SimpleStreamOptions,
|
|
23
23
|
): AssistantMessageEventStream;
|
|
24
24
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
export interface ApiBaseUrlEditorOptions {
|
|
10
|
+
initial: string;
|
|
11
|
+
defaultBaseUrl: string;
|
|
12
|
+
theme: SettingsListTheme;
|
|
13
|
+
requestRender: () => void;
|
|
14
|
+
onSubmit: (value: string) => void;
|
|
15
|
+
onCancel: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Status =
|
|
19
|
+
| { kind: "idle" }
|
|
20
|
+
| { kind: "validating"; url: string }
|
|
21
|
+
| { kind: "error"; message: string };
|
|
22
|
+
|
|
23
|
+
export function normalizeApiBaseUrlInput(value: string): string {
|
|
24
|
+
return value.trim().replace(/\/+$/u, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function checkApiBase(
|
|
28
|
+
base: string,
|
|
29
|
+
timeoutMs = 8_000,
|
|
30
|
+
): Promise<string | null> {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${base}/models`, {
|
|
33
|
+
headers: { Accept: "application/json" },
|
|
34
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
35
|
+
});
|
|
36
|
+
return response.ok ? null : `HTTP ${response.status} from ${base}/models`;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return error instanceof Error && error.name === "TimeoutError"
|
|
39
|
+
? `Timed out reaching ${base}/models`
|
|
40
|
+
: `Could not reach ${base}/models`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ApiBaseUrlEditor implements Component {
|
|
45
|
+
private readonly input = new Input();
|
|
46
|
+
private readonly defaultBaseUrl: string;
|
|
47
|
+
private readonly theme: SettingsListTheme;
|
|
48
|
+
private readonly requestRender: () => void;
|
|
49
|
+
private readonly onSubmit: (value: string) => void;
|
|
50
|
+
private readonly onCancel: () => void;
|
|
51
|
+
private status: Status = { kind: "idle" };
|
|
52
|
+
|
|
53
|
+
constructor(options: ApiBaseUrlEditorOptions) {
|
|
54
|
+
this.defaultBaseUrl = options.defaultBaseUrl;
|
|
55
|
+
this.theme = options.theme;
|
|
56
|
+
this.requestRender = options.requestRender;
|
|
57
|
+
this.onSubmit = options.onSubmit;
|
|
58
|
+
this.onCancel = options.onCancel;
|
|
59
|
+
this.input.setValue(options.initial);
|
|
60
|
+
this.input.onSubmit = (value) => void this.submit(value);
|
|
61
|
+
this.input.onEscape = () => this.onCancel();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async submit(raw: string): Promise<void> {
|
|
65
|
+
const base = normalizeApiBaseUrlInput(raw);
|
|
66
|
+
if (base) {
|
|
67
|
+
this.status = { kind: "validating", url: `${base}/models` };
|
|
68
|
+
this.requestRender();
|
|
69
|
+
const error = await checkApiBase(base);
|
|
70
|
+
if (error) {
|
|
71
|
+
this.status = { kind: "error", message: error };
|
|
72
|
+
this.requestRender();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.onSubmit(base);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
invalidate(): void {}
|
|
80
|
+
|
|
81
|
+
handleInput(data: string): void {
|
|
82
|
+
if (matchesKey(data, Key.escape)) {
|
|
83
|
+
this.onCancel();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (this.status.kind === "validating") return;
|
|
87
|
+
this.status = { kind: "idle" };
|
|
88
|
+
this.input.handleInput(data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render(width: number): string[] {
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
lines.push(
|
|
94
|
+
truncateToWidth(this.theme.label("API base URL", true), width, "", true),
|
|
95
|
+
);
|
|
96
|
+
lines.push(...this.input.render(width));
|
|
97
|
+
if (this.status.kind === "validating") {
|
|
98
|
+
lines.push(
|
|
99
|
+
truncateToWidth(
|
|
100
|
+
this.theme.description(`Validating ${this.status.url} …`),
|
|
101
|
+
width,
|
|
102
|
+
"",
|
|
103
|
+
true,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
} else if (this.status.kind === "error") {
|
|
107
|
+
lines.push(
|
|
108
|
+
truncateToWidth(
|
|
109
|
+
this.theme.value(this.status.message, true),
|
|
110
|
+
width,
|
|
111
|
+
"",
|
|
112
|
+
true,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
lines.push(
|
|
117
|
+
truncateToWidth(
|
|
118
|
+
this.theme.description(
|
|
119
|
+
`Enter to save · empty = direct upstream (${this.defaultBaseUrl})`,
|
|
120
|
+
),
|
|
121
|
+
width,
|
|
122
|
+
"",
|
|
123
|
+
true,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
NEURALWATT_CONFIG_UPDATED_EVENT,
|
|
15
15
|
type NeuralwattFeatureId,
|
|
16
16
|
} from "../../../../src/events";
|
|
17
|
+
import { ApiBaseUrlEditor } from "./api-base-url-editor";
|
|
17
18
|
|
|
18
19
|
export interface RegisterNeuralwattSettingsOptions {
|
|
19
20
|
getLoadedFeatures: () => Set<NeuralwattFeatureId>;
|
|
@@ -62,15 +63,16 @@ export function registerNeuralwattSettings(
|
|
|
62
63
|
options: RegisterNeuralwattSettingsOptions,
|
|
63
64
|
): void {
|
|
64
65
|
const { getLoadedFeatures } = options;
|
|
65
|
-
// The provider stamps `provider.api`
|
|
66
|
-
// reaches it after `/reload`.
|
|
66
|
+
// The provider stamps `provider.api` and assembles against the configured
|
|
67
|
+
// base at extension load; a saved change only reaches it after `/reload`.
|
|
67
68
|
let pendingApi: NeuralwattApi | undefined;
|
|
69
|
+
let pendingApiBaseUrl: string | undefined;
|
|
68
70
|
|
|
69
71
|
registerSettingsCommand<NeuralwattConfig, ResolvedNeuralwattConfig>(pi, {
|
|
70
72
|
commandName: "neuralwatt:settings",
|
|
71
73
|
title: "Neuralwatt Settings",
|
|
72
74
|
configStore: configLoader,
|
|
73
|
-
buildSections: (tabConfig, resolved): SettingsSection[] => {
|
|
75
|
+
buildSections: (tabConfig, resolved, buildCtx): SettingsSection[] => {
|
|
74
76
|
const loaded = getLoadedFeatures();
|
|
75
77
|
return [
|
|
76
78
|
{
|
|
@@ -80,10 +82,41 @@ export function registerNeuralwattSettings(
|
|
|
80
82
|
id: "api",
|
|
81
83
|
label: "API",
|
|
82
84
|
description:
|
|
83
|
-
"Serve models via the OpenAI
|
|
85
|
+
"Serve models via the OpenAI completions endpoint or the Anthropic endpoint",
|
|
84
86
|
currentValue: tabConfig?.provider?.api ?? resolved.provider.api,
|
|
85
87
|
values: ["openai-completions", "anthropic-messages"],
|
|
86
88
|
},
|
|
89
|
+
{
|
|
90
|
+
id: "apiBaseUrl",
|
|
91
|
+
label: "API base URL",
|
|
92
|
+
description:
|
|
93
|
+
"Base URL for API requests; empty uses the default upstream",
|
|
94
|
+
currentValue: tabConfig?.provider?.apiBaseUrl || "(not set)",
|
|
95
|
+
submenu: (current, submenuDone, submenuCtx) => {
|
|
96
|
+
const display = (value: string) => value || "(not set)";
|
|
97
|
+
return new ApiBaseUrlEditor({
|
|
98
|
+
initial: current === "(not set)" ? "" : current,
|
|
99
|
+
defaultBaseUrl: resolved.provider.apiBaseUrl,
|
|
100
|
+
theme: buildCtx.theme,
|
|
101
|
+
requestRender: submenuCtx.requestRender,
|
|
102
|
+
onSubmit: (value) => {
|
|
103
|
+
const updated = structuredClone(
|
|
104
|
+
tabConfig ?? {},
|
|
105
|
+
) as NeuralwattConfig;
|
|
106
|
+
updated.provider = { ...updated.provider };
|
|
107
|
+
if (value) {
|
|
108
|
+
updated.provider.apiBaseUrl = value;
|
|
109
|
+
} else {
|
|
110
|
+
delete updated.provider.apiBaseUrl;
|
|
111
|
+
}
|
|
112
|
+
buildCtx.setDraft(updated);
|
|
113
|
+
pendingApiBaseUrl = value;
|
|
114
|
+
submenuDone(display(value));
|
|
115
|
+
},
|
|
116
|
+
onCancel: () => submenuDone(display(current)),
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
87
120
|
],
|
|
88
121
|
},
|
|
89
122
|
{
|
|
@@ -165,9 +198,12 @@ export function registerNeuralwattSettings(
|
|
|
165
198
|
},
|
|
166
199
|
onSave: async (ctx) => {
|
|
167
200
|
emitConfigUpdated(pi);
|
|
168
|
-
if (pendingApi === undefined)
|
|
201
|
+
if (pendingApi === undefined && pendingApiBaseUrl === undefined) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
169
204
|
pendingApi = undefined;
|
|
170
|
-
|
|
205
|
+
pendingApiBaseUrl = undefined;
|
|
206
|
+
ctx.ui.notify("Run /reload to apply the new provider settings", "info");
|
|
171
207
|
},
|
|
172
208
|
});
|
|
173
209
|
}
|
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
parseRateLimitHeaders,
|
|
31
31
|
} from "./rate-limit-error";
|
|
32
32
|
import { updateQuotasFromSseComment } from "./sse-quotas";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type NeuralwattStreamCallbacks,
|
|
35
|
+
wrapNeuralwattStreamSimple,
|
|
36
|
+
} from "./stream-simple";
|
|
34
37
|
|
|
35
38
|
const HEADER_EMIT_THROTTLE_MS = 5_000;
|
|
36
39
|
|
|
@@ -42,7 +45,7 @@ function emitConfigUpdated(pi: ExtensionAPI): void {
|
|
|
42
45
|
|
|
43
46
|
function registerNeuralwattProvider(
|
|
44
47
|
pi: ExtensionAPI,
|
|
45
|
-
|
|
48
|
+
streamCallbacks: NeuralwattStreamCallbacks,
|
|
46
49
|
): void {
|
|
47
50
|
const staticModels = buildNeuralwattProviderModels();
|
|
48
51
|
|
|
@@ -51,7 +54,7 @@ function registerNeuralwattProvider(
|
|
|
51
54
|
const streamSimple = baseStreamSimple
|
|
52
55
|
? (wrapNeuralwattStreamSimple(
|
|
53
56
|
baseStreamSimple as never,
|
|
54
|
-
|
|
57
|
+
streamCallbacks,
|
|
55
58
|
) as never)
|
|
56
59
|
: undefined;
|
|
57
60
|
|
|
@@ -60,7 +63,7 @@ function registerNeuralwattProvider(
|
|
|
60
63
|
const messagesStreamSimple = messagesBaseStreamSimple
|
|
61
64
|
? (wrapNeuralwattStreamSimple(
|
|
62
65
|
messagesBaseStreamSimple as never,
|
|
63
|
-
|
|
66
|
+
streamCallbacks,
|
|
64
67
|
) as never)
|
|
65
68
|
: undefined;
|
|
66
69
|
|
|
@@ -101,7 +104,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
101
104
|
emitQuotas(quotas, "sse");
|
|
102
105
|
};
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
// Rate-limit info captured by the per-request fetch wrapper from the most
|
|
108
|
+
// recent 429 response. Used in message_end to rewrite the generic error
|
|
109
|
+
// text with actionable details from Neuralwatt's response headers.
|
|
110
|
+
let pendingRateLimitInfo: NeuralwattRateLimitInfo | undefined;
|
|
111
|
+
|
|
112
|
+
registerNeuralwattProvider(pi, {
|
|
113
|
+
onSseQuota: handleSseQuota,
|
|
114
|
+
onRateLimit: (info) => {
|
|
115
|
+
pendingRateLimitInfo = info;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
105
118
|
|
|
106
119
|
const loadedFeatures = new Set<NeuralwattFeatureId>();
|
|
107
120
|
|
|
@@ -125,10 +138,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
125
138
|
pi.events.emit(NEURALWATT_QUOTAS_UPDATED_EVENT, { quotas, source });
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
// Stored rate-limit info from the most recent 429 response.
|
|
129
|
-
// Used in message_end to rewrite the generic error text with
|
|
130
|
-
// actionable details from Neuralwatt's response headers.
|
|
131
|
-
let pendingRateLimitInfo: NeuralwattRateLimitInfo | undefined;
|
|
132
141
|
let currentModelRegistry: ModelRegistry | undefined;
|
|
133
142
|
|
|
134
143
|
pi.on("message_end", (event, ctx) => {
|
|
@@ -148,14 +157,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
148
157
|
return { message };
|
|
149
158
|
}
|
|
150
159
|
|
|
151
|
-
// Fallback for 429s where no layer-specific headers were captured
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
160
|
+
// Fallback for 429s where no layer-specific headers were captured (the
|
|
161
|
+
// fetch wrapper reported `undefined` via onRateLimit, or the 429 came
|
|
162
|
+
// from infra in front of Neuralwatt). The `"429 rate limit:"` prefix
|
|
163
|
+
// guard skips messages the first branch already rewrote. On the
|
|
164
|
+
// openai-completions surface after_provider_response cannot observe
|
|
165
|
+
// 429s — the OpenAI SDK throws before Pi's onResponse hook runs — so
|
|
166
|
+
// this is the only repair path there; on anthropic-messages
|
|
167
|
+
// after_provider_response also feeds pendingRateLimitInfo directly.
|
|
159
168
|
if (
|
|
160
169
|
event.message.role === "assistant" &&
|
|
161
170
|
event.message.stopReason === "error" &&
|
|
@@ -4,8 +4,24 @@ import type {
|
|
|
4
4
|
NeuralwattReasoningEffort,
|
|
5
5
|
} from "../../../src/types/models-api";
|
|
6
6
|
|
|
7
|
+
export type ProviderChatModelConfig = Extract<
|
|
8
|
+
ProviderModelConfig,
|
|
9
|
+
{ type?: "chat" }
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export type ProviderClassifierModelConfig = Extract<
|
|
13
|
+
ProviderModelConfig,
|
|
14
|
+
{ type: "classifier" }
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export const NEURALWATT_SYSTEM_ONE_API = "typesafe-system-one" as const;
|
|
18
|
+
|
|
19
|
+
export type NeuralwattClassifierModel = ProviderClassifierModelConfig & {
|
|
20
|
+
api: typeof NEURALWATT_SYSTEM_ONE_API;
|
|
21
|
+
};
|
|
22
|
+
|
|
7
23
|
export type ThinkingLevelMap = NonNullable<
|
|
8
|
-
|
|
24
|
+
ProviderChatModelConfig["thinkingLevelMap"]
|
|
9
25
|
>;
|
|
10
26
|
|
|
11
27
|
/**
|
|
@@ -13,7 +29,7 @@ export type ThinkingLevelMap = NonNullable<
|
|
|
13
29
|
* retained for anthropic-messages map derivation. Rides the models store
|
|
14
30
|
* (JSON passthrough); stripped from stamped runtime models.
|
|
15
31
|
*/
|
|
16
|
-
export type NeuralwattCompiledModel =
|
|
32
|
+
export type NeuralwattCompiledModel = ProviderChatModelConfig & {
|
|
17
33
|
reasoningContract?: NeuralwattReasoningMapSource;
|
|
18
34
|
};
|
|
19
35
|
|
|
@@ -157,7 +173,7 @@ export function buildNeuralwattModel(
|
|
|
157
173
|
): NeuralwattCompiledModel {
|
|
158
174
|
const vision = variant.vision ?? family.vision;
|
|
159
175
|
|
|
160
|
-
const compat: NonNullable<
|
|
176
|
+
const compat: NonNullable<ProviderChatModelConfig["compat"]> = {
|
|
161
177
|
supportsDeveloperRole: false,
|
|
162
178
|
maxTokensField: "max_tokens",
|
|
163
179
|
};
|
|
@@ -1,19 +1,45 @@
|
|
|
1
|
-
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import type { NeuralwattApiModel } from "../../../src/types/models-api";
|
|
3
2
|
import {
|
|
4
3
|
buildThinkingLevelMap,
|
|
4
|
+
NEURALWATT_SYSTEM_ONE_API,
|
|
5
|
+
type NeuralwattClassifierModel,
|
|
5
6
|
type NeuralwattCompiledModel,
|
|
7
|
+
type ProviderChatModelConfig,
|
|
6
8
|
resolveMaxTokens,
|
|
7
9
|
type ThinkingLevelMap,
|
|
8
10
|
} from "./build";
|
|
9
11
|
import { NEURALWATT_MODELS } from "./public-models";
|
|
10
12
|
|
|
11
|
-
export type
|
|
13
|
+
export type NeuralwattChatModel = NeuralwattCompiledModel;
|
|
14
|
+
|
|
15
|
+
export type NeuralwattModel = NeuralwattChatModel | NeuralwattClassifierModel;
|
|
16
|
+
|
|
17
|
+
export function isNeuralwattClassifierModel(
|
|
18
|
+
model: NeuralwattModel,
|
|
19
|
+
): model is NeuralwattClassifierModel {
|
|
20
|
+
return model.type === "classifier";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function partitionNeuralwattModels(models: readonly NeuralwattModel[]): {
|
|
24
|
+
chat: NeuralwattChatModel[];
|
|
25
|
+
classifiers: NeuralwattClassifierModel[];
|
|
26
|
+
} {
|
|
27
|
+
const chat: NeuralwattChatModel[] = [];
|
|
28
|
+
const classifiers: NeuralwattClassifierModel[] = [];
|
|
29
|
+
for (const model of models) {
|
|
30
|
+
if (isNeuralwattClassifierModel(model)) {
|
|
31
|
+
classifiers.push(model);
|
|
32
|
+
} else {
|
|
33
|
+
chat.push(model);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { chat, classifiers };
|
|
37
|
+
}
|
|
12
38
|
|
|
13
39
|
// Chat-template thinking: the API exposes a `reasoning` block, but the
|
|
14
40
|
// underlying mechanism is chat_template_kwargs, so Pi needs the mapping.
|
|
15
41
|
const COMPAT_OVERRIDES: Partial<
|
|
16
|
-
Record<string, Partial<NonNullable<
|
|
42
|
+
Record<string, Partial<NonNullable<ProviderChatModelConfig["compat"]>>>
|
|
17
43
|
> = {
|
|
18
44
|
"Qwen/Qwen3.8-27B-FP8": {
|
|
19
45
|
thinkingFormat: "chat-template",
|
|
@@ -23,7 +49,36 @@ const COMPAT_OVERRIDES: Partial<
|
|
|
23
49
|
},
|
|
24
50
|
};
|
|
25
51
|
|
|
26
|
-
function
|
|
52
|
+
function apiModelToClassifierModel(
|
|
53
|
+
model: NeuralwattApiModel,
|
|
54
|
+
): NeuralwattClassifierModel {
|
|
55
|
+
const meta = model.metadata;
|
|
56
|
+
if (!meta)
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Neuralwatt API returned model "${model.id}" without metadata`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
type: "classifier",
|
|
63
|
+
api: NEURALWATT_SYSTEM_ONE_API,
|
|
64
|
+
id: model.id,
|
|
65
|
+
name: meta.display_name ?? model.id,
|
|
66
|
+
input: meta.capabilities.vision
|
|
67
|
+
? (["text", "image"] as const)
|
|
68
|
+
: (["text"] as const),
|
|
69
|
+
cost: {
|
|
70
|
+
input: meta.pricing.input_per_million,
|
|
71
|
+
output: meta.pricing.output_per_million,
|
|
72
|
+
cacheRead: meta.pricing.cached_input_per_million ?? 0,
|
|
73
|
+
cacheWrite: meta.pricing.cached_output_per_million ?? 0,
|
|
74
|
+
},
|
|
75
|
+
contextWindow: model.max_model_len,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function apiModelToProviderModel(
|
|
80
|
+
model: NeuralwattApiModel,
|
|
81
|
+
): NeuralwattChatModel {
|
|
27
82
|
const meta = model.metadata;
|
|
28
83
|
if (!meta)
|
|
29
84
|
throw new Error(
|
|
@@ -32,7 +87,7 @@ function apiModelToProviderModel(model: NeuralwattApiModel): NeuralwattModel {
|
|
|
32
87
|
|
|
33
88
|
const reasoning = meta.capabilities.reasoning;
|
|
34
89
|
|
|
35
|
-
const compat: NonNullable<
|
|
90
|
+
const compat: NonNullable<ProviderChatModelConfig["compat"]> = {
|
|
36
91
|
supportsDeveloperRole: meta.capabilities.developer_role,
|
|
37
92
|
maxTokensField: "max_tokens",
|
|
38
93
|
...COMPAT_OVERRIDES[model.id],
|
|
@@ -83,13 +138,18 @@ export function buildNeuralwattProviderModelsFromApi(
|
|
|
83
138
|
m.metadata &&
|
|
84
139
|
!m.metadata.deprecated &&
|
|
85
140
|
!m.metadata.pricing.pricing_tbd &&
|
|
86
|
-
// Exclude non-chat models (e.g. embeddings) by task
|
|
87
141
|
!(
|
|
88
142
|
m.metadata.capabilities.task &&
|
|
89
|
-
!["chat", "completions"].includes(
|
|
143
|
+
!["chat", "completions", "decision"].includes(
|
|
144
|
+
m.metadata.capabilities.task,
|
|
145
|
+
)
|
|
90
146
|
),
|
|
91
147
|
)
|
|
92
|
-
.map(
|
|
148
|
+
.map((m) =>
|
|
149
|
+
m.metadata?.capabilities.task === "decision"
|
|
150
|
+
? apiModelToClassifierModel(m)
|
|
151
|
+
: apiModelToProviderModel(m),
|
|
152
|
+
);
|
|
93
153
|
return models;
|
|
94
154
|
}
|
|
95
155
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import {
|
|
3
2
|
buildNeuralwattFamily,
|
|
4
3
|
type NeuralwattModelFamily,
|
|
5
4
|
type NeuralwattVariantSpec,
|
|
5
|
+
type ProviderChatModelConfig,
|
|
6
6
|
} from "./build";
|
|
7
7
|
|
|
8
8
|
// Public models returned by https://api.neuralwatt.com/v1/models.
|
|
@@ -375,6 +375,14 @@ const FAMILIES: [NeuralwattModelFamily, NeuralwattVariantSpec[]][] = [
|
|
|
375
375
|
maxOutputTokens: 393216,
|
|
376
376
|
reasoning: true,
|
|
377
377
|
},
|
|
378
|
+
{
|
|
379
|
+
id: "mimo-v2.6-pro-flex",
|
|
380
|
+
name: "MiMo-V2.6-Pro (flex)",
|
|
381
|
+
contextWindow: 1048560,
|
|
382
|
+
maxOutputTokens: 393216,
|
|
383
|
+
reasoning: true,
|
|
384
|
+
costMultiplier: 0.65,
|
|
385
|
+
},
|
|
378
386
|
],
|
|
379
387
|
],
|
|
380
388
|
[
|
|
@@ -492,6 +500,6 @@ const FAMILIES: [NeuralwattModelFamily, NeuralwattVariantSpec[]][] = [
|
|
|
492
500
|
// `costMultiplier: 0.65` per variant.
|
|
493
501
|
// https://docs.neuralwatt.com/guides/flex-tier.md
|
|
494
502
|
|
|
495
|
-
export const NEURALWATT_MODELS:
|
|
503
|
+
export const NEURALWATT_MODELS: ProviderChatModelConfig[] = FAMILIES.flatMap(
|
|
496
504
|
([family, variants]) => buildNeuralwattFamily(family, variants),
|
|
497
505
|
);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AnyModel,
|
|
2
3
|
ModelsStoreEntry,
|
|
3
4
|
RefreshModelsContext,
|
|
4
5
|
} from "@earendil-works/pi-ai";
|
|
6
|
+
import { configuredApiBaseUrl } from "../../../src/config/loader";
|
|
5
7
|
import type { NeuralwattApiModel } from "../../../src/types/models-api";
|
|
6
8
|
import type {
|
|
7
9
|
buildNeuralwattProviderModels,
|
|
@@ -17,7 +19,8 @@ export const MODEL_STORE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
|
17
19
|
* key-scoped catalog (preview, grant-gated, private models), so an entry
|
|
18
20
|
* stamped "public" must not shadow a keyed refresh — and a key-scoped entry
|
|
19
21
|
* must not be replayed for an anonymous user. Matches the anonymous-key
|
|
20
|
-
* convention in src/lib/neuralwatt-api.ts (authHeaders).
|
|
22
|
+
* convention in src/lib/neuralwatt-api.ts (authHeaders). The base URL is part
|
|
23
|
+
* of the key because a gateway can serve a different catalog than upstream.
|
|
21
24
|
*/
|
|
22
25
|
const CATALOG_SCOPE_VERSION = "v2";
|
|
23
26
|
type CatalogScope = "public" | "key";
|
|
@@ -30,18 +33,15 @@ function catalogScope(apiKey: string | undefined): CatalogScope {
|
|
|
30
33
|
: "public";
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
function
|
|
34
|
-
return
|
|
36
|
+
function catalogKey(scope: CatalogScope): string {
|
|
37
|
+
return `${scope} ${CATALOG_SCOPE_VERSION} ${configuredApiBaseUrl()}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
function catalogKeyMatches(
|
|
38
41
|
entry: ScopedModelsStoreEntry | undefined,
|
|
39
42
|
scope: CatalogScope,
|
|
40
43
|
): boolean {
|
|
41
|
-
return (
|
|
42
|
-
entry !== undefined &&
|
|
43
|
-
storedCatalogKey(entry) === `${scope} ${CATALOG_SCOPE_VERSION}`
|
|
44
|
-
);
|
|
44
|
+
return entry !== undefined && entry.catalogKey === catalogKey(scope);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export type FetchNeuralwattApiModels = (
|
|
@@ -49,6 +49,10 @@ export type FetchNeuralwattApiModels = (
|
|
|
49
49
|
signal?: AbortSignal,
|
|
50
50
|
) => Promise<readonly NeuralwattApiModel[]>;
|
|
51
51
|
|
|
52
|
+
function asStoredChatModels(models: readonly AnyModel[]): NeuralwattModel[] {
|
|
53
|
+
return models as unknown as NeuralwattModel[];
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
function isFreshStoreEntry(
|
|
53
57
|
entry: Readonly<ModelsStoreEntry> | undefined,
|
|
54
58
|
): entry is ModelsStoreEntry {
|
|
@@ -82,11 +86,11 @@ export function createNeuralwattRefreshModels(
|
|
|
82
86
|
const stored = context.stored as ScopedModelsStoreEntry | undefined;
|
|
83
87
|
if (!context.allowNetwork) {
|
|
84
88
|
return stored !== undefined && catalogKeyMatches(stored, scope)
|
|
85
|
-
? buildFromStore(stored.models)
|
|
89
|
+
? buildFromStore(asStoredChatModels(stored.models))
|
|
86
90
|
: fallback;
|
|
87
91
|
}
|
|
88
92
|
if (!context.force && isUsableStoreEntry(stored, scope)) {
|
|
89
|
-
return buildFromStore(stored.models);
|
|
93
|
+
return buildFromStore(asStoredChatModels(stored.models));
|
|
90
94
|
}
|
|
91
95
|
const apiModels = await fetchApiModels(apiKey, context.signal);
|
|
92
96
|
context.signal.throwIfAborted();
|
|
@@ -94,7 +98,7 @@ export function createNeuralwattRefreshModels(
|
|
|
94
98
|
const entry: ScopedModelsStoreEntry = {
|
|
95
99
|
models: models as unknown as ModelsStoreEntry["models"],
|
|
96
100
|
checkedAt: Date.now(),
|
|
97
|
-
catalogKey:
|
|
101
|
+
catalogKey: catalogKey(scope),
|
|
98
102
|
};
|
|
99
103
|
await context.publish({ persist: entry }).catch(() => undefined);
|
|
100
104
|
context.signal.throwIfAborted();
|
|
@@ -2,6 +2,7 @@ import type { Provider } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { NeuralwattApi } from "../../src/config";
|
|
3
3
|
import { createAnthropicMessagesApi } from "./api/anthropic-messages";
|
|
4
4
|
import { createOpenAiCompletionsApi } from "./api/openai-completions";
|
|
5
|
+
import { classify, stampClassifierModels } from "./api/system-one";
|
|
5
6
|
import type { NeuralwattApiHandler } from "./api/types";
|
|
6
7
|
import {
|
|
7
8
|
NEURALWATT_API_KEY_ENV,
|
|
@@ -13,6 +14,7 @@ import type { NeuralwattModel } from "./models/catalog";
|
|
|
13
14
|
import {
|
|
14
15
|
buildNeuralwattProviderModelsFromApi,
|
|
15
16
|
buildNeuralwattProviderModelsFromStore,
|
|
17
|
+
partitionNeuralwattModels,
|
|
16
18
|
} from "./models/catalog";
|
|
17
19
|
import {
|
|
18
20
|
createNeuralwattRefreshModels,
|
|
@@ -98,11 +100,22 @@ export function createNeuralwattProvider(
|
|
|
98
100
|
if (envKey) {
|
|
99
101
|
return { auth: { apiKey: envKey }, source: NEURALWATT_API_KEY_ENV };
|
|
100
102
|
}
|
|
101
|
-
|
|
103
|
+
// pi-ai rejects empty keys before dispatch; "-" is the neutral
|
|
104
|
+
// placeholder that gateways strip or replace server-side.
|
|
105
|
+
return { auth: { apiKey: "-" }, source: "anonymous" };
|
|
102
106
|
},
|
|
103
107
|
},
|
|
104
108
|
},
|
|
105
|
-
getModels: () =>
|
|
109
|
+
getModels: () =>
|
|
110
|
+
handler.stampModels(partitionNeuralwattModels(canonicalModels).chat),
|
|
111
|
+
getAllModels: () => {
|
|
112
|
+
const { chat, classifiers } = partitionNeuralwattModels(canonicalModels);
|
|
113
|
+
return [
|
|
114
|
+
...handler.stampModels(chat),
|
|
115
|
+
...stampClassifierModels(classifiers),
|
|
116
|
+
];
|
|
117
|
+
},
|
|
118
|
+
classify: (model, context, options) => classify(model, context, options),
|
|
106
119
|
refreshModels: async (context) => {
|
|
107
120
|
const models = await refreshCatalog(context);
|
|
108
121
|
await context.publish({
|
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
// Neuralwatt is OpenAI-compatible, but the OpenAI SDK throws on non-2xx
|
|
2
2
|
// responses before Pi's after_provider_response hook can see the raw headers.
|
|
3
|
-
// We
|
|
4
|
-
// headers can be captured before the SDK turns them into a generic
|
|
5
|
-
//
|
|
3
|
+
// We inject a per-request `options.fetch` wrapper so 429
|
|
4
|
+
// rate-limit headers can be captured before the SDK turns them into a generic
|
|
5
|
+
// error, and tee successful SSE bodies for live quota comments, while still
|
|
6
|
+
// delegating normal streaming behavior to Pi's provider implementation.
|
|
7
|
+
// `globalThis.fetch` is never touched.
|
|
6
8
|
//
|
|
7
9
|
// The SSE tee used for live quota comments is inspired by:
|
|
8
10
|
// https://github.com/monotykamary/pi-neuralwatt-provider
|
|
9
11
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
import type {
|
|
13
|
+
AssistantMessageEventStream,
|
|
14
|
+
FetchFunction,
|
|
15
|
+
Model,
|
|
16
|
+
SimpleStreamOptions,
|
|
17
|
+
TranscriptContext,
|
|
16
18
|
} from "@earendil-works/pi-ai";
|
|
19
|
+
import { configuredApiBaseUrl } from "../../src/config/loader";
|
|
17
20
|
import {
|
|
18
21
|
type NeuralwattRateLimitInfo,
|
|
19
|
-
normalizeNeuralwattRateLimitError,
|
|
20
22
|
parseRateLimitHeaders,
|
|
21
23
|
} from "./rate-limit-error";
|
|
22
24
|
import { readQuotaCommentsFromTee } from "./sse-quotas";
|
|
23
25
|
|
|
24
26
|
export type AnyStreamSimple = (
|
|
25
27
|
model: Model<string>,
|
|
26
|
-
context:
|
|
28
|
+
context: TranscriptContext,
|
|
27
29
|
options?: SimpleStreamOptions,
|
|
28
30
|
) => AssistantMessageEventStream;
|
|
29
31
|
|
|
32
|
+
export interface NeuralwattStreamCallbacks {
|
|
33
|
+
onSseQuota: (line: string) => void;
|
|
34
|
+
onRateLimit: (info: NeuralwattRateLimitInfo | undefined) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
31
38
|
const record: Record<string, string> = {};
|
|
32
39
|
headers.forEach((value, key) => {
|
|
@@ -58,56 +65,34 @@ function isProviderStreamUrl(
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
async function forwardStream(
|
|
62
|
-
stream: AssistantMessageEventStream,
|
|
63
|
-
outer: AssistantMessageEventStream,
|
|
64
|
-
getRateLimitInfo: () => NeuralwattRateLimitInfo | undefined,
|
|
65
|
-
restoreFetch: () => void,
|
|
66
|
-
): Promise<void> {
|
|
67
|
-
try {
|
|
68
|
-
for await (const event of stream) {
|
|
69
|
-
const rateLimitInfo = getRateLimitInfo();
|
|
70
|
-
if (event.type === "error" && rateLimitInfo) {
|
|
71
|
-
outer.push({
|
|
72
|
-
...event,
|
|
73
|
-
error: normalizeNeuralwattRateLimitError(event.error, rateLimitInfo),
|
|
74
|
-
});
|
|
75
|
-
} else {
|
|
76
|
-
outer.push(event);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
} finally {
|
|
80
|
-
restoreFetch();
|
|
81
|
-
outer.end();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
68
|
export function wrapNeuralwattStreamSimple(
|
|
86
69
|
base: AnyStreamSimple,
|
|
87
|
-
|
|
70
|
+
callbacks: NeuralwattStreamCallbacks,
|
|
88
71
|
): AnyStreamSimple {
|
|
89
72
|
return (model, context, options = {}) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const response = await originalFetch(input, init);
|
|
73
|
+
const providerOrigin = new URL(model.baseUrl ?? configuredApiBaseUrl())
|
|
74
|
+
.origin;
|
|
75
|
+
const callerFetch = options.fetch;
|
|
76
|
+
|
|
77
|
+
const neuralwattFetch: FetchFunction = async (input, init) => {
|
|
78
|
+
// Chain onto a caller-provided fetch when present; otherwise resolve
|
|
79
|
+
// the global at request time so late host/test overrides still apply.
|
|
80
|
+
const response = await (callerFetch ?? globalThis.fetch)(input, init);
|
|
99
81
|
|
|
100
82
|
if (!isProviderStreamUrl(input, providerOrigin)) return response;
|
|
101
83
|
|
|
102
|
-
const headers = headersToRecord(response.headers);
|
|
103
84
|
if (response.status === 429) {
|
|
104
|
-
|
|
85
|
+
callbacks.onRateLimit(
|
|
86
|
+
parseRateLimitHeaders(headersToRecord(response.headers)),
|
|
87
|
+
);
|
|
105
88
|
return response;
|
|
106
89
|
}
|
|
107
90
|
|
|
108
91
|
if (response.ok && response.body) {
|
|
109
92
|
const [sdkBody, quotaBody] = response.body.tee();
|
|
110
|
-
|
|
93
|
+
// Best-effort side channel; failures inside are swallowed by the
|
|
94
|
+
// reader so quota comments can never break the SDK stream.
|
|
95
|
+
void readQuotaCommentsFromTee(quotaBody, callbacks.onSseQuota);
|
|
111
96
|
return new Response(sdkBody, {
|
|
112
97
|
headers: response.headers,
|
|
113
98
|
status: response.status,
|
|
@@ -118,22 +103,6 @@ export function wrapNeuralwattStreamSimple(
|
|
|
118
103
|
return response;
|
|
119
104
|
};
|
|
120
105
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const restoreFetch = () => {
|
|
124
|
-
if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
|
|
125
|
-
sseQuotaTask?.catch(() => {});
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const stream = base(model, context, options);
|
|
129
|
-
const originalOuterEnd = outer.end.bind(outer);
|
|
130
|
-
outer.end = (result?: Parameters<typeof originalOuterEnd>[0]) => {
|
|
131
|
-
restoreFetch();
|
|
132
|
-
originalOuterEnd(result);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
void forwardStream(stream, outer, () => rateLimitInfo, restoreFetch);
|
|
136
|
-
|
|
137
|
-
return outer;
|
|
106
|
+
return base(model, context, { ...options, fetch: neuralwattFetch });
|
|
138
107
|
};
|
|
139
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-neuralwatt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -38,21 +38,22 @@
|
|
|
38
38
|
"@aliou/pi-utils-ui": "^0.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-ai": ">=0.
|
|
42
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
41
|
+
"@earendil-works/pi-ai": ">=1.0.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
43
43
|
"@earendil-works/pi-tui": "*"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@aliou/biome-plugins": "^0.11.0",
|
|
47
47
|
"@biomejs/biome": "^2.4.15",
|
|
48
48
|
"@changesets/cli": "^2.27.11",
|
|
49
|
-
"@earendil-works/pi-ai": "
|
|
50
|
-
"@earendil-works/pi-coding-agent": "
|
|
51
|
-
"@earendil-works/pi-tui": "
|
|
49
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
51
|
+
"@earendil-works/pi-tui": "1.1.0",
|
|
52
52
|
"@types/node": "^25.0.10",
|
|
53
53
|
"husky": "^9.1.7",
|
|
54
54
|
"ts-json-schema-generator": "^2.4.0",
|
|
55
55
|
"tsx": "4.23.12",
|
|
56
|
+
"typebox": "1.3.27",
|
|
56
57
|
"typescript": "^5.9.3",
|
|
57
58
|
"vitest": "^4.0.18"
|
|
58
59
|
},
|
package/schema.json
CHANGED
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
"api": {
|
|
77
77
|
"$ref": "#/definitions/NeuralwattApi",
|
|
78
78
|
"description": "Which API serves model requests."
|
|
79
|
+
},
|
|
80
|
+
"apiBaseUrl": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "Fallback base URL for Neuralwatt API calls, used only by models that carry no baseUrl of their own (models.json, custom providers, and proxy routing all take precedence) and by the extension's own catalog and quota fetches. Defaults to the direct upstream."
|
|
79
83
|
}
|
|
80
84
|
},
|
|
81
85
|
"additionalProperties": false
|
package/src/config/defaults.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ResolvedNeuralwattConfig } from "./types";
|
|
2
2
|
|
|
3
|
+
export const NEURALWATT_API_BASE_URL = "https://api.neuralwatt.com/v1";
|
|
4
|
+
|
|
3
5
|
export const DEFAULT_CONFIG: ResolvedNeuralwattConfig = {
|
|
4
6
|
quotaCommand: {
|
|
5
7
|
enabled: true,
|
|
@@ -12,5 +14,6 @@ export const DEFAULT_CONFIG: ResolvedNeuralwattConfig = {
|
|
|
12
14
|
},
|
|
13
15
|
provider: {
|
|
14
16
|
api: "openai-completions",
|
|
17
|
+
apiBaseUrl: NEURALWATT_API_BASE_URL,
|
|
15
18
|
},
|
|
16
19
|
};
|
package/src/config/loader.ts
CHANGED
|
@@ -33,6 +33,7 @@ function normalizeResolvedConfig(
|
|
|
33
33
|
},
|
|
34
34
|
provider: {
|
|
35
35
|
api: resolveApi(config.provider?.api),
|
|
36
|
+
apiBaseUrl: resolveApiBaseUrl(config.provider?.apiBaseUrl),
|
|
36
37
|
},
|
|
37
38
|
};
|
|
38
39
|
}
|
|
@@ -44,6 +45,30 @@ export function resolveApi(value: string | undefined): NeuralwattApi {
|
|
|
44
45
|
return DEFAULT_CONFIG.provider.api;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
export function resolveApiBaseUrl(value: string | undefined): string {
|
|
49
|
+
const trimmed = value?.trim().replace(/\/+$/u, "");
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
return DEFAULT_CONFIG.provider.apiBaseUrl;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(trimmed);
|
|
55
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
56
|
+
return DEFAULT_CONFIG.provider.apiBaseUrl;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return DEFAULT_CONFIG.provider.apiBaseUrl;
|
|
60
|
+
}
|
|
61
|
+
return trimmed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function configuredApiBaseUrl(): string {
|
|
65
|
+
try {
|
|
66
|
+
return configLoader.getConfig().provider.apiBaseUrl;
|
|
67
|
+
} catch {
|
|
68
|
+
return DEFAULT_CONFIG.provider.apiBaseUrl;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
export const configLoader = new ConfigLoader<
|
|
48
73
|
NeuralwattConfig,
|
|
49
74
|
ResolvedNeuralwattConfig
|
package/src/config/types.ts
CHANGED
|
@@ -23,6 +23,14 @@ export type NeuralwattApi = "openai-completions" | "anthropic-messages";
|
|
|
23
23
|
export interface NeuralwattProviderConfig {
|
|
24
24
|
/** Which API serves model requests. */
|
|
25
25
|
api?: NeuralwattApi;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fallback base URL for Neuralwatt API calls, used only by models that
|
|
29
|
+
* carry no baseUrl of their own (models.json, custom providers, and proxy
|
|
30
|
+
* routing all take precedence) and by the extension's own catalog and
|
|
31
|
+
* quota fetches. Defaults to the direct upstream.
|
|
32
|
+
*/
|
|
33
|
+
apiBaseUrl?: string;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export interface NeuralwattConfig {
|
|
@@ -54,5 +62,6 @@ export interface ResolvedNeuralwattConfig {
|
|
|
54
62
|
};
|
|
55
63
|
provider: {
|
|
56
64
|
api: NeuralwattApi;
|
|
65
|
+
apiBaseUrl: string;
|
|
57
66
|
};
|
|
58
67
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { configuredApiBaseUrl } from "../config/loader";
|
|
1
2
|
import type {
|
|
2
3
|
NeuralwattApiModel,
|
|
3
4
|
NeuralwattApiModelsResponse,
|
|
@@ -5,7 +6,6 @@ import type {
|
|
|
5
6
|
import type { NeuralwattQuotas } from "../types/quota-api";
|
|
6
7
|
import type { QuotasResult } from "../types/quota-result";
|
|
7
8
|
|
|
8
|
-
const BASE_URL = "https://api.neuralwatt.com/v1";
|
|
9
9
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
10
10
|
|
|
11
11
|
function authHeaders(apiKey: string | undefined): Record<string, string> {
|
|
@@ -31,7 +31,7 @@ async function neuralwattFetch(
|
|
|
31
31
|
signal?: AbortSignal,
|
|
32
32
|
headers?: Record<string, string>,
|
|
33
33
|
): Promise<Response> {
|
|
34
|
-
return fetch(`${
|
|
34
|
+
return fetch(`${configuredApiBaseUrl()}${path}`, {
|
|
35
35
|
headers: { ...authHeaders(apiKey), ...headers },
|
|
36
36
|
signal: combineSignals(signal),
|
|
37
37
|
});
|