@aliou/pi-synthetic 0.19.1 → 0.20.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/README.md CHANGED
@@ -64,7 +64,7 @@ The `provider` field in `src/extensions/provider/models.ts` is for maintenance o
64
64
 
65
65
  ### Web Search Tool
66
66
 
67
- The extension registers `synthetic_web_search` — a zero-data-retention web search tool. The tool is always visible; it fails with a clear message if credentials are missing or the account lacks a subscription.
67
+ The extension registers `synthetic_web_search` — a zero-data-retention web search tool. The tool is always visible; it fails with a clear message if credentials are missing, the account lacks a subscription, or the configured utility proxy rejects the request.
68
68
 
69
69
  ### Reasoning Levels
70
70
 
@@ -85,6 +85,12 @@ Check your API usage:
85
85
  /synthetic:quotas
86
86
  ```
87
87
 
88
+ ### Utility API Proxy
89
+
90
+ Web search and quotas can use a proxy instead of `https://api.synthetic.new`. Configure it with `/synthetic:settings` under **Connection > Utility API Proxy**. The proxy is only used for `/v2/search` and `/v2/quotas`; model provider calls still use Synthetic's OpenAI-compatible endpoint directly.
91
+
92
+ If the proxy requires auth, pi-synthetic still requires a Synthetic API key and sends `Authorization: Bearer ...`. If the proxy does not require auth, disable **Requires auth** and these utility calls skip API key checks and omit `Authorization`.
93
+
88
94
  ### Usage Status
89
95
 
90
96
  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.
@@ -107,7 +113,7 @@ pi config extensions.disabled add @aliou/pi-synthetic/quota-warnings
107
113
 
108
114
  This prevents the quota-warnings extension from loading while keeping the rest of pi-synthetic active. Replace `quota-warnings` with `web-search`, `command-quotas`, `sub-bar-integration`, `usage-status`, or `provider` to disable other features.
109
115
 
110
- The **Proxied Models** setting is not a loadable extension feature. It is a regular setting controlled through `/synthetic:settings`.
116
+ The **Proxied Models** and **Utility API Proxy** settings are not loadable extension features. They are regular settings controlled through `/synthetic:settings`.
111
117
 
112
118
  ## Adding or Updating Models
113
119
 
