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.
 
 
 
 
 

205 lines
5.6 KiB

import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL,
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 ms from 'ms';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable()
export class FetchService implements OnModuleInit {
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
this.webFetchRoutes =
(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);
Logger.debug(`${method} ${urlRedacted}`, 'FetchService');
if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url);
if (webFetchRoute) {
const response = await this.fetchViaWebFetchTool({
url,
webFetchRoute
});
if (response) {
return response;
}
}
}
try {
return await globalThis.fetch(input, init);
} catch (error) {
if (error instanceof Error) {
Logger.error(
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`,
'FetchService'
);
} else {
Logger.error(
`${method} ${urlRedacted} failed: ${String(error)}`,
'FetchService'
);
}
throw error;
}
}
private async fetchViaWebFetchTool({
url,
webFetchRoute
}: {
url: string;
webFetchRoute: WebFetchRoute;
}) {
const [openRouterApiKey, openRouterModel] = await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL)
]);
if (!openRouterApiKey || !openRouterModel) {
return undefined;
}
try {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel),
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'),
timeout: FetchService.WEB_FETCH_TIMEOUT,
tools: {
// Provider-defined tool: lets OpenRouter perform the actual web
// request server-side via its `web_fetch` engine. `id` and `args`
// are the OpenRouter-specific identifiers; the input schema is left
// open as the arguments are supplied by the model.
web_fetch: tool({
args: { engine: 'openrouter' },
id: 'openrouter.web_fetch',
inputSchema: jsonSchema({
additionalProperties: true,
type: 'object'
}),
type: 'provider'
})
}
});
const candidates = [
...(sources ?? []).map((source) => {
return source.providerMetadata?.openrouter?.content;
}),
text
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const body = candidate.trim();
if (!body) {
continue;
}
if (webFetchRoute.responseContentType?.includes('application/json')) {
try {
JSON.parse(body);
} catch {
continue;
}
}
Logger.debug(
`Routed ${this.redactUrl(url)} via web fetch tool`,
'FetchService'
);
return new Response(body, {
headers: webFetchRoute.responseContentType
? { 'content-type': webFetchRoute.responseContentType }
: undefined
});
}
return undefined;
} catch (error) {
Logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error)
}`,
'FetchService'
);
return undefined;
}
}
private getMatchingWebFetchRoute(url: string) {
try {
const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
return undefined;
}
}
private redactUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const redacted = redactPaths({
object: Object.fromEntries(url.searchParams),
paths: FetchService.REDACTED_QUERY_PARAM_NAMES
});
for (const [key, value] of Object.entries(redacted)) {
if (value === null) {
url.searchParams.set(key, '*******');
}
}
return url.toString();
} catch {
return rawUrl;
}
}
}