@sayknow-cli/coding-agent 0.3.0 → 0.3.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/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +98 -3
- package/dist/types/defaults/skc-defaults.d.ts +2 -13
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/i18n/messages/en.d.ts +1 -0
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/components/settings-defs.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/notifications/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/telegram-daemon.d.ts +19 -0
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +22 -0
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/notify-cli.ts +152 -5
- package/src/commands/team.ts +1 -1
- package/src/config/settings-schema.ts +119 -5
- package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
- package/src/defaults/skc-defaults.ts +2 -27
- package/src/extensibility/shared-events.ts +1 -0
- package/src/i18n/messages/de.ts +1 -0
- package/src/i18n/messages/en.ts +1 -0
- package/src/i18n/messages/es.ts +1 -0
- package/src/i18n/messages/fr.ts +1 -0
- package/src/i18n/messages/ja.ts +1 -0
- package/src/i18n/messages/ko.ts +1 -0
- package/src/i18n/messages/zh.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +9 -9
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/components/settings-defs.ts +2 -1
- package/src/modes/components/settings-selector.ts +7 -2
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/interactive-mode.ts +46 -2
- package/src/modes/theme/theme.ts +4 -0
- package/src/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +180 -38
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/telegram-daemon.ts +235 -14
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/session/agent-session.ts +82 -51
- package/src/skc-runtime/launch-tmux.ts +17 -3
- package/src/skc-runtime/ledger-event-renderer.ts +1 -0
- package/src/skc-runtime/ralplan-runtime.ts +2 -2
- package/src/skc-runtime/workflow-manifest.generated.json +29 -0
- package/src/skc-runtime/workflow-manifest.ts +7 -2
- package/src/tools/fetch.ts +78 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +155 -0
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -72,6 +72,11 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
|
|
|
72
72
|
label: "DuckDuckGo",
|
|
73
73
|
load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
|
|
74
74
|
},
|
|
75
|
+
insane: {
|
|
76
|
+
id: "insane",
|
|
77
|
+
label: "Insane",
|
|
78
|
+
load: async () => new (await import("./providers/insane")).InsaneProvider(),
|
|
79
|
+
},
|
|
75
80
|
"openai-compatible": {
|
|
76
81
|
id: "openai-compatible",
|
|
77
82
|
label: "OpenAI-compatible",
|
|
@@ -97,6 +102,7 @@ export async function getSearchProvider(id: SearchProviderId): Promise<SearchPro
|
|
|
97
102
|
|
|
98
103
|
export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
|
|
99
104
|
"duckduckgo",
|
|
105
|
+
"insane",
|
|
100
106
|
"tavily",
|
|
101
107
|
"perplexity",
|
|
102
108
|
"brave",
|
|
@@ -234,14 +240,41 @@ export function isLocalBaseUrl(baseUrl: string | undefined): boolean {
|
|
|
234
240
|
return false;
|
|
235
241
|
}
|
|
236
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Whether `baseUrl` is an official OpenAI endpoint (or absent, i.e. the default
|
|
245
|
+
* hosted OpenAI). The dedicated `codex` provider authenticates against the
|
|
246
|
+
* ChatGPT backend with the user's *local* Codex OAuth, so it must only be
|
|
247
|
+
* selected when the active model is genuinely served by OpenAI/ChatGPT — never
|
|
248
|
+
* for a custom/proxy endpoint, which should reuse its own credentials through
|
|
249
|
+
* the `openai-compatible` adapter instead.
|
|
250
|
+
*/
|
|
251
|
+
function isOpenAIOfficialBaseUrl(baseUrl: string | undefined): boolean {
|
|
252
|
+
if (!baseUrl?.trim()) return true;
|
|
253
|
+
let host: string;
|
|
254
|
+
try {
|
|
255
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return (
|
|
260
|
+
host === "api.openai.com" ||
|
|
261
|
+
host === "chatgpt.com" ||
|
|
262
|
+
host.endsWith(".openai.com") ||
|
|
263
|
+
host.endsWith(".chatgpt.com")
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
237
267
|
export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
|
|
238
268
|
if (!ctx || ctx.webSearch === "off") return undefined;
|
|
239
269
|
const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
|
|
240
270
|
if (modelId.startsWith("claude-") && isAnthropicWire(ctx.api)) return "anthropic";
|
|
241
271
|
if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api)) return "gemini";
|
|
242
272
|
if (looksXaiFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) return "xai";
|
|
243
|
-
|
|
244
|
-
|
|
273
|
+
// `codex` hits the ChatGPT backend with local Codex OAuth, so only infer it
|
|
274
|
+
// for genuine OpenAI endpoints. Custom/proxy OpenAI-compatible models fall
|
|
275
|
+
// through to `activeContextNativeId` → `openai-compatible` (their own creds).
|
|
276
|
+
if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api) && isOpenAIOfficialBaseUrl(ctx.baseUrl)) {
|
|
277
|
+
return "codex";
|
|
245
278
|
}
|
|
246
279
|
return undefined;
|
|
247
280
|
}
|
|
@@ -249,8 +282,9 @@ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | und
|
|
|
249
282
|
function canUseDirectProviderMapping(ctx: ActiveSearchModelContext, id: SearchProviderId): boolean {
|
|
250
283
|
if (ctx.webSearch === "off") return false;
|
|
251
284
|
if (id !== "codex") return true;
|
|
252
|
-
|
|
253
|
-
|
|
285
|
+
// Same constraint as inference: the ChatGPT-backed codex provider is valid
|
|
286
|
+
// only for official OpenAI endpoints, not custom/proxy base URLs.
|
|
287
|
+
return isOpenAIOfficialBaseUrl(ctx.baseUrl);
|
|
254
288
|
}
|
|
255
289
|
|
|
256
290
|
export async function canUseGenericCredentials(
|
|
@@ -268,17 +302,35 @@ export async function canUseGenericCredentials(
|
|
|
268
302
|
return Boolean(key);
|
|
269
303
|
}
|
|
270
304
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Native web-search provider to attempt by reusing the ACTIVE model's own
|
|
307
|
+
* credentials + baseUrl, dispatched by the model's wire protocol.
|
|
308
|
+
*
|
|
309
|
+
* This is the "native search over a proxy" path: when a model is served through
|
|
310
|
+
* a proxy/custom endpoint, its canonical search credentials (e.g. a dedicated
|
|
311
|
+
* `anthropic` key, or ChatGPT OAuth for `codex`) are usually absent, but the
|
|
312
|
+
* credential that authenticates the model itself — stored under the active
|
|
313
|
+
* provider id and aimed at `ctx.baseUrl` — can drive native web search just as
|
|
314
|
+
* well. Each provider's `search()` falls back to those active credentials when
|
|
315
|
+
* its canonical ones are missing.
|
|
316
|
+
*
|
|
317
|
+
* Returned ids are matched purely from the wire `api` (+ model-id family where a
|
|
318
|
+
* native tool only makes sense for that family); the providers themselves fail
|
|
319
|
+
* closed (and the chain falls through to DuckDuckGo) if the endpoint does not
|
|
320
|
+
* actually support web search.
|
|
321
|
+
*/
|
|
322
|
+
export function activeContextNativeId(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
|
|
323
|
+
if (!ctx || ctx.webSearch === "off") return undefined;
|
|
324
|
+
const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
|
|
325
|
+
// Dispatch must match exactly what each provider can service by reusing the
|
|
326
|
+
// active credential: the OpenAI-compatible adapter only speaks the two plain
|
|
327
|
+
// OpenAI wires (not azure), and the Gemini active path only speaks the public
|
|
328
|
+
// Generative Language wire (not vertex/cloud-code). Returning an id the
|
|
329
|
+
// provider would reject just wastes a guaranteed-fail attempt before DuckDuckGo.
|
|
330
|
+
if (isAnthropicWire(ctx.api) && modelId.startsWith("claude-")) return "anthropic";
|
|
331
|
+
if (ctx.api === "openai-responses" || ctx.api === "openai-completions") return "openai-compatible";
|
|
332
|
+
if (ctx.api === "google-generative-ai" && modelId.startsWith("gemini-")) return "gemini";
|
|
333
|
+
return undefined;
|
|
282
334
|
}
|
|
283
335
|
|
|
284
336
|
export async function resolveProviderChain(options: ResolveProviderChainOptions): Promise<SearchProvider[]> {
|
|
@@ -304,9 +356,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
|
|
|
304
356
|
await appendAvailable(chain, directId, authStorage);
|
|
305
357
|
const inferred = inferNativeProviderFromModel(activeModelContext);
|
|
306
358
|
if (inferred) await appendAvailable(chain, inferred, authStorage);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
359
|
+
// Native-over-proxy: when no canonical native provider was selected above,
|
|
360
|
+
// fall back to the model's own credentials (resolved under the active
|
|
361
|
+
// provider id against its baseUrl) to drive native web search. Gated on
|
|
362
|
+
// those credentials actually resolving; otherwise the chain ends at the
|
|
363
|
+
// keyless DuckDuckGo terminal fallback.
|
|
364
|
+
if (chain.length === 0) {
|
|
365
|
+
const activeNativeId = activeContextNativeId(activeModelContext);
|
|
366
|
+
if (activeNativeId && (await canUseGenericCredentials(authStorage, activeModelContext, sessionId, signal)))
|
|
367
|
+
chain.push(activeNativeId);
|
|
368
|
+
}
|
|
310
369
|
}
|
|
311
370
|
|
|
312
371
|
// Configured fallbacks are user-facing only: the internal `openai-compatible`
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
import { SearchProviderError } from "../../../web/search/types";
|
|
26
26
|
import type { SearchParams } from "./base";
|
|
27
27
|
import { SearchProvider } from "./base";
|
|
28
|
+
import { extractTextSources } from "./text-citations";
|
|
28
29
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
29
30
|
|
|
30
31
|
const DEFAULT_MODEL = "claude-haiku-4-5";
|
|
@@ -87,9 +88,10 @@ async function callSearch(
|
|
|
87
88
|
maxTokens?: number,
|
|
88
89
|
temperature?: number,
|
|
89
90
|
signal?: AbortSignal,
|
|
91
|
+
extraHeaders?: Record<string, string>,
|
|
90
92
|
): Promise<AnthropicApiResponse> {
|
|
91
93
|
const url = buildAnthropicUrl(auth);
|
|
92
|
-
const headers = buildAnthropicSearchHeaders(auth);
|
|
94
|
+
const headers = { ...(extraHeaders ?? {}), ...buildAnthropicSearchHeaders(auth) };
|
|
93
95
|
|
|
94
96
|
const systemBlocks = buildSystemBlocks(auth, model, systemPrompt);
|
|
95
97
|
|
|
@@ -191,7 +193,7 @@ function parseResponse(response: AnthropicApiResponse): SearchResponse {
|
|
|
191
193
|
if (block.input?.query) {
|
|
192
194
|
searchQueries.push(block.input.query);
|
|
193
195
|
}
|
|
194
|
-
} else if (block.type === "web_search_tool_result" && block.content) {
|
|
196
|
+
} else if (block.type === "web_search_tool_result" && Array.isArray(block.content)) {
|
|
195
197
|
// Search results
|
|
196
198
|
for (const result of block.content) {
|
|
197
199
|
if (result.type === "web_search_result") {
|
|
@@ -235,6 +237,34 @@ function parseResponse(response: AnthropicApiResponse): SearchResponse {
|
|
|
235
237
|
};
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Whether the response carries proof that a web search actually ran: a
|
|
242
|
+
* `web_search_tool_result` block, a `web_search` server tool call, or a
|
|
243
|
+
* non-zero `server_tool_use.web_search_requests` usage counter.
|
|
244
|
+
*/
|
|
245
|
+
function anthropicSearchPerformed(response: AnthropicApiResponse): boolean {
|
|
246
|
+
if (response.usage?.server_tool_use?.web_search_requests) return true;
|
|
247
|
+
for (const block of response.content ?? []) {
|
|
248
|
+
if (block.type === "web_search_tool_result") {
|
|
249
|
+
// `content` is an array of results on success but an error OBJECT
|
|
250
|
+
// (`web_search_tool_result_error`) on failure; only count a result
|
|
251
|
+
// array with at least one real result as proof of search.
|
|
252
|
+
if (Array.isArray(block.content) && block.content.some(result => result.type === "web_search_result")) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
block.type === "server_tool_use" &&
|
|
259
|
+
block.name &&
|
|
260
|
+
stripClaudeToolPrefix(block.name) === WEB_SEARCH_TOOL_NAME
|
|
261
|
+
) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
/**
|
|
239
269
|
* Executes a web search using Anthropic's Anthropic model with built-in web search tool.
|
|
240
270
|
* @param params - Search parameters including query and optional settings
|
|
@@ -248,6 +278,10 @@ export async function searchAnthropic(
|
|
|
248
278
|
const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
|
|
249
279
|
const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
|
|
250
280
|
let auth: AnthropicAuthConfig | undefined;
|
|
281
|
+
// When reusing the active model's own credentials (native search over a
|
|
282
|
+
// proxy), prefer its wire model id and carry its request headers through.
|
|
283
|
+
let modelOverride: string | undefined;
|
|
284
|
+
let extraHeaders: Record<string, string> | undefined;
|
|
251
285
|
|
|
252
286
|
if (searchApiKey) {
|
|
253
287
|
auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
|
|
@@ -256,6 +290,23 @@ export async function searchAnthropic(
|
|
|
256
290
|
signal: params.signal,
|
|
257
291
|
});
|
|
258
292
|
if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
|
|
293
|
+
|
|
294
|
+
// Fall back to the active model's own credentials + baseUrl when no
|
|
295
|
+
// canonical Anthropic key exists but the active model speaks the
|
|
296
|
+
// Anthropic wire (e.g. Claude served through a proxy).
|
|
297
|
+
const ctx = params.activeModelContext;
|
|
298
|
+
if (!auth && ctx && ctx.api === "anthropic-messages") {
|
|
299
|
+
const ctxKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
|
|
300
|
+
baseUrl: ctx.baseUrl,
|
|
301
|
+
modelId: ctx.modelId,
|
|
302
|
+
signal: params.signal,
|
|
303
|
+
});
|
|
304
|
+
if (ctxKey) {
|
|
305
|
+
auth = buildAnthropicAuthConfig(ctxKey, ctx.baseUrl);
|
|
306
|
+
modelOverride = ctx.wireModelId ?? ctx.modelId;
|
|
307
|
+
extraHeaders = ctx.headers;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
259
310
|
}
|
|
260
311
|
|
|
261
312
|
if (!auth) {
|
|
@@ -264,7 +315,7 @@ export async function searchAnthropic(
|
|
|
264
315
|
);
|
|
265
316
|
}
|
|
266
317
|
|
|
267
|
-
const model = getModel();
|
|
318
|
+
const model = modelOverride ?? getModel();
|
|
268
319
|
const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
|
|
269
320
|
const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
|
|
270
321
|
const response = await callSearch(
|
|
@@ -275,9 +326,25 @@ export async function searchAnthropic(
|
|
|
275
326
|
maxTokens,
|
|
276
327
|
params.temperature,
|
|
277
328
|
params.signal,
|
|
329
|
+
extraHeaders,
|
|
278
330
|
);
|
|
279
331
|
|
|
280
332
|
const result = parseResponse(response);
|
|
333
|
+
const searched = anthropicSearchPerformed(response);
|
|
334
|
+
|
|
335
|
+
// When a search ran but the model wrote its citations inline instead of as
|
|
336
|
+
// structured `web_search_result_location` blocks, recover sources from the
|
|
337
|
+
// answer text so a genuinely grounded result is not discarded.
|
|
338
|
+
if (result.sources.length === 0 && searched && result.answer) {
|
|
339
|
+
const inline = extractTextSources(result.answer);
|
|
340
|
+
if (inline.length > 0) result.sources = inline;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fail closed so the chain falls through to DuckDuckGo when Claude answered
|
|
344
|
+
// from stable knowledge without running a web search.
|
|
345
|
+
if (result.sources.length === 0 && !(result.citations && result.citations.length > 0) && !searched) {
|
|
346
|
+
throw new SearchProviderError("anthropic", "Anthropic web search returned no grounded sources", 424);
|
|
347
|
+
}
|
|
281
348
|
|
|
282
349
|
const numResults = "authStorage" in params ? (params.numSearchResults ?? params.limit) : params.num_results;
|
|
283
350
|
if (numResults && result.sources.length > numResults) {
|
|
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
|
15
15
|
import { SearchProviderError } from "../../../web/search/types";
|
|
16
16
|
import type { SearchParams } from "./base";
|
|
17
17
|
import { SearchProvider } from "./base";
|
|
18
|
+
import { addSource, extractTextSources } from "./text-citations";
|
|
18
19
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
19
20
|
|
|
20
21
|
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
@@ -118,125 +119,6 @@ function isImagePlaceholderAnswer(text: string): boolean {
|
|
|
118
119
|
return text.trim().toLowerCase() === "(see attached image)";
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
function addSource(sources: SearchSource[], source: SearchSource): void {
|
|
122
|
-
if (!sources.some(existing => existing.url === source.url)) {
|
|
123
|
-
sources.push(source);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function countCharacter(text: string, target: string): number {
|
|
128
|
-
let count = 0;
|
|
129
|
-
for (const char of text) {
|
|
130
|
-
if (char === target) {
|
|
131
|
-
count += 1;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return count;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Strips prose punctuation and unmatched closing delimiters from extracted URLs.
|
|
139
|
-
* OpenAI code backend often returns links in markdown or sentence text without structured annotations.
|
|
140
|
-
*/
|
|
141
|
-
function normalizeExtractedUrl(candidate: string): string | null {
|
|
142
|
-
let url = candidate.trim();
|
|
143
|
-
|
|
144
|
-
while (url.length > 0) {
|
|
145
|
-
const lastCharacter = url.at(-1);
|
|
146
|
-
if (!lastCharacter) break;
|
|
147
|
-
if (/[.,!?;:'"]/u.test(lastCharacter)) {
|
|
148
|
-
url = url.slice(0, -1);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (lastCharacter === ")" && countCharacter(url, ")") > countCharacter(url, "(")) {
|
|
152
|
-
url = url.slice(0, -1);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (lastCharacter === "]" && countCharacter(url, "]") > countCharacter(url, "[")) {
|
|
156
|
-
url = url.slice(0, -1);
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (lastCharacter === "}" && countCharacter(url, "}") > countCharacter(url, "{")) {
|
|
160
|
-
url = url.slice(0, -1);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!/^https?:\/\//.test(url)) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
return new URL(url).toString();
|
|
172
|
-
} catch {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function findMarkdownLinkUrlEnd(text: string, openParenIndex: number): number | null {
|
|
178
|
-
let depth = 0;
|
|
179
|
-
|
|
180
|
-
for (let index = openParenIndex; index < text.length; index += 1) {
|
|
181
|
-
const character = text[index];
|
|
182
|
-
if (!character || character === "\n") {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
if (character === "(") {
|
|
186
|
-
depth += 1;
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (character !== ")") {
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
depth -= 1;
|
|
193
|
-
if (depth === 0) {
|
|
194
|
-
return index;
|
|
195
|
-
}
|
|
196
|
-
if (depth < 0) {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Extracts citation sources from markdown links and bare URLs in the answer text.
|
|
206
|
-
* Used as a fallback when the OpenAI code backend response omits `url_citation` annotations.
|
|
207
|
-
*/
|
|
208
|
-
function extractTextSources(text: string): SearchSource[] {
|
|
209
|
-
const sources: SearchSource[] = [];
|
|
210
|
-
|
|
211
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
212
|
-
if (text[index] !== "[") {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const titleEnd = text.indexOf("]", index + 1);
|
|
216
|
-
if (titleEnd === -1 || text[titleEnd + 1] !== "(") {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
const urlEnd = findMarkdownLinkUrlEnd(text, titleEnd + 1);
|
|
220
|
-
if (urlEnd === null) {
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const title = text.slice(index + 1, titleEnd).trim();
|
|
224
|
-
const url = normalizeExtractedUrl(text.slice(titleEnd + 2, urlEnd));
|
|
225
|
-
if (url) {
|
|
226
|
-
addSource(sources, { title: title || url, url });
|
|
227
|
-
}
|
|
228
|
-
index = urlEnd;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
for (const match of text.matchAll(/https?:\/\/\S+/g)) {
|
|
232
|
-
const url = normalizeExtractedUrl(match[0] ?? "");
|
|
233
|
-
if (!url) continue;
|
|
234
|
-
addSource(sources, { title: url, url });
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return sources;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
122
|
/**
|
|
241
123
|
* Extracts account ID from a OpenAI code backend access token.
|
|
242
124
|
* @param accessToken - JWT access token
|
|
@@ -425,6 +425,98 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
|
|
|
425
425
|
};
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Native Gemini web search over the public Generative Language REST API
|
|
430
|
+
* (`{baseUrl}/v1beta/models/{model}:generateContent`), reusing the ACTIVE
|
|
431
|
+
* model's own API key + baseUrl. This is the "native search over a proxy" path
|
|
432
|
+
* for `google-generative-ai` wire models whose canonical gemini-cli/antigravity
|
|
433
|
+
* OAuth is absent. Distinct from {@link searchGemini}, which speaks the Cloud
|
|
434
|
+
* Code Assist API with OAuth.
|
|
435
|
+
*/
|
|
436
|
+
async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<SearchResponse> {
|
|
437
|
+
const ctx = params.activeModelContext;
|
|
438
|
+
if (!ctx) throw new SearchProviderError("gemini", "Gemini web search requires active model context", 400);
|
|
439
|
+
const apiKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
|
|
440
|
+
baseUrl: ctx.baseUrl,
|
|
441
|
+
modelId: ctx.modelId,
|
|
442
|
+
signal: params.signal,
|
|
443
|
+
});
|
|
444
|
+
if (!apiKey) throw new SearchProviderError("gemini", `No credentials for ${ctx.provider}`, 401);
|
|
445
|
+
|
|
446
|
+
const model = ctx.wireModelId ?? ctx.modelId;
|
|
447
|
+
const base = (ctx.baseUrl ?? "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
|
|
448
|
+
// Respect an already-versioned active baseUrl (e.g. a proxy exposing `…/v1beta`)
|
|
449
|
+
// instead of double-appending the version segment.
|
|
450
|
+
const versionedBase = /\/v1(beta|alpha)?$/.test(base) ? base : `${base}/v1beta`;
|
|
451
|
+
const url = `${versionedBase}/models/${encodeURIComponent(model)}:generateContent`;
|
|
452
|
+
const systemPrompt = params.systemPrompt?.toWellFormed();
|
|
453
|
+
const body: Record<string, unknown> = {
|
|
454
|
+
contents: [{ role: "user", parts: [{ text: params.query }] }],
|
|
455
|
+
tools: buildGeminiRequestTools({ google_search: params.googleSearch }),
|
|
456
|
+
...(systemPrompt ? { systemInstruction: { parts: [{ text: systemPrompt }] } } : {}),
|
|
457
|
+
};
|
|
458
|
+
if (params.maxOutputTokens !== undefined || params.temperature !== undefined) {
|
|
459
|
+
const generationConfig: Record<string, number> = {};
|
|
460
|
+
if (params.maxOutputTokens !== undefined) generationConfig.maxOutputTokens = params.maxOutputTokens;
|
|
461
|
+
if (params.temperature !== undefined) generationConfig.temperature = params.temperature;
|
|
462
|
+
body.generationConfig = generationConfig;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const response = await fetch(url, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { ...(ctx.headers ?? {}), "x-goog-api-key": apiKey, "Content-Type": "application/json" },
|
|
468
|
+
body: JSON.stringify(body),
|
|
469
|
+
signal: withHardTimeout(params.signal),
|
|
470
|
+
});
|
|
471
|
+
const text = await response.text();
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
const classified = classifyProviderHttpError("gemini", response.status, text);
|
|
474
|
+
if (classified) throw classified;
|
|
475
|
+
throw new SearchProviderError("gemini", `Gemini API error (${response.status}): ${text}`, response.status);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const json = text ? JSON.parse(text) : {};
|
|
479
|
+
const candidate = json.candidates?.[0];
|
|
480
|
+
const grounding: GeminiGroundingMetadata | undefined = candidate?.groundingMetadata;
|
|
481
|
+
const answer = (candidate?.content?.parts ?? []).map((part: { text?: string }) => part.text ?? "").join("");
|
|
482
|
+
|
|
483
|
+
const sources: SearchSource[] = [];
|
|
484
|
+
const citations: SearchCitation[] = [];
|
|
485
|
+
const searchQueries: string[] = [];
|
|
486
|
+
const seenUrls = new Set<string>();
|
|
487
|
+
const chunks = grounding?.groundingChunks ?? [];
|
|
488
|
+
for (const grChunk of chunks) {
|
|
489
|
+
const uri = grChunk.web?.uri;
|
|
490
|
+
if (uri && !seenUrls.has(uri)) {
|
|
491
|
+
seenUrls.add(uri);
|
|
492
|
+
sources.push({ title: grChunk.web?.title ?? uri, url: uri });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
for (const support of grounding?.groundingSupports ?? []) {
|
|
496
|
+
const citedText = support.segment?.text;
|
|
497
|
+
for (const idx of support.groundingChunkIndices ?? []) {
|
|
498
|
+
const uri = chunks[idx]?.web?.uri;
|
|
499
|
+
if (uri) citations.push({ url: uri, title: chunks[idx]?.web?.title ?? uri, citedText });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const q of grounding?.webSearchQueries ?? []) {
|
|
503
|
+
if (!searchQueries.includes(q)) searchQueries.push(q);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (sources.length === 0) {
|
|
507
|
+
throw new SearchProviderError("gemini", "Gemini native search returned no grounding sources", 424);
|
|
508
|
+
}
|
|
509
|
+
const limit = params.numSearchResults ?? params.limit;
|
|
510
|
+
return {
|
|
511
|
+
provider: "gemini",
|
|
512
|
+
answer: answer || undefined,
|
|
513
|
+
sources: limit && sources.length > limit ? sources.slice(0, limit) : sources,
|
|
514
|
+
citations: citations.length > 0 ? citations : undefined,
|
|
515
|
+
searchQueries: searchQueries.length > 0 ? searchQueries : undefined,
|
|
516
|
+
model: json.modelVersion ?? model,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
428
520
|
/** Search provider for Google Gemini web search. */
|
|
429
521
|
export class GeminiProvider extends SearchProvider {
|
|
430
522
|
readonly id = "gemini";
|
|
@@ -438,6 +530,13 @@ export class GeminiProvider extends SearchProvider {
|
|
|
438
530
|
}
|
|
439
531
|
|
|
440
532
|
search(params: SearchParams): Promise<SearchResponse> {
|
|
533
|
+
// Native-over-proxy: when canonical gemini-cli/antigravity OAuth is
|
|
534
|
+
// absent but the active model speaks the Generative Language wire, reuse
|
|
535
|
+
// its own API key + baseUrl instead of failing closed.
|
|
536
|
+
const ctx = params.activeModelContext;
|
|
537
|
+
if (!hasGeminiOAuth(params.authStorage) && ctx?.api === "google-generative-ai") {
|
|
538
|
+
return searchGeminiViaGenerativeLanguage(params);
|
|
539
|
+
}
|
|
441
540
|
return searchGemini({
|
|
442
541
|
query: params.query,
|
|
443
542
|
system_prompt: params.systemPrompt,
|