@@ -181,7 +187,7 @@ This repository uses [Changesets](https://github.com/changesets/changesets) for
181
187
  ## Requirements
182
188
 
183
189
  - Pi coding agent v0.77.0+
184
- - Synthetic API key (configured in `~/.pi/agent/auth.json` or via `SYNTHETIC_API_KEY`)
190
+ - Synthetic API key (configured in `~/.pi/agent/auth.json` or via `SYNTHETIC_API_KEY`) for model provider calls and authenticated utility API calls
185
191
 
186
192
  ## Links
187
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/src/config.ts CHANGED
@@ -2,11 +2,17 @@ import {
2
2
  ConfigLoader,
3
3
  type Migration,
4
4
  registerSettingsCommand,
5
+ SettingsDetailEditor,
5
6
  type SettingsSection,
6
7
  } from "@aliou/pi-utils-settings";
7
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
9
  import type { SettingItem } from "@earendil-works/pi-tui";
9
10
  import pkg from "../package.json" with { type: "json" };
11
+ import {
12
+ formatSyntheticUtilityApiProxySummary,
13
+ hasSyntheticUtilityApiProxy,
14
+ validateSyntheticUtilityApiProxyUrl,
15
+ } from "./lib/utility-api";
10
16
 
11
17
  export type SyntheticFeatureId =
12
18
  | "webSearch"
@@ -36,6 +42,8 @@ export interface SyntheticConfig {
36
42
  quotaWarnings?: boolean;
37
43
  subBarIntegration?: boolean;
38
44
  proxiedModels?: boolean;
45
+ proxyUrl?: string;
46
+ proxyRequiresAuth?: boolean;
39
47
  }
40
48
 
41
49
  export interface ResolvedSyntheticConfig {
@@ -46,6 +54,8 @@ export interface ResolvedSyntheticConfig {
46
54
  quotaWarnings: boolean;
47
55
  subBarIntegration: boolean;
48
56
  proxiedModels: boolean;
57
+ proxyUrl: string;
58
+ proxyRequiresAuth: boolean;
49
59
  }
50
60
 
51
61
  const DEFAULT_CONFIG: ResolvedSyntheticConfig = {
@@ -56,6 +66,8 @@ const DEFAULT_CONFIG: ResolvedSyntheticConfig = {
56
66
  quotaWarnings: false,
57
67
  subBarIntegration: true,
58
68
  proxiedModels: false,
69
+ proxyUrl: "",
70
+ proxyRequiresAuth: true,
59
71
  };
60
72
 
61
73
  export const pendingMessages: string[] = [];
@@ -165,7 +177,7 @@ export function registerSyntheticSettings(
165
177
  commandDescription: "Configure Synthetic extension settings",
166
178
  title: "Synthetic Settings",
167
179
  configStore: configLoader,
168
- buildSections: (tabConfig, resolved): SettingsSection[] => {
180
+ buildSections: (tabConfig, resolved, ctx): SettingsSection[] => {
169
181
  const loaded = getLoadedFeatures();
170
182
  const webSearch = tabConfig?.webSearch ?? resolved.webSearch;
171
183
  const quotasCommand = tabConfig?.quotasCommand ?? resolved.quotasCommand;
@@ -174,10 +186,100 @@ export function registerSyntheticSettings(
174
186
  const subBarIntegration =
175
187
  tabConfig?.subBarIntegration ?? resolved.subBarIntegration;
176
188
  const proxiedModels = tabConfig?.proxiedModels ?? resolved.proxiedModels;
189
+ const proxyUrl = tabConfig?.proxyUrl ?? resolved.proxyUrl;
190
+ const proxyRequiresAuth =
191
+ tabConfig?.proxyRequiresAuth ?? resolved.proxyRequiresAuth;
177
192
 
178
193
  const sections: SettingsSection[] = [];
179
194
 
180
195
  sections.push(
196
+ {
197
+ label: "Connection",
198
+ items: [
199
+ {
200
+ id: "utilityApiProxy",
201
+ label: "Utility API Proxy",
202
+ description:
203
+ "Override the Synthetic quotas and web search API root. The provider endpoint is not proxied.",
204
+ currentValue: formatSyntheticUtilityApiProxySummary({
205
+ proxyUrl,
206
+ proxyRequiresAuth,
207
+ }),
208
+ submenu: (_current, done) => {
209
+ const current: SyntheticConfig =
210
+ tabConfig ?? (ctx.scope === "memory" ? resolved : {});
211
+ let nextProxyUrl = proxyUrl;
212
+ let nextProxyRequiresAuth =
213
+ proxyUrl.trim() || proxyRequiresAuth
214
+ ? proxyRequiresAuth
215
+ : true;
216
+
217
+ const syncDraft = () => {
218
+ ctx.setDraft({
219
+ ...current,
220
+ proxyUrl: nextProxyUrl.trim() || undefined,
221
+ proxyRequiresAuth: nextProxyRequiresAuth,
222
+ });
223
+ };
224
+
225
+ return new SettingsDetailEditor({
226
+ title: "Utility API Proxy",
227
+ theme: ctx.theme,
228
+ fields: [
229
+ {
230
+ id: "proxyUrl.detail",
231
+ type: "text",
232
+ label: "Proxy URL",
233
+ description:
234
+ "Leave empty to call https://api.synthetic.new directly for quotas and web search.",
235
+ getValue: () => nextProxyUrl,
236
+ setValue: (value) => {
237
+ nextProxyUrl = value;
238
+ // Requires auth only makes sense with a proxy.
239
+ // Enforce enabled state when the URL is empty.
240
+ if (!value.trim()) nextProxyRequiresAuth = true;
241
+ syncDraft();
242
+ },
243
+ validate: validateSyntheticUtilityApiProxyUrl,
244
+ emptyValueText: "direct",
245
+ },
246
+ {
247
+ id: "proxyRequiresAuth.detail",
248
+ type: "boolean",
249
+ label: "Requires auth",
250
+ description:
251
+ "When disabled, quotas and web search skip the Synthetic API key check and omit Authorization. Only effective when a proxy URL is set.",
252
+ getValue: () =>
253
+ hasSyntheticUtilityApiProxy({
254
+ proxyUrl: nextProxyUrl,
255
+ proxyRequiresAuth: nextProxyRequiresAuth,
256
+ })
257
+ ? nextProxyRequiresAuth
258
+ : true,
259
+ setValue: (value) => {
260
+ nextProxyRequiresAuth = hasSyntheticUtilityApiProxy({
261
+ proxyUrl: nextProxyUrl,
262
+ proxyRequiresAuth: nextProxyRequiresAuth,
263
+ })
264
+ ? value
265
+ : true;
266
+ syncDraft();
267
+ },
268
+ trueLabel: "enabled",
269
+ falseLabel: "disabled",
270
+ },
271
+ ],
272
+ onDone: done,
273
+ getDoneSummary: () =>
274
+ formatSyntheticUtilityApiProxySummary({
275
+ proxyUrl: nextProxyUrl,
276
+ proxyRequiresAuth: nextProxyRequiresAuth,
277
+ }),
278
+ });
279
+ },
280
+ },
281
+ ],
282
+ },
181
283
  {
182
284
  label: "Models",
183
285
  items: [
@@ -1,17 +1,38 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { configLoader } from "../../config";
3
3
  import { getSyntheticApiKey } from "../../lib/env";
4
- import { fetchQuotas } from "../../utils/quotas";
4
+ import {
5
+ resolveSyntheticUtilityApiAuth,
6
+ type SyntheticUtilityApiConfig,
7
+ } from "../../lib/utility-api";
8
+ import { type FetchQuotasOptions, fetchQuotas } from "../../utils/quotas";
5
9
  import { QuotasComponent } from "./components/quotas-display";
6
10
 
7
11
  const MISSING_AUTH_MESSAGE =
8
- "Synthetic quotas requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.";
12
+ "Synthetic quotas requires a Synthetic subscription or an unauthenticated proxy. Add credentials to ~/.pi/agent/auth.json, set SYNTHETIC_API_KEY, or disable proxy auth in /synthetic:settings.";
13
+
14
+ async function buildQuotasOptions(
15
+ config: SyntheticUtilityApiConfig,
16
+ authStorage: NonNullable<Parameters<typeof getSyntheticApiKey>[0]>,
17
+ ): Promise<FetchQuotasOptions | undefined> {
18
+ const auth = await resolveSyntheticUtilityApiAuth(config, () =>
19
+ getSyntheticApiKey(authStorage),
20
+ );
21
+ if (!auth) return undefined;
22
+
23
+ return {
24
+ apiKey: auth.apiKey,
25
+ proxyUrl: config.proxyUrl,
26
+ requiresAuth: auth.requiresAuth,
27
+ };
28
+ }
9
29
 
10
30
  export function registerQuotasCommand(pi: ExtensionAPI): void {
11
31
  pi.registerCommand("synthetic:quotas", {
12
32
  description: "Display Synthetic API usage quotas",
13
33
  handler: async (_args, ctx) => {
14
- if (!configLoader.getConfig().quotasCommand) {
34
+ const config = configLoader.getConfig();
35
+ if (!config.quotasCommand) {
15
36
  ctx.ui.notify(
16
37
  "Synthetic quotas command is disabled. Restart Pi to unload the command after re-enabling or disabling it.",
17
38
  "warning",
@@ -19,12 +40,14 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
19
40
  return;
20
41
  }
21
42
 
22
- const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
23
- if (!apiKey) {
43
+ const quotasOptions = await buildQuotasOptions(
44
+ config,
45
+ ctx.modelRegistry.authStorage,
46
+ );
47
+ if (!quotasOptions) {
24
48
  ctx.ui.notify(MISSING_AUTH_MESSAGE, "warning");
25
49
  return;
26
50
  }
27
- const key: string = apiKey;
28
51
 
29
52
  const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
30
53
  const controller = new AbortController();
@@ -43,7 +66,10 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
43
66
  );
44
67
 
45
68
  async function loadQuotas(): Promise<void> {
46
- const fetchResult = await fetchQuotas(key, controller.signal);
69
+ const fetchResult = await fetchQuotas({
70
+ ...quotasOptions,
71
+ signal: controller.signal,
72
+ });
47
73
  if (controller.signal.aborted) return;
48
74
  if (fetchResult.success) {
49
75
  component.setState({
@@ -74,7 +100,7 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
74
100
 
75
101
  // Non-interactive fallback (RPC, print, JSON modes)
76
102
  if (result === undefined) {
77
- const fetchResult = await fetchQuotas(key);
103
+ const fetchResult = await fetchQuotas(quotasOptions);
78
104
  if (!fetchResult.success) {
79
105
  ctx.ui.notify(
80
106
  JSON.stringify({ error: fetchResult.error.message }),
@@ -16,6 +16,7 @@ import {
16
16
  seedSyntheticConfigIfMissing,
17
17
  } from "../../config";
18
18
  import { getSyntheticApiKey } from "../../lib/env";
19
+ import { resolveSyntheticUtilityApiAuth } from "../../lib/utility-api";
19
20
  import { QuotaStore } from "../../services/quota-store";
20
21
  import {
21
22
  parseQuotaHeader,
@@ -94,13 +95,29 @@ export default async function (pi: ExtensionAPI) {
94
95
  await configLoader.load();
95
96
  await seedSyntheticConfigIfMissing();
96
97
 
97
- const includeProxiedModels = configLoader.getConfig().proxiedModels;
98
+ const initialConfig = configLoader.getConfig();
99
+ const includeProxiedModels = initialConfig.proxiedModels;
100
+ let utilityApiProxyUrl = initialConfig.proxyUrl;
101
+ let utilityApiProxyRequiresAuth = initialConfig.proxyRequiresAuth;
102
+ const quotaStore = new QuotaStore();
103
+ let currentAuthStorage: AuthStorage | undefined;
104
+
98
105
  registerSyntheticProvider(pi, { includeProxiedModels });
99
106
 
100
107
  pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
101
- const includeProxiedModels = (data as SyntheticConfigUpdatedPayload).config
102
- .proxiedModels;
103
- registerSyntheticProvider(pi, { includeProxiedModels });
108
+ const config = (data as SyntheticConfigUpdatedPayload).config;
109
+ registerSyntheticProvider(pi, {
110
+ includeProxiedModels: config.proxiedModels,
111
+ });
112
+
113
+ if (
114
+ config.proxyUrl !== utilityApiProxyUrl ||
115
+ config.proxyRequiresAuth !== utilityApiProxyRequiresAuth
116
+ ) {
117
+ quotaStore.clear();
118
+ utilityApiProxyUrl = config.proxyUrl;
119
+ utilityApiProxyRequiresAuth = config.proxyRequiresAuth;
120
+ }
104
121
  });
105
122
 
106
123
  const loadedFeatures = new Set<SyntheticFeatureId>();
@@ -114,14 +131,20 @@ export default async function (pi: ExtensionAPI) {
114
131
  getLoadedFeatures: () => loadedFeatures,
115
132
  });
116
133
 
117
- const quotaStore = new QuotaStore();
118
- let currentAuthStorage: AuthStorage | undefined;
119
-
120
134
  async function fetchQuotasFromAuth(): Promise<QuotasResponse | undefined> {
121
- if (!currentAuthStorage) return undefined;
122
- const apiKey = await getSyntheticApiKey(currentAuthStorage);
123
- if (!apiKey) return undefined;
124
- const result = await fetchQuotas(apiKey);
135
+ const config = configLoader.getConfig();
136
+ const auth = await resolveSyntheticUtilityApiAuth(config, () =>
137
+ currentAuthStorage
138
+ ? getSyntheticApiKey(currentAuthStorage)
139
+ : Promise.resolve(undefined),
140
+ );
141
+ if (!auth) return undefined;
142
+
143
+ const result = await fetchQuotas({
144
+ apiKey: auth.apiKey,
145
+ proxyUrl: config.proxyUrl,
146
+ requiresAuth: auth.requiresAuth,
147
+ });
125
148
  return result.success ? result.data.quotas : undefined;
126
149
  }
127
150
 
@@ -14,6 +14,11 @@ import { Container, Text } from "@earendil-works/pi-tui";
14
14
  import { type Static, Type } from "typebox";
15
15
  import { configLoader } from "../../config";
16
16
  import { getSyntheticApiKey } from "../../lib/env";
17
+ import {
18
+ resolveSyntheticUtilityApiAuth,
19
+ resolveSyntheticUtilityApiBaseUrl,
20
+ syntheticUtilityApiUrl,
21
+ } from "../../lib/utility-api";
17
22
 
18
23
  export const SYNTHETIC_WEB_SEARCH_TOOL = "synthetic_web_search" as const;
19
24
 
@@ -69,28 +74,46 @@ export const syntheticWebSearchTool = defineTool({
69
74
  details: { query: params.query },
70
75
  });
71
76
 
72
- if (!configLoader.getConfig().webSearch) {
77
+ const config = configLoader.getConfig();
78
+ if (!config.webSearch) {
73
79
  throw new Error(
74
80
  "Synthetic web search is disabled. Re-enable it with synthetic:settings or pi config.",
75
81
  );
76
82
  }
77
83
 
78
- const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
79
- if (!apiKey) {
84
+ const auth = await resolveSyntheticUtilityApiAuth(config, () =>
85
+ getSyntheticApiKey(ctx.modelRegistry.authStorage),
86
+ );
87
+ if (!auth) {
80
88
  throw new Error(
81
- "Synthetic web search requires a Synthetic subscription. Add credentials to ~/.pi/agent/auth.json or set SYNTHETIC_API_KEY environment variable.",
89
+ "Synthetic web search requires a Synthetic subscription or an unauthenticated proxy. Add credentials to ~/.pi/agent/auth.json, set SYNTHETIC_API_KEY, or disable proxy auth in /synthetic:settings.",
82
90
  );
83
91
  }
84
92
 
85
- const response = await fetch("https://api.synthetic.new/v2/search", {
86
- method: "POST",
87
- headers: {
88
- Authorization: `Bearer ${apiKey}`,
89
- "Content-Type": "application/json",
93
+ let baseUrl: string;
94
+ try {
95
+ baseUrl = resolveSyntheticUtilityApiBaseUrl(config.proxyUrl);
96
+ } catch (err: unknown) {
97
+ const message = err instanceof Error ? err.message : "Invalid proxy URL";
98
+ throw new Error(`Synthetic web search: ${message}`);
99
+ }
100
+
101
+ const headers: Record<string, string> = {
102
+ "Content-Type": "application/json",
103
+ };
104
+ if (auth.apiKey) {
105
+ headers.Authorization = `Bearer ${auth.apiKey}`;
106
+ }
107
+
108
+ const response = await fetch(
109
+ syntheticUtilityApiUrl(baseUrl, "/v2/search"),
110
+ {
111
+ method: "POST",
112
+ headers,
113
+ body: JSON.stringify({ query: params.query }),
114
+ signal,
90
115
  },
91
- body: JSON.stringify({ query: params.query }),
92
- signal,
93
- });
116
+ );
94
117
 
95
118
  if (!response.ok) {
96
119
  const errorText = await response.text();
@@ -0,0 +1,85 @@
1
+ export const DEFAULT_SYNTHETIC_API_BASE_URL = "https://api.synthetic.new";
2
+
3
+ export interface SyntheticUtilityApiConfig {
4
+ proxyUrl?: string;
5
+ proxyRequiresAuth?: boolean;
6
+ }
7
+
8
+ export function hasSyntheticUtilityApiProxy(
9
+ config: SyntheticUtilityApiConfig,
10
+ ): boolean {
11
+ return !!config.proxyUrl?.trim();
12
+ }
13
+
14
+ export function syntheticUtilityApiRequiresAuth(
15
+ config: SyntheticUtilityApiConfig,
16
+ ): boolean {
17
+ return (
18
+ !hasSyntheticUtilityApiProxy(config) || config.proxyRequiresAuth !== false
19
+ );
20
+ }
21
+
22
+ export function validateSyntheticUtilityApiProxyUrl(
23
+ value: string,
24
+ ): string | null {
25
+ const trimmed = value.trim();
26
+ if (!trimmed) return null;
27
+
28
+ let url: URL;
29
+ try {
30
+ url = new URL(trimmed);
31
+ } catch {
32
+ return "Proxy URL must be a valid URL";
33
+ }
34
+
35
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
36
+ return "Proxy URL must use http or https";
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ export function resolveSyntheticUtilityApiBaseUrl(proxyUrl?: string): string {
43
+ const raw = proxyUrl?.trim() || DEFAULT_SYNTHETIC_API_BASE_URL;
44
+ const error = validateSyntheticUtilityApiProxyUrl(raw);
45
+ if (error) {
46
+ throw new Error(`Synthetic utility API: ${error}`);
47
+ }
48
+ return raw.replace(/\/+$/, "");
49
+ }
50
+
51
+ export function syntheticUtilityApiUrl(baseUrl: string, path: string): string {
52
+ return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
53
+ }
54
+
55
+ export function formatSyntheticUtilityApiProxySummary(
56
+ config: SyntheticUtilityApiConfig,
57
+ ): string {
58
+ if (!hasSyntheticUtilityApiProxy(config)) return "direct";
59
+ const auth = syntheticUtilityApiRequiresAuth(config) ? "auth" : "no auth";
60
+ return `${config.proxyUrl?.trim()} · ${auth}`;
61
+ }
62
+
63
+ export interface ResolvedSyntheticUtilityApiAuth {
64
+ apiKey: string | undefined;
65
+ requiresAuth: boolean;
66
+ }
67
+
68
+ /**
69
+ * Resolve the auth state for a utility API request.
70
+ *
71
+ * - When auth is required (direct calls, or proxy with auth enabled), the
72
+ * provided `getApiKey` callback is awaited. Returns `null` if no key is
73
+ * available, so callers can surface a missing-credentials error.
74
+ * - When auth is not required (unauthenticated proxy), `apiKey` is left
75
+ * `undefined` and the Synthetic API key check is skipped.
76
+ */
77
+ export async function resolveSyntheticUtilityApiAuth(
78
+ config: SyntheticUtilityApiConfig,
79
+ getApiKey: () => Promise<string | undefined>,
80
+ ): Promise<ResolvedSyntheticUtilityApiAuth | null> {
81
+ const requiresAuth = syntheticUtilityApiRequiresAuth(config);
82
+ const apiKey = requiresAuth ? await getApiKey() : undefined;
83
+ if (requiresAuth && !apiKey) return null;
84
+ return { apiKey, requiresAuth };
85
+ }
@@ -1,7 +1,18 @@
1
+ import {
2
+ resolveSyntheticUtilityApiBaseUrl,
3
+ syntheticUtilityApiUrl,
4
+ } from "../lib/utility-api";
1
5
  import type { QuotasResponse, QuotasResult } from "../types/quotas";
2
6
 
3
7
  const FETCH_TIMEOUT_MS = 15_000;
4
8
 
9
+ export interface FetchQuotasOptions {
10
+ apiKey?: string;
11
+ proxyUrl?: string;
12
+ requiresAuth?: boolean;
13
+ signal?: AbortSignal;
14
+ }
15
+
5
16
  function isTimeoutReason(reason: unknown): boolean {
6
17
  return (
7
18
  (reason instanceof DOMException && reason.name === "TimeoutError") ||
@@ -10,23 +21,37 @@ function isTimeoutReason(reason: unknown): boolean {
10
21
  }
11
22
 
12
23
  export async function fetchQuotas(
13
- apiKey: string,
14
- signal?: AbortSignal,
24
+ options: FetchQuotasOptions,
15
25
  ): Promise<QuotasResult> {
16
- if (!apiKey) {
26
+ const requiresAuth = options.requiresAuth ?? true;
27
+ if (requiresAuth && !options.apiKey) {
17
28
  return {
18
29
  success: false,
19
30
  error: { message: "No API key provided", kind: "config" },
20
31
  };
21
32
  }
22
33
 
34
+ let url: string;
35
+ try {
36
+ const baseUrl = resolveSyntheticUtilityApiBaseUrl(options.proxyUrl);
37
+ url = syntheticUtilityApiUrl(baseUrl, "/v2/quotas");
38
+ } catch (err: unknown) {
39
+ const message = err instanceof Error ? err.message : "Invalid proxy URL";
40
+ return { success: false, error: { message, kind: "config" } };
41
+ }
42
+
23
43
  const signals: AbortSignal[] = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
24
- if (signal) signals.push(signal);
44
+ if (options.signal) signals.push(options.signal);
25
45
  const combined = AbortSignal.any(signals);
26
46
 
27
47
  try {
28
- const response = await fetch("https://api.synthetic.new/v2/quotas", {
29
- headers: { Authorization: `Bearer ${apiKey}` },
48
+ const headers: Record<string, string> = {};
49
+ if (options.apiKey) {
50
+ headers.Authorization = `Bearer ${options.apiKey}`;
51
+ }
52
+
53
+ const response = await fetch(url, {
54
+ headers,
30
55
  signal: combined,
31
56
  });
32
57