@aliou/pi-synthetic 0.12.0 → 0.13.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/README.md CHANGED
@@ -73,6 +73,28 @@ Check your API usage:
73
73
  /synthetic:quotas
74
74
  ```
75
75
 
76
+ ### Usage Status
77
+
78
+ When a Synthetic model is active, the footer status bar shows live quota usage (e.g. `week:82% (↺in 3d) 5h:95%`). Colors follow the same severity assessment as quota warnings: green by default, yellow/red only when projected usage is at risk. The status auto-refreshes every 60 seconds and after each turn.
79
+
80
+ ### Quota Warnings
81
+
82
+ The extension automatically notifies you when you approach or exceed your Synthetic API quotas. Notifications fire on severity transitions only (no repeated alerts for the same level) and use correct terminology (regen/tick/resets) with precise time formatting.
83
+
84
+ - Escalation always notifies
85
+ - `high` and `critical` levels have no cooldown
86
+ - `warning` level has a 60-minute cooldown
87
+
88
+ ## Disabling Features
89
+
90
+ Each feature (provider, web search, quotas command, usage status, quota warnings) is a separate Pi extension. You can disable individual features using `pi config`:
91
+
92
+ ```
93
+ pi config extensions.disabled add @aliou/pi-synthetic/quota-warnings
94
+ ```
95
+
96
+ This prevents the quota-warnings extension from loading while keeping the rest of pi-synthetic active. Replace `quota-warnings` with `web-search`, `command-quotas`, or `provider` to disable other features.
97
+
76
98
  ## Adding or Updating Models
77
99
 
78
100
  Models are hardcoded in `src/providers/models.ts`. To add or update models:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -18,7 +18,9 @@
18
18
  "./src/extensions/provider/index.ts",
19
19
  "./src/extensions/web-search/index.ts",
20
20
  "./src/extensions/command-quotas/index.ts",
21
- "./src/extensions/quota-warnings/index.ts"
21
+ "./src/extensions/sub-bar-integration/index.ts",
22
+ "./src/extensions/quota-warnings/index.ts",
23
+ "./src/extensions/usage-status/index.ts"
22
24
  ],
23
25
  "video": "https://assets.aliou.me/pi-extensions/demos/pi-synthetic.mp4"
24
26
  },
@@ -34,6 +36,7 @@
34
36
  "@mariozechner/pi-tui": "0.61.0"
35
37
  },
36
38
  "dependencies": {
39
+ "@aliou/pi-utils-settings": "^0.13.0",
37
40
  "@aliou/pi-utils-ui": "^0.1.2"
38
41
  },
39
42
  "devDependencies": {
package/src/config.ts ADDED
@@ -0,0 +1,277 @@
1
+ import {
2
+ ConfigLoader,
3
+ type Migration,
4
+ registerSettingsCommand,
5
+ type SettingsSection,
6
+ } from "@aliou/pi-utils-settings";
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { SettingItem } from "@mariozechner/pi-tui";
9
+ import pkg from "../package.json" with { type: "json" };
10
+
11
+ export type SyntheticFeatureId =
12
+ | "webSearch"
13
+ | "quotasCommand"
14
+ | "subBarIntegration"
15
+ | "usageStatus"
16
+ | "quotaWarnings";
17
+
18
+ export const SYNTHETIC_EXTENSIONS_REQUEST_EVENT =
19
+ "synthetic:extensions:request" as const;
20
+
21
+ export const SYNTHETIC_EXTENSIONS_REGISTER_EVENT =
22
+ "synthetic:extensions:register" as const;
23
+
24
+ export interface SyntheticExtensionsRegisterPayload {
25
+ feature: SyntheticFeatureId;
26
+ }
27
+
28
+ /**
29
+ * Config schema version. Stamped on disk when the initial migration runs or
30
+ * when the config is seeded. Uses the package version; bumping the package
31
+ * does not retrigger migrations (we only run them when `configVersion` is
32
+ * missing), but it records which release first created the file.
33
+ */
34
+ export const SYNTHETIC_CONFIG_VERSION: string = pkg.version;
35
+
36
+ export interface SyntheticConfig {
37
+ configVersion?: string;
38
+ webSearch?: boolean;
39
+ quotasCommand?: boolean;
40
+ usageStatus?: boolean;
41
+ quotaWarnings?: boolean;
42
+ subBarIntegration?: boolean;
43
+ }
44
+
45
+ export interface ResolvedSyntheticConfig {
46
+ configVersion: string;
47
+ webSearch: boolean;
48
+ quotasCommand: boolean;
49
+ usageStatus: boolean;
50
+ quotaWarnings: boolean;
51
+ subBarIntegration: boolean;
52
+ }
53
+
54
+ const DEFAULT_CONFIG: ResolvedSyntheticConfig = {
55
+ configVersion: SYNTHETIC_CONFIG_VERSION,
56
+ webSearch: true,
57
+ quotasCommand: true,
58
+ usageStatus: false,
59
+ quotaWarnings: false,
60
+ subBarIntegration: true,
61
+ };
62
+
63
+ // Module-level flag set when the v1 migration runs or when the global config
64
+ // is seeded for the first time. Consumed once by the provider extension to
65
+ // display a one-time notice about the new settings UI.
66
+ let pendingMigrationNotice = false;
67
+
68
+ export function hasPendingMigrationNotice(): boolean {
69
+ return pendingMigrationNotice;
70
+ }
71
+
72
+ export function clearPendingMigrationNotice(): void {
73
+ pendingMigrationNotice = false;
74
+ }
75
+
76
+ function markMigrationNoticePending(): void {
77
+ pendingMigrationNotice = true;
78
+ }
79
+
80
+ const migrations: Migration<SyntheticConfig>[] = [
81
+ {
82
+ name: "seed-defaults",
83
+ shouldRun: (config) => config.configVersion === undefined,
84
+ run: (config) => {
85
+ markMigrationNoticePending();
86
+ return {
87
+ configVersion: SYNTHETIC_CONFIG_VERSION,
88
+ webSearch: config.webSearch ?? DEFAULT_CONFIG.webSearch,
89
+ quotasCommand: config.quotasCommand ?? DEFAULT_CONFIG.quotasCommand,
90
+ usageStatus: config.usageStatus ?? DEFAULT_CONFIG.usageStatus,
91
+ quotaWarnings: config.quotaWarnings ?? DEFAULT_CONFIG.quotaWarnings,
92
+ subBarIntegration:
93
+ config.subBarIntegration ?? DEFAULT_CONFIG.subBarIntegration,
94
+ };
95
+ },
96
+ },
97
+ ];
98
+
99
+ const QUOTA_WARNING_THRESHOLDS_DESCRIPTION =
100
+ "Toggle warnings when your quotas reach thresholds. Thresholds: warning at 80% projected usage, high at 90%, critical at 100% for fixed windows; dynamic windows use adaptive projected thresholds based on window progress.";
101
+
102
+ export const configLoader = new ConfigLoader<
103
+ SyntheticConfig,
104
+ ResolvedSyntheticConfig
105
+ >("synthetic", DEFAULT_CONFIG, { migrations });
106
+
107
+ /**
108
+ * Seed the global config file on first use. When no config file exists in
109
+ * any scope, this writes the current defaults (with configVersion) to the
110
+ * global scope and flags the migration notice as pending.
111
+ *
112
+ * Must be called after `configLoader.load()`.
113
+ */
114
+ export async function seedSyntheticConfigIfMissing(): Promise<void> {
115
+ if (configLoader.hasConfig("global") || configLoader.hasConfig("local")) {
116
+ return;
117
+ }
118
+ markMigrationNoticePending();
119
+ try {
120
+ await configLoader.save("global", {
121
+ configVersion: SYNTHETIC_CONFIG_VERSION,
122
+ webSearch: DEFAULT_CONFIG.webSearch,
123
+ quotasCommand: DEFAULT_CONFIG.quotasCommand,
124
+ usageStatus: DEFAULT_CONFIG.usageStatus,
125
+ quotaWarnings: DEFAULT_CONFIG.quotaWarnings,
126
+ subBarIntegration: DEFAULT_CONFIG.subBarIntegration,
127
+ });
128
+ } catch {
129
+ // If the write fails, keep the notice pending so the user still sees it.
130
+ }
131
+ }
132
+
133
+ export const SYNTHETIC_CONFIG_UPDATED_EVENT =
134
+ "synthetic:config:updated" as const;
135
+
136
+ export interface SyntheticConfigUpdatedPayload {
137
+ config: ResolvedSyntheticConfig;
138
+ }
139
+
140
+ export function emitSyntheticConfigUpdated(pi: ExtensionAPI): void {
141
+ pi.events.emit(SYNTHETIC_CONFIG_UPDATED_EVENT, {
142
+ config: configLoader.getConfig(),
143
+ });
144
+ }
145
+
146
+ export interface RegisterSyntheticSettingsOptions {
147
+ getLoadedFeatures: () => Set<SyntheticFeatureId>;
148
+ }
149
+
150
+ function featureRow(
151
+ id: SyntheticFeatureId,
152
+ label: string,
153
+ description: string,
154
+ configValue: boolean,
155
+ isLoaded: boolean,
156
+ ): SettingItem {
157
+ if (isLoaded) {
158
+ return {
159
+ id,
160
+ label,
161
+ description,
162
+ currentValue: configValue ? "enabled" : "disabled",
163
+ values: ["enabled", "disabled"],
164
+ };
165
+ }
166
+ return {
167
+ id,
168
+ label,
169
+ description: `${description} (Not loaded by Pi)`,
170
+ currentValue: "unavailable",
171
+ values: [],
172
+ };
173
+ }
174
+
175
+ export function registerSyntheticSettings(
176
+ pi: ExtensionAPI,
177
+ options: RegisterSyntheticSettingsOptions,
178
+ ): void {
179
+ const { getLoadedFeatures } = options;
180
+
181
+ registerSettingsCommand<SyntheticConfig, ResolvedSyntheticConfig>(pi, {
182
+ commandName: "synthetic:settings",
183
+ commandDescription: "Configure Synthetic extension settings",
184
+ title: "Synthetic Settings",
185
+ configStore: configLoader,
186
+ buildSections: (tabConfig, resolved): SettingsSection[] => {
187
+ const loaded = getLoadedFeatures();
188
+ const webSearch = tabConfig?.webSearch ?? resolved.webSearch;
189
+ const quotasCommand = tabConfig?.quotasCommand ?? resolved.quotasCommand;
190
+ const usageStatus = tabConfig?.usageStatus ?? resolved.usageStatus;
191
+ const quotaWarnings = tabConfig?.quotaWarnings ?? resolved.quotaWarnings;
192
+ const subBarIntegration =
193
+ tabConfig?.subBarIntegration ?? resolved.subBarIntegration;
194
+
195
+ const sections: SettingsSection[] = [];
196
+
197
+ sections.push(
198
+ {
199
+ label: "Tools",
200
+ items: [
201
+ featureRow(
202
+ "webSearch",
203
+ "Web Search",
204
+ "Toggle `synthetic_web_search`, a tool for searching online with zero data retention",
205
+ webSearch,
206
+ loaded.has("webSearch"),
207
+ ),
208
+ ],
209
+ },
210
+ {
211
+ label: "Quotas",
212
+ items: [
213
+ featureRow(
214
+ "quotasCommand",
215
+ "Quotas Command",
216
+ "Toggle the `/synthetic:quotas` command, showing your quotas at a glance",
217
+ quotasCommand,
218
+ loaded.has("quotasCommand"),
219
+ ),
220
+ featureRow(
221
+ "usageStatus",
222
+ "Usage widget",
223
+ "Toggle the usage widget, showing your usage at a glance",
224
+ usageStatus,
225
+ loaded.has("usageStatus"),
226
+ ),
227
+ featureRow(
228
+ "quotaWarnings",
229
+ "Quota Warnings",
230
+ QUOTA_WARNING_THRESHOLDS_DESCRIPTION,
231
+ quotaWarnings,
232
+ loaded.has("quotaWarnings"),
233
+ ),
234
+ ],
235
+ },
236
+ {
237
+ label: "Integration",
238
+ items: [
239
+ featureRow(
240
+ "subBarIntegration",
241
+ "pi-sub-bar integration",
242
+ "Integration with `@marckrenn/pi-sub-bar`",
243
+ subBarIntegration,
244
+ loaded.has("subBarIntegration"),
245
+ ),
246
+ ],
247
+ },
248
+ );
249
+
250
+ return sections;
251
+ },
252
+ onSettingChange: (id, newValue, config) => {
253
+ if (!getLoadedFeatures().has(id as SyntheticFeatureId)) {
254
+ return null;
255
+ }
256
+
257
+ const enabled = newValue === "enabled";
258
+ switch (id) {
259
+ case "webSearch":
260
+ return { ...config, webSearch: enabled };
261
+ case "quotasCommand":
262
+ return { ...config, quotasCommand: enabled };
263
+ case "usageStatus":
264
+ return { ...config, usageStatus: enabled };
265
+ case "quotaWarnings":
266
+ return { ...config, quotaWarnings: enabled };
267
+ case "subBarIntegration":
268
+ return { ...config, subBarIntegration: enabled };
269
+ default:
270
+ return null;
271
+ }
272
+ },
273
+ onSave: async () => {
274
+ emitSyntheticConfigUpdated(pi);
275
+ },
276
+ });
277
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { configLoader } from "../../config";
2
3
  import { getSyntheticApiKey } from "../../lib/env";
3
4
  import { fetchQuotas } from "../../utils/quotas";
4
5
  import { QuotasComponent } from "./components/quotas-display";
@@ -10,6 +11,14 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
10
11
  pi.registerCommand("synthetic:quotas", {
11
12
  description: "Display Synthetic API usage quotas",
12
13
  handler: async (_args, ctx) => {
14
+ if (!configLoader.getConfig().quotasCommand) {
15
+ ctx.ui.notify(
16
+ "Synthetic quotas command is disabled. Restart Pi to unload the command after re-enabling or disabling it.",
17
+ "warning",
18
+ );
19
+ return;
20
+ }
21
+
13
22
  const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
14
23
  if (!apiKey) {
15
24
  ctx.ui.notify(MISSING_AUTH_MESSAGE, "warning");
@@ -150,7 +150,6 @@ export class QuotasComponent implements Component {
150
150
  width,
151
151
  ),
152
152
  );
153
- lines.push("");
154
153
 
155
154
  switch (this.state.type) {
156
155
  case "loading":
@@ -186,6 +185,8 @@ export class QuotasComponent implements Component {
186
185
  const windows = toWindows(quotas);
187
186
  const barWidth = Math.min(50, Math.max(20, contentWidth - 20));
188
187
 
188
+ lines.push("");
189
+
189
190
  for (const window of windows) {
190
191
  lines.push(...this.renderWindow(window, barWidth, maxWidth));
191
192
  lines.push("");
@@ -1,8 +1,23 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ configLoader,
4
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
5
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
6
+ } from "../../config";
2
7
  import { registerQuotasCommand } from "./command";
3
- import { registerSubIntegration } from "./sub-integration";
4
8
 
5
9
  export default async function (pi: ExtensionAPI) {
6
- registerQuotasCommand(pi);
7
- registerSubIntegration(pi);
10
+ await configLoader.load();
11
+
12
+ const config = configLoader.getConfig();
13
+
14
+ if (config.quotasCommand) {
15
+ registerQuotasCommand(pi);
16
+ }
17
+
18
+ pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
19
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
20
+ feature: "quotasCommand",
21
+ });
22
+ });
8
23
  }
@@ -1,6 +1,59 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
+ import {
4
+ clearPendingMigrationNotice,
5
+ configLoader,
6
+ emitSyntheticConfigUpdated,
7
+ hasPendingMigrationNotice,
8
+ registerSyntheticSettings,
9
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
10
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
11
+ type SyntheticExtensionsRegisterPayload,
12
+ type SyntheticFeatureId,
13
+ seedSyntheticConfigIfMissing,
14
+ } from "../../config";
2
15
  import { SYNTHETIC_MODELS } from "./models";
3
16
 
17
+ const MIGRATION_NOTICE_MESSAGE_TYPE = "synthetic:migration-notice";
18
+ const MIGRATION_NOTICE_TITLE = "pi-synthetic";
19
+ const MIGRATION_NOTICE_CONTENT = [
20
+ "New optional features added to `pi-synthetic`:",
21
+ "- Usage widget",
22
+ "- Quotas warnings",
23
+ "",
24
+ "Enable them either with `pi config` or inside of `pi` with `/synthetic:settings`.",
25
+ ].join("\n");
26
+
27
+ /** Wrap lines in a rounded Unicode frame with 1-char inner padding. */
28
+ function wrapInRoundedBorder(
29
+ lines: string[],
30
+ width: number,
31
+ colorFn: (text: string) => string,
32
+ ): string[] {
33
+ const innerWidth = Math.max(1, width - 2);
34
+ const hBar = "\u2500".repeat(innerWidth);
35
+ const top = colorFn(`\u256D${hBar}\u256E`);
36
+ const bottom = colorFn(`\u2570${hBar}\u256F`);
37
+ const left = colorFn("\u2502");
38
+ const right = colorFn("\u2502");
39
+
40
+ const wrapped = lines.map((line) => {
41
+ const contentWidth = visibleWidth(line);
42
+ const fill = Math.max(0, innerWidth - contentWidth);
43
+ return `${left}${line}${" ".repeat(fill)}${right}`;
44
+ });
45
+
46
+ return [top, ...wrapped, bottom];
47
+ }
48
+
49
+ /** Highlight `backtick-wrapped` spans using the accent color. */
50
+ function highlightInlineCode(
51
+ text: string,
52
+ colorFn: (text: string) => string,
53
+ ): string {
54
+ return text.replace(/`([^`]+)`/g, (_, code) => colorFn(code));
55
+ }
56
+
4
57
  export function registerSyntheticProvider(pi: ExtensionAPI): void {
5
58
  pi.registerProvider("synthetic", {
6
59
  baseUrl: "https://api.synthetic.new/openai/v1",
@@ -10,14 +63,8 @@ export function registerSyntheticProvider(pi: ExtensionAPI): void {
10
63
  Referer: "https://pi.dev",
11
64
  "X-Title": "npm:@aliou/pi-synthetic",
12
65
  },
13
- models: SYNTHETIC_MODELS.map((model) => ({
14
- id: model.id,
15
- name: model.name,
16
- reasoning: model.reasoning,
17
- input: model.input,
18
- cost: model.cost,
19
- contextWindow: model.contextWindow,
20
- maxTokens: model.maxTokens,
66
+ models: SYNTHETIC_MODELS.map(({ provider: _provider, ...model }) => ({
67
+ ...model,
21
68
  compat: {
22
69
  supportsDeveloperRole: false,
23
70
  maxTokensField: "max_tokens",
@@ -28,5 +75,65 @@ export function registerSyntheticProvider(pi: ExtensionAPI): void {
28
75
  }
29
76
 
30
77
  export default async function (pi: ExtensionAPI) {
78
+ await configLoader.load();
79
+ await seedSyntheticConfigIfMissing();
31
80
  registerSyntheticProvider(pi);
81
+
82
+ pi.registerMessageRenderer(
83
+ MIGRATION_NOTICE_MESSAGE_TYPE,
84
+ (message, _options, theme) => {
85
+ const rawContent =
86
+ typeof message.content === "string"
87
+ ? message.content
88
+ : MIGRATION_NOTICE_CONTENT;
89
+ const accent = (t: string) => theme.fg("accent", t);
90
+ const borderColor = accent;
91
+ const title = theme.bold(accent(MIGRATION_NOTICE_TITLE));
92
+ const body = highlightInlineCode(rawContent, accent);
93
+
94
+ return {
95
+ render(width: number) {
96
+ // border (2) + inner padding (2)
97
+ const contentWidth = Math.max(1, width - 4);
98
+ const bodyLines = wrapTextWithAnsi(body, contentWidth);
99
+ const lines = [title, "", ...bodyLines];
100
+ const padded = lines.map((line) => ` ${line} `);
101
+ return wrapInRoundedBorder(padded, width, borderColor);
102
+ },
103
+ handleInput() {
104
+ return false;
105
+ },
106
+ invalidate() {},
107
+ };
108
+ },
109
+ );
110
+
111
+ const loadedFeatures = new Set<SyntheticFeatureId>();
112
+
113
+ pi.events.on(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
114
+ const { feature } = data as SyntheticExtensionsRegisterPayload;
115
+ loadedFeatures.add(feature);
116
+ });
117
+
118
+ registerSyntheticSettings(pi, {
119
+ getLoadedFeatures: () => loadedFeatures,
120
+ });
121
+
122
+ pi.on("session_start", async () => {
123
+ loadedFeatures.clear();
124
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, undefined);
125
+ emitSyntheticConfigUpdated(pi);
126
+
127
+ if (hasPendingMigrationNotice()) {
128
+ clearPendingMigrationNotice();
129
+ pi.sendMessage(
130
+ {
131
+ customType: MIGRATION_NOTICE_MESSAGE_TYPE,
132
+ content: MIGRATION_NOTICE_CONTENT,
133
+ display: true,
134
+ },
135
+ { triggerTurn: false },
136
+ );
137
+ }
138
+ });
32
139
  }
@@ -4,6 +4,7 @@ import { SYNTHETIC_MODELS } from "./models";
4
4
  interface ApiModel {
5
5
  id: string;
6
6
  name: string;
7
+ provider: string | null;
7
8
  input_modalities: string[];
8
9
  output_modalities: string[];
9
10
  context_length: number;
@@ -156,6 +157,19 @@ function compareModels(
156
157
  });
157
158
  }
158
159
  }
160
+
161
+ // Check provider
162
+ if (
163
+ apiModel.provider !== null &&
164
+ apiModel.provider !== hardcoded.provider
165
+ ) {
166
+ discrepancies.push({
167
+ model: hardcoded.id,
168
+ field: "provider",
169
+ hardcoded: hardcoded.provider,
170
+ api: apiModel.provider,
171
+ });
172
+ }
159
173
  }
160
174
 
161
175
  // Check for API models not in hardcoded list
@@ -2,29 +2,11 @@
2
2
  // Source: https://api.synthetic.new/openai/v1/models
3
3
  // maxTokens sourced from https://models.dev/api.json (synthetic provider)
4
4
 
5
- export interface SyntheticModelConfig {
6
- id: string;
7
- name: string;
8
- reasoning: boolean;
9
- input: ("text" | "image")[];
10
- cost: {
11
- input: number;
12
- output: number;
13
- cacheRead: number;
14
- cacheWrite: number;
15
- };
16
- contextWindow: number;
17
- maxTokens: number;
18
- compat?: {
19
- supportsDeveloperRole?: boolean;
20
- supportsReasoningEffort?: boolean;
21
- reasoningEffortMap?: Partial<
22
- Record<"minimal" | "low" | "medium" | "high" | "xhigh", string>
23
- >;
24
- maxTokensField?: "max_completion_tokens" | "max_tokens";
25
- requiresToolResultName?: boolean;
26
- requiresMistralToolIds?: boolean;
27
- };
5
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
6
+
7
+ export interface SyntheticModelConfig extends ProviderModelConfig {
8
+ /** Upstream backend Synthetic proxies this model through (e.g. "fireworks", "together", "synthetic"). */
9
+ provider: string;
28
10
  }
29
11
 
30
12
  const SYNTHETIC_REASONING_EFFORT_MAP = {
@@ -40,6 +22,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
40
22
  {
41
23
  id: "hf:zai-org/GLM-4.7",
42
24
  name: "zai-org/GLM-4.7",
25
+ provider: "synthetic",
43
26
  reasoning: true,
44
27
  compat: {
45
28
  supportsReasoningEffort: true,
@@ -59,6 +42,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
59
42
  {
60
43
  id: "hf:zai-org/GLM-5",
61
44
  name: "zai-org/GLM-5",
45
+ provider: "synthetic",
62
46
  reasoning: true,
63
47
  compat: {
64
48
  supportsReasoningEffort: true,
@@ -78,6 +62,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
78
62
  {
79
63
  id: "hf:zai-org/GLM-5.1",
80
64
  name: "zai-org/GLM-5.1",
65
+ provider: "synthetic",
81
66
  reasoning: true,
82
67
  compat: {
83
68
  supportsReasoningEffort: true,
@@ -98,6 +83,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
98
83
  {
99
84
  id: "hf:zai-org/GLM-4.7-Flash",
100
85
  name: "zai-org/GLM-4.7-Flash",
86
+ provider: "synthetic",
101
87
  reasoning: true,
102
88
  compat: {
103
89
  supportsReasoningEffort: true,
@@ -117,6 +103,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
117
103
  {
118
104
  id: "hf:MiniMaxAI/MiniMax-M2.1",
119
105
  name: "MiniMaxAI/MiniMax-M2.1",
106
+ provider: "fireworks",
120
107
  reasoning: true,
121
108
  compat: {
122
109
  supportsReasoningEffort: true,
@@ -136,6 +123,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
136
123
  {
137
124
  id: "hf:meta-llama/Llama-3.3-70B-Instruct",
138
125
  name: "meta-llama/Llama-3.3-70B-Instruct",
126
+ provider: "together",
139
127
  reasoning: false,
140
128
  input: ["text"],
141
129
  cost: {
@@ -151,6 +139,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
151
139
  {
152
140
  id: "hf:deepseek-ai/DeepSeek-R1-0528",
153
141
  name: "deepseek-ai/DeepSeek-R1-0528",
142
+ provider: "together",
154
143
  reasoning: true,
155
144
  compat: {
156
145
  supportsReasoningEffort: true,
@@ -170,6 +159,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
170
159
  {
171
160
  id: "hf:deepseek-ai/DeepSeek-V3.2",
172
161
  name: "deepseek-ai/DeepSeek-V3.2",
162
+ provider: "fireworks",
173
163
  reasoning: false,
174
164
  input: ["text"],
175
165
  cost: {
@@ -185,6 +175,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
185
175
  {
186
176
  id: "hf:moonshotai/Kimi-K2-Instruct-0905",
187
177
  name: "moonshotai/Kimi-K2-Instruct-0905",
178
+ provider: "fireworks",
188
179
  reasoning: false,
189
180
  input: ["text"],
190
181
  cost: {
@@ -200,6 +191,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
200
191
  {
201
192
  id: "hf:moonshotai/Kimi-K2-Thinking",
202
193
  name: "moonshotai/Kimi-K2-Thinking",
194
+ provider: "fireworks",
203
195
  reasoning: true,
204
196
  compat: {
205
197
  supportsReasoningEffort: true,
@@ -219,6 +211,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
219
211
  {
220
212
  id: "hf:openai/gpt-oss-120b",
221
213
  name: "openai/gpt-oss-120b",
214
+ provider: "fireworks",
222
215
  reasoning: false,
223
216
  input: ["text"],
224
217
  cost: {
@@ -234,6 +227,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
234
227
  {
235
228
  id: "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct",
236
229
  name: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
230
+ provider: "together",
237
231
  reasoning: true,
238
232
  compat: {
239
233
  supportsReasoningEffort: true,
@@ -253,6 +247,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
253
247
  {
254
248
  id: "hf:moonshotai/Kimi-K2.5",
255
249
  name: "moonshotai/Kimi-K2.5",
250
+ provider: "synthetic",
256
251
  reasoning: true,
257
252
  compat: {
258
253
  supportsReasoningEffort: true,
@@ -272,6 +267,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
272
267
  {
273
268
  id: "hf:nvidia/Kimi-K2.5-NVFP4",
274
269
  name: "nvidia/Kimi-K2.5-NVFP4",
270
+ provider: "synthetic",
275
271
  reasoning: true,
276
272
  compat: {
277
273
  supportsReasoningEffort: true,
@@ -291,6 +287,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
291
287
  {
292
288
  id: "hf:deepseek-ai/DeepSeek-V3",
293
289
  name: "deepseek-ai/DeepSeek-V3",
290
+ provider: "together",
294
291
  reasoning: false,
295
292
  input: ["text"],
296
293
  cost: {
@@ -306,6 +303,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
306
303
  {
307
304
  id: "hf:Qwen/Qwen3-235B-A22B-Thinking-2507",
308
305
  name: "Qwen/Qwen3-235B-A22B-Thinking-2507",
306
+ provider: "together",
309
307
  reasoning: true,
310
308
  compat: {
311
309
  supportsReasoningEffort: true,
@@ -325,6 +323,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
325
323
  {
326
324
  id: "hf:Qwen/Qwen3.5-397B-A17B",
327
325
  name: "Qwen/Qwen3.5-397B-A17B",
326
+ provider: "together",
328
327
  reasoning: true,
329
328
  compat: {
330
329
  supportsReasoningEffort: true,
@@ -344,6 +343,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
344
343
  {
345
344
  id: "hf:MiniMaxAI/MiniMax-M2.5",
346
345
  name: "MiniMaxAI/MiniMax-M2.5",
346
+ provider: "synthetic",
347
347
  reasoning: true,
348
348
  input: ["text"],
349
349
  cost: {
@@ -364,6 +364,7 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
364
364
  {
365
365
  id: "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
366
366
  name: "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
367
+ provider: "synthetic",
367
368
  reasoning: true,
368
369
  compat: {
369
370
  supportsReasoningEffort: true,
@@ -1,22 +1,58 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ configLoader,
4
+ SYNTHETIC_CONFIG_UPDATED_EVENT,
5
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
6
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
7
+ type SyntheticConfigUpdatedPayload,
8
+ } from "../../config";
2
9
  import { clearAlertState, triggerCheck } from "./notifier";
3
10
 
4
11
  export default async function (pi: ExtensionAPI) {
5
- // Session start: reset local warning state and run an immediate check
12
+ await configLoader.load();
13
+
14
+ let enabled = configLoader.getConfig().quotaWarnings;
15
+ let currentModel: { provider: string; id: string } | undefined;
16
+ let currentContext: Parameters<typeof triggerCheck>[0] | undefined;
17
+
18
+ pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
19
+ enabled = (data as SyntheticConfigUpdatedPayload).config.quotaWarnings;
20
+
21
+ if (!enabled) {
22
+ clearAlertState();
23
+ return;
24
+ }
25
+
26
+ if (currentContext && currentModel?.provider === "synthetic") {
27
+ clearAlertState();
28
+ triggerCheck(currentContext, currentModel, false);
29
+ }
30
+ });
31
+
6
32
  pi.on("session_start", async (_event, ctx) => {
7
- if (ctx.model?.provider !== "synthetic") return;
33
+ currentContext = ctx;
34
+ currentModel = ctx.model;
35
+ if (!enabled || ctx.model?.provider !== "synthetic") return;
8
36
  clearAlertState();
9
37
  triggerCheck(ctx, ctx.model, false);
10
38
  });
11
39
 
12
- // Check after agent turn - only warn for newly crossed thresholds
13
40
  pi.on("agent_end", async (_event, ctx) => {
14
- if (ctx.model?.provider !== "synthetic") return;
41
+ currentContext = ctx;
42
+ currentModel = ctx.model;
43
+ if (!enabled || ctx.model?.provider !== "synthetic") return;
15
44
  triggerCheck(ctx, ctx.model, true);
16
45
  });
17
46
 
18
- // Clear state on shutdown
19
47
  pi.on("session_shutdown", async () => {
48
+ currentContext = undefined;
49
+ currentModel = undefined;
20
50
  clearAlertState();
21
51
  });
52
+
53
+ pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
54
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
55
+ feature: "quotaWarnings",
56
+ });
57
+ });
22
58
  }
@@ -1,4 +1,11 @@
1
1
  import type { AuthStorage, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ configLoader,
4
+ SYNTHETIC_CONFIG_UPDATED_EVENT,
5
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
6
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
7
+ type SyntheticConfigUpdatedPayload,
8
+ } from "../../config";
2
9
  import { getSyntheticApiKey } from "../../lib/env";
3
10
  import type { QuotasResponse } from "../../types/quotas";
4
11
  import { fetchQuotas, formatResetTime } from "../../utils/quotas";
@@ -28,7 +35,6 @@ interface SubCoreSettingsPayload {
28
35
  function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
29
36
  const windows: RateWindow[] = [];
30
37
 
31
- // Weekly token limit (credits-based)
32
38
  if (quotas.weeklyTokenLimit) {
33
39
  const { weeklyTokenLimit } = quotas;
34
40
  windows.push({
@@ -41,7 +47,6 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
41
47
  });
42
48
  }
43
49
 
44
- // Rolling 5-hour limit (request-based)
45
50
  if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
46
51
  const { rollingFiveHourLimit } = quotas;
47
52
  const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
@@ -55,7 +60,6 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
55
60
  });
56
61
  }
57
62
 
58
- // Legacy subscription (fallback if rollingFiveHourLimit not available)
59
63
  if (
60
64
  !quotas.rollingFiveHourLimit &&
61
65
  quotas.subscription?.limit &&
@@ -117,15 +121,16 @@ async function emitCurrentUsage(
117
121
  });
118
122
  }
119
123
 
120
- export function registerSubIntegration(pi: ExtensionAPI): void {
124
+ export function registerSubBarIntegration(pi: ExtensionAPI): void {
121
125
  let interval: NodeJS.Timeout | undefined;
122
126
  let refreshMs = 60000;
123
127
  let subCoreReady = false;
124
128
  let currentProvider: string | undefined;
125
129
  let currentAuthStorage: AuthStorage | undefined;
130
+ let enabled = configLoader.getConfig().subBarIntegration;
126
131
 
127
132
  function isSynthetic(): boolean {
128
- return currentProvider === "synthetic";
133
+ return enabled && currentProvider === "synthetic";
129
134
  }
130
135
 
131
136
  function stop(): void {
@@ -148,24 +153,33 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
148
153
  interval.unref?.();
149
154
  }
150
155
 
151
- // Custom events (inter-extension bus)
156
+ pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
157
+ enabled = (data as SyntheticConfigUpdatedPayload).config.subBarIntegration;
158
+
159
+ if (!enabled) {
160
+ stop();
161
+ return;
162
+ }
163
+
164
+ if (subCoreReady && currentAuthStorage && currentProvider === "synthetic") {
165
+ startPolling(currentAuthStorage);
166
+ }
167
+ });
168
+
152
169
  pi.events.on("sub-core:ready", () => {
153
170
  subCoreReady = true;
154
- // Polling starts in session_start/model_select when provider is synthetic
155
171
  });
156
172
 
157
173
  pi.events.on("sub-core:settings:updated", (data: unknown) => {
158
174
  const payload = data as SubCoreSettingsPayload;
159
175
  if (payload.settings?.behavior?.refreshInterval) {
160
176
  refreshMs = payload.settings.behavior.refreshInterval * 1000;
161
- // Restart with new interval if currently running
162
177
  if (interval && isSynthetic() && currentAuthStorage) {
163
178
  startPolling(currentAuthStorage);
164
179
  }
165
180
  }
166
181
  });
167
182
 
168
- // Lifecycle events
169
183
  pi.on("session_start", async (_event, ctx) => {
170
184
  currentProvider = ctx.model?.provider;
171
185
  currentAuthStorage = ctx.modelRegistry.authStorage;
@@ -200,3 +214,14 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
200
214
  stop();
201
215
  });
202
216
  }
217
+
218
+ export default async function (pi: ExtensionAPI) {
219
+ await configLoader.load();
220
+ registerSubBarIntegration(pi);
221
+
222
+ pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
223
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
224
+ feature: "subBarIntegration",
225
+ });
226
+ });
227
+ }
@@ -0,0 +1,245 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import {
6
+ configLoader,
7
+ SYNTHETIC_CONFIG_UPDATED_EVENT,
8
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
9
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
10
+ type SyntheticConfigUpdatedPayload,
11
+ } from "../../config";
12
+ import { getSyntheticApiKey } from "../../lib/env";
13
+ import type { QuotasResponse } from "../../types/quotas";
14
+ import { fetchQuotas, formatResetTime } from "../../utils/quotas";
15
+ import {
16
+ assessWindow,
17
+ getSeverityColor,
18
+ type RiskSeverity,
19
+ toWindows,
20
+ } from "../../utils/quotas-severity";
21
+
22
+ const EXTENSION_ID = "synthetic-usage";
23
+ const REFRESH_INTERVAL_MS = 60_000;
24
+
25
+ type WindowStatus = {
26
+ label: string;
27
+ usedPercent: number;
28
+ severity: RiskSeverity;
29
+ resetsAt: string | null;
30
+ limited: boolean;
31
+ };
32
+
33
+ function parseSnapshot(quotas: QuotasResponse): WindowStatus[] {
34
+ const windows = toWindows(quotas);
35
+ return windows.map((w) => {
36
+ const assessment = assessWindow(w);
37
+ return {
38
+ label: w.label,
39
+ usedPercent: w.usedPercent,
40
+ severity: assessment.severity,
41
+ resetsAt: w.resetsAt.toISOString(),
42
+ limited: w.limited ?? false,
43
+ };
44
+ });
45
+ }
46
+
47
+ const SHORT_LABELS: Record<string, string> = {
48
+ "Credits / week": "week",
49
+ "Requests / 5h": "5h",
50
+ "Search / hour": "search",
51
+ "Free Tool Calls / day": "tools",
52
+ };
53
+
54
+ function formatStatus(ctx: ExtensionContext, windows: WindowStatus[]): string {
55
+ const theme = ctx.ui.theme;
56
+ const parts: string[] = [];
57
+
58
+ for (const w of windows) {
59
+ const short = SHORT_LABELS[w.label] ?? w.label;
60
+ const remaining = Math.max(
61
+ 0,
62
+ Math.min(100, Math.round(100 - w.usedPercent)),
63
+ );
64
+ const color = getSeverityColor(w.severity);
65
+ const pctText = theme.fg(color, `${remaining}%`);
66
+ const reset = w.resetsAt
67
+ ? theme.fg("dim", ` (\u21ba${formatResetTime(w.resetsAt)})`)
68
+ : "";
69
+ const limitTag = w.limited ? theme.fg("error", " [limited]") : "";
70
+ parts.push(`${theme.fg("dim", `${short}:`)}${pctText}${reset}${limitTag}`);
71
+ }
72
+
73
+ return parts.join(" ");
74
+ }
75
+
76
+ function createStatusRefresher() {
77
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
78
+ let activeContext: ExtensionContext | undefined;
79
+ let isRefreshInFlight = false;
80
+ let queuedRefresh = false;
81
+ let lastSnapshot: WindowStatus[] | undefined;
82
+
83
+ async function updateFooterStatus(ctx: ExtensionContext): Promise<void> {
84
+ if (!ctx.hasUI) return;
85
+ if (isRefreshInFlight) {
86
+ queuedRefresh = true;
87
+ return;
88
+ }
89
+ isRefreshInFlight = true;
90
+ try {
91
+ const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
92
+ if (!apiKey) {
93
+ lastSnapshot = undefined;
94
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
95
+ return;
96
+ }
97
+ const result = await fetchQuotas(apiKey);
98
+ if (!result.success) {
99
+ ctx.ui.setStatus(
100
+ EXTENSION_ID,
101
+ ctx.ui.theme.fg("warning", "usage unavailable"),
102
+ );
103
+ return;
104
+ }
105
+ const windows = parseSnapshot(result.data.quotas);
106
+ lastSnapshot = windows;
107
+ if (windows.length === 0) {
108
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
109
+ return;
110
+ }
111
+ ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, windows));
112
+ } catch {
113
+ ctx.ui.setStatus(
114
+ EXTENSION_ID,
115
+ ctx.ui.theme.fg("warning", "usage unavailable"),
116
+ );
117
+ } finally {
118
+ isRefreshInFlight = false;
119
+ if (queuedRefresh) {
120
+ queuedRefresh = false;
121
+ void updateFooterStatus(ctx);
122
+ }
123
+ }
124
+ }
125
+
126
+ function refreshFor(ctx: ExtensionContext): Promise<void> {
127
+ activeContext = ctx;
128
+ return updateFooterStatus(ctx);
129
+ }
130
+
131
+ function startAutoRefresh(): void {
132
+ if (refreshTimer) clearInterval(refreshTimer);
133
+ refreshTimer = setInterval(() => {
134
+ if (!activeContext) return;
135
+ void updateFooterStatus(activeContext);
136
+ }, REFRESH_INTERVAL_MS);
137
+ refreshTimer.unref?.();
138
+ }
139
+
140
+ function stopAutoRefresh(ctx?: ExtensionContext): void {
141
+ if (refreshTimer) {
142
+ clearInterval(refreshTimer);
143
+ refreshTimer = undefined;
144
+ }
145
+ ctx?.ui.setStatus(EXTENSION_ID, undefined);
146
+ }
147
+
148
+ async function setLoadingStatus(ctx: ExtensionContext): Promise<void> {
149
+ if (!ctx.hasUI) return;
150
+ const apiKey = await getSyntheticApiKey(
151
+ ctx.modelRegistry.authStorage,
152
+ ).catch(() => undefined);
153
+ if (!apiKey) {
154
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
155
+ return;
156
+ }
157
+ ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("dim", "loading usage..."));
158
+ }
159
+
160
+ function renderFromLastSnapshot(ctx: ExtensionContext): boolean {
161
+ if (!ctx.hasUI || !lastSnapshot) return false;
162
+ ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, lastSnapshot));
163
+ return true;
164
+ }
165
+
166
+ return {
167
+ refreshFor,
168
+ startAutoRefresh,
169
+ stopAutoRefresh,
170
+ setLoadingStatus,
171
+ renderFromLastSnapshot,
172
+ };
173
+ }
174
+
175
+ export default async function (pi: ExtensionAPI) {
176
+ await configLoader.load();
177
+
178
+ const refresher = createStatusRefresher();
179
+ let enabled = configLoader.getConfig().usageStatus;
180
+ let currentContext: ExtensionContext | undefined;
181
+ let currentProvider: string | undefined;
182
+
183
+ pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
184
+ enabled = (data as SyntheticConfigUpdatedPayload).config.usageStatus;
185
+
186
+ if (!enabled) {
187
+ refresher.stopAutoRefresh(currentContext);
188
+ return;
189
+ }
190
+
191
+ if (currentContext && currentProvider === "synthetic") {
192
+ refresher.startAutoRefresh();
193
+ void refresher.refreshFor(currentContext);
194
+ }
195
+ });
196
+
197
+ pi.on("session_start", async (_event, ctx) => {
198
+ currentContext = ctx;
199
+ currentProvider = ctx.model?.provider;
200
+ if (!enabled || ctx.model?.provider !== "synthetic") return;
201
+ refresher.startAutoRefresh();
202
+ await refresher.setLoadingStatus(ctx);
203
+ await refresher.refreshFor(ctx);
204
+ });
205
+
206
+ pi.on("turn_end", (_event, ctx) => {
207
+ currentContext = ctx;
208
+ currentProvider = ctx.model?.provider;
209
+ if (!enabled || ctx.model?.provider !== "synthetic") return;
210
+ void refresher.refreshFor(ctx);
211
+ });
212
+
213
+ pi.on("session_switch", (_event, ctx) => {
214
+ currentContext = ctx;
215
+ currentProvider = ctx.model?.provider;
216
+ if (enabled && ctx.model?.provider === "synthetic") {
217
+ void refresher.refreshFor(ctx);
218
+ } else {
219
+ refresher.stopAutoRefresh(ctx);
220
+ }
221
+ });
222
+
223
+ pi.on("model_select", (_event, ctx) => {
224
+ currentContext = ctx;
225
+ currentProvider = ctx.model?.provider;
226
+ if (enabled && ctx.model?.provider === "synthetic") {
227
+ refresher.startAutoRefresh();
228
+ void refresher.refreshFor(ctx);
229
+ } else {
230
+ refresher.stopAutoRefresh(ctx);
231
+ }
232
+ });
233
+
234
+ pi.on("session_shutdown", (_event, ctx) => {
235
+ currentContext = undefined;
236
+ currentProvider = undefined;
237
+ refresher.stopAutoRefresh(ctx);
238
+ });
239
+
240
+ pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
241
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
242
+ feature: "usageStatus",
243
+ });
244
+ });
245
+ }
@@ -1,6 +1,50 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { registerSyntheticWebSearchTool } from "./tool";
2
+ import {
3
+ configLoader,
4
+ SYNTHETIC_CONFIG_UPDATED_EVENT,
5
+ SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
6
+ SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
7
+ type SyntheticConfigUpdatedPayload,
8
+ } from "../../config";
9
+ import {
10
+ registerSyntheticWebSearchTool,
11
+ SYNTHETIC_WEB_SEARCH_TOOL,
12
+ } from "./tool";
13
+
14
+ function syncToolActivation(pi: ExtensionAPI, enabled: boolean): void {
15
+ const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name));
16
+ const activeTools = new Set(pi.getActiveTools());
17
+
18
+ if (!allToolNames.has(SYNTHETIC_WEB_SEARCH_TOOL)) return;
19
+
20
+ if (enabled) {
21
+ activeTools.add(SYNTHETIC_WEB_SEARCH_TOOL);
22
+ } else {
23
+ activeTools.delete(SYNTHETIC_WEB_SEARCH_TOOL);
24
+ }
25
+
26
+ pi.setActiveTools([...activeTools].filter((name) => allToolNames.has(name)));
27
+ }
3
28
 
4
29
  export default async function (pi: ExtensionAPI) {
30
+ await configLoader.load();
31
+
32
+ let webSearchEnabled = configLoader.getConfig().webSearch;
33
+
5
34
  registerSyntheticWebSearchTool(pi);
35
+
36
+ pi.on("session_start", async () => {
37
+ syncToolActivation(pi, webSearchEnabled);
38
+ });
39
+
40
+ pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
41
+ webSearchEnabled = (data as SyntheticConfigUpdatedPayload).config.webSearch;
42
+ syncToolActivation(pi, webSearchEnabled);
43
+ });
44
+
45
+ pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
46
+ pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
47
+ feature: "webSearch",
48
+ });
49
+ });
6
50
  }
@@ -9,6 +9,7 @@ import type {
9
9
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
10
10
  import { Container, Markdown, Text } from "@mariozechner/pi-tui";
11
11
  import { type Static, Type } from "@sinclair/typebox";
12
+ import { configLoader } from "../../config";
12
13
  import { getSyntheticApiKey } from "../../lib/env";
13
14
 
14
15
  export const SYNTHETIC_WEB_SEARCH_TOOL = "synthetic_web_search" as const;
@@ -59,6 +60,12 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
59
60
  details: { query: params.query },
60
61
  });
61
62
 
63
+ if (!configLoader.getConfig().webSearch) {
64
+ throw new Error(
65
+ "Synthetic web search is disabled. Re-enable it with synthetic:settings or pi config.",
66
+ );
67
+ }
68
+
62
69
  const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
63
70
  if (!apiKey) {
64
71
  throw new Error(