@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.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.
@@ -0,0 +1,307 @@
1
+ import type { JsonRepresentation } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+
8
+ import type { CliproxyModel } from "../core/index.ts";
9
+ import { runWebSearch, selectSearchModel } from "../web-search/index.ts";
10
+ import type { WebSearchSource } from "../web-search/index.ts";
11
+ import { completeFrom } from "./completions.ts";
12
+ import {
13
+ loadCliproxyConfig,
14
+ resolveExtensionConnection,
15
+ saveCliproxyConfig,
16
+ } from "./config.ts";
17
+
18
+ export const WebSearchParameters = Type.Object({
19
+ query: Type.String({
20
+ description: "The web search query keywords or question to look up online",
21
+ minLength: 1,
22
+ }),
23
+ });
24
+
25
+ export const WebSearchOutputSchema = Type.Object({
26
+ query: Type.String({ description: "The trimmed search query" }),
27
+ sources: Type.Array(
28
+ Type.Object({
29
+ title: Type.Optional(Type.String()),
30
+ url: Type.String(),
31
+ }),
32
+ { description: "All unique sources reported by the search response" }
33
+ ),
34
+ text: Type.String({
35
+ description: "Search summary, including any appended source links",
36
+ }),
37
+ });
38
+
39
+ export interface WebSearchOutput {
40
+ query: string;
41
+ sources: WebSearchSource[];
42
+ text: string;
43
+ }
44
+
45
+ export interface WebSearchDetails {
46
+ durationMs?: number;
47
+ model?: string;
48
+ provider?: string;
49
+ query?: string;
50
+ sourcesCount?: number;
51
+ }
52
+
53
+ export interface RegisterWebSearchOptions {
54
+ agentDir: string;
55
+ providerId: string;
56
+ getModels: () => readonly CliproxyModel[];
57
+ /** The catalog without models that are out of quota. Searches pick from these. */
58
+ getAvailableModels: () => readonly CliproxyModel[];
59
+ getStandardCost: (modelId: string) => CliproxyModel["cost"] | undefined;
60
+ }
61
+
62
+ export interface PickSearchModelOptions {
63
+ /** Models to choose from, usually the catalog without out-of-quota models. */
64
+ models: readonly CliproxyModel[];
65
+ getStandardCost: (modelId: string) => CliproxyModel["cost"] | undefined;
66
+ /** The configured searchModel, if any. */
67
+ preferred?: string;
68
+ }
69
+
70
+ /** The model web_search uses: the preferred one when available, else the best ranked. Shared with /cliproxyapi-status. */
71
+ export const pickSearchModel = (
72
+ options: PickSearchModelOptions
73
+ ): CliproxyModel | undefined => {
74
+ const modelId = selectSearchModel(
75
+ options.models.map((model) => ({
76
+ cost: options.getStandardCost(model.id) ?? model.cost,
77
+ id: model.id,
78
+ supportsWebSearch: model.supportsWebSearch,
79
+ })),
80
+ options.preferred
81
+ );
82
+
83
+ return options.models.find((candidate) => candidate.id === modelId);
84
+ };
85
+
86
+ /** Arguments that hand the choice back to pickSearchModel. A bare command means the same without a UI. */
87
+ const AUTO_ARGUMENTS = new Set(["", "auto", "clear", "current", "reset"]);
88
+
89
+ const appendSources = (answer: string, sources: WebSearchSource[]): string => {
90
+ if (
91
+ sources.length === 0 ||
92
+ answer.includes("http://") ||
93
+ answer.includes("https://")
94
+ ) {
95
+ return answer;
96
+ }
97
+
98
+ const links = sources
99
+ .slice(0, 8)
100
+ .map(
101
+ (source, index) =>
102
+ `${index + 1}. [${source.title || source.url}](${source.url})`
103
+ );
104
+
105
+ return `${answer}\n\n## Sources\n${links.join("\n")}`;
106
+ };
107
+
108
+ export const registerWebSearch = (
109
+ pi: ExtensionAPI,
110
+ options: RegisterWebSearchOptions
111
+ ): void => {
112
+ pi.registerTool({
113
+ annotations: {
114
+ destructiveHint: false,
115
+ idempotentHint: true,
116
+ openWorldHint: true,
117
+ readOnlyHint: true,
118
+ },
119
+ description:
120
+ "Search the web for real-time, current, or online information (such as weather, news, recent events, documentation, or public web data) and return the results.",
121
+ execute: async (_toolCallId, params, signal, onUpdate) => {
122
+ const startedAt = Date.now();
123
+ const query = params.query.trim();
124
+
125
+ const connection = resolveExtensionConnection({
126
+ agentDir: options.agentDir,
127
+ env: process.env,
128
+ providerId: options.providerId,
129
+ });
130
+
131
+ if (!connection) {
132
+ throw new Error(
133
+ `CLIProxyAPI is not configured. Run /login ${options.providerId}.`
134
+ );
135
+ }
136
+
137
+ const model = pickSearchModel({
138
+ getStandardCost: options.getStandardCost,
139
+ models: options.getAvailableModels(),
140
+ preferred: loadCliproxyConfig(options.agentDir).searchModel,
141
+ });
142
+
143
+ if (!model) {
144
+ throw new Error(
145
+ "No CLIProxyAPI search model is available. Run /cliproxyapi-refresh."
146
+ );
147
+ }
148
+
149
+ const standardCost = options.getStandardCost(model.id);
150
+
151
+ const pricedModel = standardCost
152
+ ? { ...model, cost: standardCost }
153
+ : model;
154
+
155
+ const details: WebSearchDetails = {
156
+ model: model.id,
157
+ provider: model.provider,
158
+ query,
159
+ };
160
+
161
+ const result = await runWebSearch({
162
+ apiKey: connection.apiKey,
163
+ model: pricedModel,
164
+ onProgress: (event) => {
165
+ onUpdate?.({
166
+ content: [{ text: event.message, type: "text" }],
167
+ details,
168
+ });
169
+ },
170
+ query,
171
+ responsesUrl: connection.responsesUrl,
172
+ signal,
173
+ });
174
+
175
+ const text = appendSources(result.answer, result.sources);
176
+
177
+ const sources = result.sources.map((source) =>
178
+ source.title
179
+ ? { title: source.title, url: source.url }
180
+ : { url: source.url }
181
+ );
182
+
183
+ const structuredContent: JsonRepresentation<WebSearchOutput> = {
184
+ query,
185
+ sources,
186
+ text,
187
+ };
188
+
189
+ const toolResult = {
190
+ content: [{ text, type: "text" as const }],
191
+ details: {
192
+ ...details,
193
+ durationMs: Date.now() - startedAt,
194
+ sourcesCount: result.sources.length,
195
+ },
196
+ structuredContent,
197
+ };
198
+
199
+ if (result.usage) {
200
+ return { ...toolResult, usage: result.usage };
201
+ }
202
+
203
+ return toolResult;
204
+ },
205
+ exposure: "direct",
206
+ label: "web_search",
207
+ name: "web_search",
208
+ outputSchema: WebSearchOutputSchema,
209
+ parameters: WebSearchParameters,
210
+ promptGuidelines: [
211
+ "Use the `web_search` tool for any questions that require real-time, current, or online information (e.g., weather, news, recent events, latest releases).",
212
+ "Do NOT attempt to run shell commands (such as bash, PowerShell, curl, wget, python, or scripts) to search the web or check weather/news when `web_search` is available.",
213
+ "Use the returned web search results to formulate your response accurately.",
214
+ ],
215
+ promptSnippet: "Search current and real-time information",
216
+ });
217
+
218
+ const searchModelIds = (): string[] =>
219
+ options
220
+ .getModels()
221
+ .filter((model) => model.supportsWebSearch)
222
+ .map((model) => model.id);
223
+
224
+ const saveSearchModel = (
225
+ ctx: ExtensionCommandContext,
226
+ searchModel: string | undefined
227
+ ): void => {
228
+ try {
229
+ saveCliproxyConfig(options.agentDir, { searchModel });
230
+ } catch (error) {
231
+ ctx.ui.notify(
232
+ `Failed to save the web search model: ${error instanceof Error ? error.message : String(error)}`,
233
+ "error"
234
+ );
235
+
236
+ return;
237
+ }
238
+
239
+ ctx.ui.notify(
240
+ searchModel
241
+ ? `Web search model set to: ${searchModel}`
242
+ : "Web search will choose a model automatically.",
243
+ "info"
244
+ );
245
+ };
246
+
247
+ const pickFromMenu = async (ctx: ExtensionCommandContext): Promise<void> => {
248
+ const ids = searchModelIds();
249
+
250
+ const automatic = pickSearchModel({
251
+ getStandardCost: options.getStandardCost,
252
+ models: options.getAvailableModels(),
253
+ });
254
+
255
+ const autoChoice = `auto (currently ${automatic?.id ?? "none"})`;
256
+
257
+ const choice = await ctx.ui.select("Web search model", [
258
+ autoChoice,
259
+ ...ids,
260
+ ]);
261
+
262
+ if (choice === undefined) {
263
+ return;
264
+ }
265
+
266
+ saveSearchModel(ctx, choice === autoChoice ? undefined : choice);
267
+ };
268
+
269
+ pi.registerCommand("websearch-model", {
270
+ description:
271
+ "Set the CLIProxyAPI model used for web_search (/websearch-model [model-id|auto])",
272
+ getArgumentCompletions: (prefix) =>
273
+ completeFrom(["auto", ...searchModelIds()], prefix),
274
+ handler: async (args, ctx) => {
275
+ const requested = args.trim();
276
+
277
+ if (!requested && ctx.hasUI) {
278
+ await pickFromMenu(ctx);
279
+
280
+ return;
281
+ }
282
+
283
+ if (AUTO_ARGUMENTS.has(requested)) {
284
+ saveSearchModel(ctx, undefined);
285
+
286
+ return;
287
+ }
288
+
289
+ const model = options
290
+ .getModels()
291
+ .find(
292
+ (candidate) => candidate.id.toLowerCase() === requested.toLowerCase()
293
+ );
294
+
295
+ if (!model) {
296
+ ctx.ui.notify(
297
+ `Model "${requested}" is not in the CLIProxyAPI catalog.`,
298
+ "warning"
299
+ );
300
+
301
+ return;
302
+ }
303
+
304
+ saveSearchModel(ctx, model.id);
305
+ },
306
+ });
307
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolve the Responses endpoint from a CLIProxyAPI root, a `/v1` base URL,
3
+ * or a full Responses URL.
4
+ */
5
+ export const resolveResponsesEndpoint = (baseUrl: string): string => {
6
+ const normalized = baseUrl.trim().replace(/\/+$/u, "");
7
+
8
+ if (!normalized) {
9
+ throw new Error("Responses base URL is empty");
10
+ }
11
+
12
+ if (normalized.endsWith("/v1/responses")) {
13
+ return normalized;
14
+ }
15
+
16
+ if (normalized.endsWith("/v1")) {
17
+ return `${normalized}/responses`;
18
+ }
19
+
20
+ return `${normalized}/v1/responses`;
21
+ };
@@ -0,0 +1,21 @@
1
+ export { resolveResponsesEndpoint } from "./endpoint.ts";
2
+
3
+ export { selectSearchModel } from "./model-selector.ts";
4
+
5
+ export {
6
+ buildSearchInstructions,
7
+ runWebSearch,
8
+ SEARCH_INSTRUCTIONS,
9
+ type RunWebSearchOptions,
10
+ type WebSearchProgressEvent,
11
+ type WebSearchProgressPhase,
12
+ type WebSearchResult,
13
+ type WebSearchSource,
14
+ } from "./search.ts";
15
+
16
+ export {
17
+ consumeSseStream,
18
+ createSseParser,
19
+ type SseEvent,
20
+ type SseParser,
21
+ } from "./sse.ts";
@@ -0,0 +1,93 @@
1
+ const searchModelRank = (modelId: string): number => {
2
+ const normalized = modelId.trim().toLowerCase();
3
+
4
+ const isGpt =
5
+ /(?:^|[^a-z0-9])gpt(?:[^a-z0-9]|$)/u.test(normalized) ||
6
+ normalized.startsWith("gpt");
7
+
8
+ const isClaude = normalized.includes("claude");
9
+
10
+ if (isGpt && normalized.includes("fast")) {
11
+ return 0;
12
+ }
13
+
14
+ if (isGpt) {
15
+ return 1;
16
+ }
17
+
18
+ if (isClaude && normalized.includes("haiku")) {
19
+ return 2;
20
+ }
21
+
22
+ if (isClaude) {
23
+ return 3;
24
+ }
25
+
26
+ return 4;
27
+ };
28
+
29
+ /** A pi-ai Model fits this shape. */
30
+ export interface SearchCandidate {
31
+ id: string;
32
+ cost?: { input: number };
33
+ supportsWebSearch?: boolean;
34
+ }
35
+
36
+ /** Zero means pricing is unknown, so it sorts after every priced model. */
37
+ const inputPrice = (candidate: SearchCandidate): number => {
38
+ const input = candidate.cost?.input ?? 0;
39
+
40
+ return input > 0 ? input : Number.POSITIVE_INFINITY;
41
+ };
42
+
43
+ /**
44
+ * Pick a search model. Models known not to support web search are excluded.
45
+ * The preferred id wins when it is present and not excluded. Otherwise
46
+ * models known to support web search come before models with unknown
47
+ * support. Within each support group, fast GPT models come first, then
48
+ * other GPT models, then Claude Haiku models, then other Claude models,
49
+ * then everything else. Within a group the cheapest priced model wins,
50
+ * and catalog order breaks ties.
51
+ */
52
+ export const selectSearchModel = (
53
+ models: readonly SearchCandidate[],
54
+ preferred?: string
55
+ ): string | undefined => {
56
+ const candidates = models.filter(
57
+ (model) => model.id.trim().length > 0 && model.supportsWebSearch !== false
58
+ );
59
+
60
+ if (candidates.length === 0) {
61
+ return undefined;
62
+ }
63
+
64
+ if (preferred?.trim()) {
65
+ const wanted = preferred.trim().toLowerCase();
66
+
67
+ const match = candidates.find(
68
+ (model) => model.id.trim().toLowerCase() === wanted
69
+ );
70
+
71
+ if (match) {
72
+ return match.id;
73
+ }
74
+ }
75
+
76
+ const ranked = candidates
77
+ .map((model, index) => ({
78
+ index,
79
+ modelId: model.id,
80
+ price: inputPrice(model),
81
+ rank: searchModelRank(model.id),
82
+ supportGroup: model.supportsWebSearch === true ? 0 : 1,
83
+ }))
84
+ .toSorted(
85
+ (a, b) =>
86
+ a.supportGroup - b.supportGroup ||
87
+ a.rank - b.rank ||
88
+ (a.price === b.price ? 0 : a.price - b.price) ||
89
+ a.index - b.index
90
+ );
91
+
92
+ return ranked[0]?.modelId;
93
+ };