mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
13 KiB
464 lines
13 KiB
import {
|
|
getWebFetchBody,
|
|
getWebFetchError
|
|
} from '@ghostfolio/api/helper/web-fetch.helper';
|
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
import {
|
|
DEFAULT_OPENROUTER_ENGINE_WEB_FETCH,
|
|
PROPERTY_API_KEY_OPENROUTER,
|
|
PROPERTY_OPENROUTER_ENGINE_WEB_FETCH,
|
|
PROPERTY_OPENROUTER_MODEL,
|
|
PROPERTY_OPENROUTER_MODEL_WEB_FETCH,
|
|
PROPERTY_PROXY_ROUTES,
|
|
PROPERTY_WEB_FETCH_ROUTES
|
|
} from '@ghostfolio/common/config';
|
|
|
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
import { generateText, jsonSchema, tool } from 'ai';
|
|
import { isFQDN } from 'class-validator';
|
|
import { isPlainObject } from 'lodash-es';
|
|
import ms from 'ms';
|
|
|
|
import { ProxyRoute } from './interfaces/proxy-route.interface';
|
|
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
|
|
|
|
@Injectable()
|
|
export class FetchService implements OnModuleInit {
|
|
private readonly logger = new Logger(FetchService.name);
|
|
|
|
private static readonly BODY_PREVIEW_LENGTH = 500;
|
|
private static readonly REDACTED_QUERY_PARAM_NAMES = [
|
|
'apikey',
|
|
'api_token',
|
|
'key',
|
|
'token'
|
|
];
|
|
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
|
|
|
|
private hasLoggedMissingConfiguration = false;
|
|
private proxyRoutes: ProxyRoute[] = [];
|
|
private webFetchRoutes: WebFetchRoute[] = [];
|
|
|
|
public constructor(private readonly propertyService: PropertyService) {}
|
|
|
|
public async onModuleInit() {
|
|
this.proxyRoutes =
|
|
(await this.propertyService.getByKey<ProxyRoute[]>(
|
|
PROPERTY_PROXY_ROUTES
|
|
)) ?? [];
|
|
|
|
this.webFetchRoutes = this.getValidWebFetchRoutes(
|
|
await this.propertyService.getByKey<WebFetchRoute[]>(
|
|
PROPERTY_WEB_FETCH_ROUTES
|
|
)
|
|
);
|
|
}
|
|
|
|
public async fetch(input: RequestInfo | URL, init?: RequestInit) {
|
|
const method = (
|
|
init?.method ??
|
|
(input instanceof Request ? input.method : undefined) ??
|
|
'GET'
|
|
).toUpperCase();
|
|
|
|
const url = input instanceof Request ? input.url : input.toString();
|
|
const urlRedacted = this.redactUrl(url);
|
|
|
|
this.logger.debug(`${method} ${urlRedacted}`);
|
|
|
|
if (method === 'GET') {
|
|
const webFetchRoute = this.getMatchingWebFetchRoute(url);
|
|
|
|
if (webFetchRoute) {
|
|
const response = await this.fetchViaWebFetchTool({
|
|
url,
|
|
webFetchRoute
|
|
});
|
|
|
|
if (response) {
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
|
|
const proxiedInput = this.applyProxyRoute(input);
|
|
|
|
try {
|
|
return await globalThis.fetch(proxiedInput, init);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
this.logger.error(
|
|
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`
|
|
);
|
|
} else {
|
|
this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async fetchViaWebFetchTool({
|
|
url,
|
|
webFetchRoute
|
|
}: {
|
|
url: string;
|
|
webFetchRoute: WebFetchRoute;
|
|
}) {
|
|
const [
|
|
openRouterApiKey,
|
|
openRouterEngineWebFetch,
|
|
openRouterModel,
|
|
openRouterModelWebFetch
|
|
] = await Promise.all([
|
|
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
|
|
this.propertyService.getByKey<string>(
|
|
PROPERTY_OPENROUTER_ENGINE_WEB_FETCH
|
|
),
|
|
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL),
|
|
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL_WEB_FETCH)
|
|
]);
|
|
|
|
const engine =
|
|
openRouterEngineWebFetch || DEFAULT_OPENROUTER_ENGINE_WEB_FETCH;
|
|
const model = openRouterModelWebFetch || openRouterModel;
|
|
|
|
if (!model || !openRouterApiKey) {
|
|
if (!this.hasLoggedMissingConfiguration) {
|
|
const missing = [
|
|
...(openRouterApiKey ? [] : [PROPERTY_API_KEY_OPENROUTER]),
|
|
...(model ? [] : [PROPERTY_OPENROUTER_MODEL])
|
|
].join(',');
|
|
|
|
this.logger.warn(
|
|
`Web fetch tool is not available (${this.getWebFetchLogDetails({
|
|
engine,
|
|
reason: `missing_configuration missing=${missing}`
|
|
})})`
|
|
);
|
|
|
|
this.hasLoggedMissingConfiguration = true;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
|
|
|
|
const { finishReason, sources, text } = await generateText({
|
|
model: openRouterService.chat(model),
|
|
prompt: [
|
|
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.',
|
|
'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.',
|
|
`URL: ${url}`
|
|
].join('\n'),
|
|
temperature: 0,
|
|
timeout: FetchService.WEB_FETCH_TIMEOUT,
|
|
tools: {
|
|
// Provider-executed tool: lets OpenRouter perform the actual web
|
|
// request server-side via its `web_fetch` engine. `args` and `id`
|
|
// are the OpenRouter-specific identifiers. The engine must stay
|
|
// nested in `parameters`, because the provider spreads `args` on the
|
|
// top level of the tool object, where OpenRouter ignores it. Nested
|
|
// keys need snake case, as only the top level gets converted. The
|
|
// input schema is left open as the arguments are supplied by the
|
|
// model.
|
|
web_fetch: tool({
|
|
args: { parameters: { engine } },
|
|
id: 'openrouter.web_fetch',
|
|
inputSchema: jsonSchema({
|
|
additionalProperties: true,
|
|
type: 'object'
|
|
}),
|
|
isProviderExecuted: true,
|
|
type: 'provider'
|
|
})
|
|
}
|
|
});
|
|
|
|
const candidates = [
|
|
...(sources ?? []).map((source) => {
|
|
return source.providerMetadata?.openrouter?.content;
|
|
}),
|
|
...(finishReason === 'stop' ? [text] : [])
|
|
];
|
|
|
|
const isJson =
|
|
webFetchRoute.responseContentType?.includes('application/json') ??
|
|
false;
|
|
|
|
let rejectedBody: string | undefined;
|
|
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
const body = getWebFetchBody({ candidate, isJson });
|
|
|
|
if (!body) {
|
|
// A failure message tells more than the text of a later candidate
|
|
if (
|
|
!rejectedBody ||
|
|
getWebFetchError({ candidate: rejectedBody }) === undefined
|
|
) {
|
|
rejectedBody = candidate.trim() || rejectedBody;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
this.logger.log(
|
|
`Routed ${this.redactUrl(url)} via web fetch tool (${this.getWebFetchLogDetails(
|
|
{ engine, finishReason, model, startTime }
|
|
)})`
|
|
);
|
|
|
|
return new Response(body, {
|
|
headers: webFetchRoute.responseContentType
|
|
? { 'content-type': webFetchRoute.responseContentType }
|
|
: undefined
|
|
});
|
|
}
|
|
|
|
this.logger.warn(
|
|
`Web fetch tool returned no usable body for ${this.redactUrl(url)} (${this.getWebFetchLogDetails(
|
|
{
|
|
engine,
|
|
finishReason,
|
|
model,
|
|
startTime,
|
|
reason: this.getRejectionReason({ finishReason, rejectedBody })
|
|
}
|
|
)})`
|
|
);
|
|
|
|
this.logger.debug(
|
|
`Web fetch tool response for ${this.redactUrl(url)} (${
|
|
sources?.length ?? 0
|
|
} sources): ${this.getBodyPreview(rejectedBody || text)}`
|
|
);
|
|
|
|
return undefined;
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Web fetch tool failed for ${this.redactUrl(url)} (${this.getWebFetchLogDetails(
|
|
{ engine, model, startTime, reason: 'request_error' }
|
|
)}): ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rewrites the origin (protocol, host and port) of a request when its domain
|
|
* matches a configured {@link ProxyRoute}, preserving path and query. Returns
|
|
* the input unchanged when no route matches or parsing fails.
|
|
*/
|
|
private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL {
|
|
let requestUrl: URL;
|
|
|
|
try {
|
|
requestUrl = new URL(
|
|
input instanceof Request ? input.url : input.toString()
|
|
);
|
|
} catch {
|
|
return input;
|
|
}
|
|
|
|
const route = this.proxyRoutes.find(({ domain }) => {
|
|
return this.hostnameMatchesDomain({
|
|
domain,
|
|
hostname: requestUrl.hostname
|
|
});
|
|
});
|
|
|
|
if (!route) {
|
|
return input;
|
|
}
|
|
|
|
try {
|
|
const proxyUrl = new URL(route.url);
|
|
|
|
requestUrl.host = proxyUrl.host;
|
|
requestUrl.protocol = proxyUrl.protocol;
|
|
} catch {
|
|
this.logger.warn(
|
|
`Skipping proxy route for "${route.domain}": invalid url "${route.url}"`
|
|
);
|
|
|
|
return input;
|
|
}
|
|
|
|
return input instanceof Request
|
|
? new Request(requestUrl.toString(), input)
|
|
: requestUrl.toString();
|
|
}
|
|
|
|
/**
|
|
* Makes a short single-line preview of a response body for the log. Gives
|
|
* back a placeholder if the body is empty.
|
|
*/
|
|
private getBodyPreview(body: string): string {
|
|
if (!body) {
|
|
return '(empty)';
|
|
}
|
|
|
|
const singleLineBody = body
|
|
.replace(
|
|
new RegExp(
|
|
`(${FetchService.REDACTED_QUERY_PARAM_NAMES.join('|')})=[^&\\s"']+`,
|
|
'gi'
|
|
),
|
|
'$1=*******'
|
|
)
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
return singleLineBody.length > FetchService.BODY_PREVIEW_LENGTH
|
|
? `${singleLineBody.slice(0, FetchService.BODY_PREVIEW_LENGTH)}…`
|
|
: singleLineBody;
|
|
}
|
|
|
|
private getMatchingWebFetchRoute(url: string) {
|
|
try {
|
|
const { hostname } = new URL(url);
|
|
|
|
return this.webFetchRoutes.find(({ domain }) => {
|
|
return this.hostnameMatchesDomain({ domain, hostname });
|
|
});
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private getRejectionReason({
|
|
finishReason,
|
|
rejectedBody
|
|
}: {
|
|
finishReason: string;
|
|
rejectedBody?: string;
|
|
}) {
|
|
if (!rejectedBody) {
|
|
return finishReason === 'stop' ? 'empty_body' : 'finish_reason';
|
|
}
|
|
|
|
const error = getWebFetchError({ candidate: rejectedBody });
|
|
|
|
// The model can repeat the URL of the prompt, which can contain a secret
|
|
return error !== undefined
|
|
? `failure_message error="${this.getBodyPreview(error.replace(/https?:\/\/\S+/gi, '[url]'))}"`
|
|
: 'invalid_body';
|
|
}
|
|
|
|
/**
|
|
* Gives back the valid routes of the property. A route which is not valid
|
|
* would never match, so it gets a warning instead of a silent direct request.
|
|
*/
|
|
private getValidWebFetchRoutes(routes: unknown): WebFetchRoute[] {
|
|
if (routes === undefined || routes === null) {
|
|
return [];
|
|
}
|
|
|
|
if (!Array.isArray(routes)) {
|
|
this.logger.warn(
|
|
`Ignoring the property ${PROPERTY_WEB_FETCH_ROUTES}: It must be an array`
|
|
);
|
|
|
|
return [];
|
|
}
|
|
|
|
return routes.filter((route): route is WebFetchRoute => {
|
|
const isValid = this.isValidWebFetchRoute(route);
|
|
|
|
if (!isValid) {
|
|
this.logger.warn(
|
|
`Ignoring an invalid route of the property ${PROPERTY_WEB_FETCH_ROUTES}: ${JSON.stringify(route)}`
|
|
);
|
|
}
|
|
|
|
return isValid;
|
|
});
|
|
}
|
|
|
|
private getWebFetchLogDetails({
|
|
engine,
|
|
finishReason,
|
|
model,
|
|
reason,
|
|
startTime
|
|
}: {
|
|
engine: string;
|
|
finishReason?: string;
|
|
model?: string;
|
|
reason?: string;
|
|
startTime?: number;
|
|
}) {
|
|
const details = {
|
|
engine,
|
|
finishReason,
|
|
model,
|
|
duration:
|
|
startTime === undefined
|
|
? undefined
|
|
: `${Math.round(performance.now() - startTime)}ms`,
|
|
reason
|
|
};
|
|
|
|
return Object.entries(details)
|
|
.filter(([, value]) => {
|
|
return value !== undefined;
|
|
})
|
|
.map(([key, value]) => {
|
|
return `${key}=${value}`;
|
|
})
|
|
.join(' ');
|
|
}
|
|
|
|
private hostnameMatchesDomain({
|
|
domain,
|
|
hostname
|
|
}: {
|
|
domain: string;
|
|
hostname: string;
|
|
}): boolean {
|
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
}
|
|
|
|
private isValidWebFetchRoute(route: unknown) {
|
|
if (!isPlainObject(route)) {
|
|
return false;
|
|
}
|
|
|
|
const { domain, responseContentType } = route as Record<string, unknown>;
|
|
|
|
return (
|
|
isFQDN(domain) &&
|
|
(responseContentType === undefined ||
|
|
typeof responseContentType === 'string')
|
|
);
|
|
}
|
|
|
|
private redactUrl(rawUrl: string): string {
|
|
try {
|
|
const url = new URL(rawUrl);
|
|
|
|
for (const key of [...url.searchParams.keys()]) {
|
|
if (
|
|
FetchService.REDACTED_QUERY_PARAM_NAMES.includes(key.toLowerCase())
|
|
) {
|
|
url.searchParams.set(key, '*******');
|
|
}
|
|
}
|
|
|
|
return url.toString();
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}
|
|
}
|
|
|