@aliou/pi-neuralwatt 0.16.2 → 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.
@@ -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 { NeuralwattModel } from "../models/catalog";
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: NeuralwattModel[]) {
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 ?? NEURALWATT_BASE_URL),
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) as never,
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 { NeuralwattModel } from "../models/catalog";
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: NeuralwattModel[]) =>
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 ?? NEURALWATT_BASE_URL,
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) as never,
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 { NeuralwattModel } from "../models/catalog";
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: NeuralwattModel[]): Model<Api>[];
13
+ stampModels(models: NeuralwattChatModel[]): Model<Api>[];
14
14
  stream(
15
15
  model: Model<Api>,
16
- context: Context,
16
+ context: TranscriptContext,
17
17
  options?: StreamOptions,
18
18
  ): AssistantMessageEventStream;
19
19
  streamSimple(
20
20
  model: Model<Api>,
21
- context: 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` at extension load; a saved change only
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-compatible chat/completions endpoint or the Anthropic-compatible /v1/messages endpoint",
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) return;
201
+ if (pendingApi === undefined && pendingApiBaseUrl === undefined) {
202
+ return;
203
+ }
169
204
  pendingApi = undefined;
170
- ctx.ui.notify("Run /reload to apply the new API", "info");
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 { wrapNeuralwattStreamSimple } from "./stream-simple";
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
- onSseQuota: (line: string) => void,
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
- onSseQuota,
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
- onSseQuota,
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
- registerNeuralwattProvider(pi, handleSseQuota);
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. The
152
- // streamSimple wrap (wrapNeuralwattStreamSimple) already formats a
153
- // detailed message via formatRateLimitError when it captures headers;
154
- // detect that case by the `"429 rate limit:"` prefix it emits and leave
155
- // it untouched. This branch only fires for genuinely headerless 429s
156
- // (e.g. anonymous playground limits, or a 429 from infra in front of
157
- // Neuralwatt), since after_provider_response cannot observe 429s — the
158
- // OpenAI SDK throws before Pi's onResponse hook runs.
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" &&