mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
5 changed files with 469 additions and 14 deletions
@ -0,0 +1,112 @@ |
|||
import { getWebFetchBody } from './web-fetch.helper'; |
|||
|
|||
const FAILURE_MESSAGE = |
|||
'{"url":"https://www.example.com/data.json","status":"failed","error":"HTTP 405: Method Not Allowed","_failure_reason":"http_error","http_status":405}'; |
|||
|
|||
const BODY = '{"items":[{"id":1,"name":"Example"}],"total":1}'; |
|||
|
|||
describe('getWebFetchBody', () => { |
|||
describe('with a JSON route', () => { |
|||
it.each([ |
|||
['the raw body', BODY], |
|||
['leading line breaks', `\n\n\n\n${BODY}`], |
|||
['a code fence', `\n\n\`\`\`json\n${BODY}\n\`\`\``], |
|||
['an intro text', `I'll fetch that URL for you.\n\n${BODY}`] |
|||
])('should get the body from %s', (_, candidate) => { |
|||
expect(getWebFetchBody({ candidate, isJson: true })).toBe(BODY); |
|||
}); |
|||
|
|||
it('should get an array', () => { |
|||
expect( |
|||
getWebFetchBody({ candidate: 'Result:\n[{"a":1}]', isJson: true }) |
|||
).toBe('[{"a":1}]'); |
|||
}); |
|||
|
|||
it.each([ |
|||
['the raw message', FAILURE_MESSAGE], |
|||
[ |
|||
'a text before the message', |
|||
`The fetch failed — no response body was returned. The tool reported:\n\n${FAILURE_MESSAGE}` |
|||
], |
|||
[ |
|||
'a text around the message', |
|||
`I'll fetch that URL for you.\n\nThe fetch did not succeed, so there is no response body to return. The exact tool result was:\n\n${FAILURE_MESSAGE}\n\nHTTP 405 (Method Not Allowed) means the server rejected the request method used by the fetch tool for this endpoint.` |
|||
], |
|||
[ |
|||
'a formatted message', |
|||
'I\'ll fetch that URL for you.\n\n{\n "url": "https://www.example.com/data.json",\n "status": "failed",\n "error": "The engine returned no content for this URL.",\n "_failure_reason": "empty_result"\n}' |
|||
] |
|||
])('should reject a failure message: %s', (_, candidate) => { |
|||
expect(getWebFetchBody({ candidate, isJson: true })).toBeUndefined(); |
|||
}); |
|||
|
|||
it.each([ |
|||
['an empty text', ' \n '], |
|||
['a number', '42'], |
|||
['a string', '"Example"'], |
|||
['null', 'null'], |
|||
[ |
|||
'a text without JSON', |
|||
'`AccessDenied`Access Denied\n\n<Error>\n\n<Code>AccessDenied</Code>\n\n<Message>Access Denied</Message>\n\n...\n\n</Error>' |
|||
], |
|||
[ |
|||
'a failure in plain words', |
|||
'The fetch did not succeed, so there is no response body to return.\n\n- Status: failed\n- HTTP status: 403 (Forbidden)' |
|||
], |
|||
[ |
|||
'a body with broken JSON', |
|||
'{"items":{"a":{"id":1,"value":0.1},"b\\":{\\"id\\":2,\\"value\\":0.2},"c":{"id":3,"value":0.3}}}' |
|||
], |
|||
['two JSON objects', `${BODY}\n\n${BODY}`], |
|||
['a citation marker', 'The fetch failed with HTTP 403 [1].'], |
|||
['an empty array after a text', 'No data was returned []'], |
|||
['an object after a citation marker', 'Sources [1]: {"a":1}'], |
|||
['an object after a text in brackets', `[Note] ${BODY}`], |
|||
[ |
|||
'an array in a broken object', |
|||
'{"name":"bad\\q","items":[{"id":1},{"id":2}],"total":2}' |
|||
], |
|||
['an object in a broken array', '[{"a":1},]'], |
|||
[ |
|||
'a false tool call', |
|||
'\n\n{"url":"https://www.example.com/data.json"}</parameter>' |
|||
] |
|||
])('should reject %s', (_, candidate) => { |
|||
expect(getWebFetchBody({ candidate, isJson: true })).toBeUndefined(); |
|||
}); |
|||
|
|||
it('should not reject an object which has only some keys of a failure message', () => { |
|||
const candidate = '{"id":1,"status":"failed"}'; |
|||
|
|||
expect(getWebFetchBody({ candidate, isJson: true })).toBe(candidate); |
|||
}); |
|||
}); |
|||
|
|||
describe('with a route for other content', () => { |
|||
it.each([ |
|||
['a text', 'Plain text'], |
|||
['HTML with brackets', '<script>if (a) { b(); }</script>'], |
|||
['JSON', BODY] |
|||
])('should get %s without a change', (_, candidate) => { |
|||
expect(getWebFetchBody({ candidate, isJson: false })).toBe(candidate); |
|||
}); |
|||
|
|||
it('should remove the white space around the body', () => { |
|||
expect(getWebFetchBody({ candidate: ' \nBody\n ', isJson: false })).toBe( |
|||
'Body' |
|||
); |
|||
}); |
|||
|
|||
it('should reject a failure message', () => { |
|||
expect( |
|||
getWebFetchBody({ candidate: FAILURE_MESSAGE, isJson: false }) |
|||
).toBeUndefined(); |
|||
}); |
|||
|
|||
it('should reject an empty text', () => { |
|||
expect( |
|||
getWebFetchBody({ candidate: ' \n ', isJson: false }) |
|||
).toBeUndefined(); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,70 @@ |
|||
/** |
|||
* Gets the response body from a candidate of the `web_fetch` tool. The model |
|||
* retypes the body, so that JSON can have code fences or text around it. Gives |
|||
* back `undefined` if the candidate has no usable body. |
|||
*/ |
|||
export function getWebFetchBody({ |
|||
candidate, |
|||
isJson |
|||
}: { |
|||
candidate: string; |
|||
isJson: boolean; |
|||
}): string | undefined { |
|||
const body = candidate.trim(); |
|||
|
|||
if (!body) { |
|||
return undefined; |
|||
} |
|||
|
|||
const json = extractJson(body); |
|||
|
|||
if (isWebFetchFailureMessage(json?.value)) { |
|||
return undefined; |
|||
} |
|||
|
|||
if (!isJson) { |
|||
return body; |
|||
} |
|||
|
|||
return json?.isWhole ? json.text : undefined; |
|||
} |
|||
|
|||
/** |
|||
* Extracts the JSON object or array from a text. The JSON must start at the |
|||
* beginning of a line and goes to the last matching closing bracket, so that a |
|||
* bracket in a sentence gives no result. `isWhole` tells if only a closing code |
|||
* fence follows the JSON. |
|||
*/ |
|||
function extractJson(text: string) { |
|||
const start = text.search(/^[[{]/m); |
|||
|
|||
if (start < 0) { |
|||
return undefined; |
|||
} |
|||
|
|||
const end = text.lastIndexOf(text[start] === '{' ? '}' : ']') + 1; |
|||
const jsonText = text.slice(start, end); |
|||
|
|||
try { |
|||
return { |
|||
isWhole: /^(```)?$/.test(text.slice(end).trim()), |
|||
text: jsonText, |
|||
value: JSON.parse(jsonText) as unknown |
|||
}; |
|||
} catch { |
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Checks for the result of a failed fetch, which the model can give back as if |
|||
* it was the response body: |
|||
* { "url": "…", "status": "failed", "error": "HTTP 405: Method Not Allowed" } |
|||
*/ |
|||
function isWebFetchFailureMessage(value: unknown) { |
|||
const { error, status, url } = (value ?? {}) as Record<string, unknown>; |
|||
|
|||
return ( |
|||
status === 'failed' && typeof error === 'string' && typeof url === 'string' |
|||
); |
|||
} |
|||
@ -0,0 +1,259 @@ |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
PROPERTY_API_KEY_OPENROUTER, |
|||
PROPERTY_OPENROUTER_ENGINE_WEB_FETCH, |
|||
PROPERTY_OPENROUTER_MODEL_WEB_FETCH, |
|||
PROPERTY_WEB_FETCH_ROUTES |
|||
} from '@ghostfolio/common/config'; |
|||
|
|||
import { Logger } from '@nestjs/common'; |
|||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
|||
import { generateText } from 'ai'; |
|||
|
|||
import { FetchService } from './fetch.service'; |
|||
|
|||
// The packages are ECMAScript modules, which Jest cannot load
|
|||
jest.mock('@openrouter/ai-sdk-provider', () => { |
|||
return { createOpenRouter: jest.fn() }; |
|||
}); |
|||
jest.mock('ai', () => { |
|||
return { |
|||
generateText: jest.fn(), |
|||
jsonSchema: jest.fn(), |
|||
tool: jest.fn((options) => { |
|||
return options; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
const BODY = '{"items":[{"id":1,"name":"Example"}],"total":1}'; |
|||
|
|||
const FAILURE_MESSAGE = |
|||
'{"url":"https://www.example.com/data.json","status":"failed","error":"HTTP 405: Method Not Allowed","http_status":405}'; |
|||
|
|||
const URL_WITH_ROUTE = 'https://www.example.com/data.json'; |
|||
|
|||
describe('FetchService', () => { |
|||
let directFetch: jest.SpyInstance; |
|||
let fetchService: FetchService; |
|||
let properties: Record<string, unknown>; |
|||
|
|||
const mockGenerateText = (result: { |
|||
finishReason?: string; |
|||
sources?: unknown[]; |
|||
text?: string; |
|||
}) => { |
|||
(generateText as jest.Mock).mockResolvedValue({ |
|||
finishReason: 'stop', |
|||
sources: [], |
|||
text: '', |
|||
...result |
|||
}); |
|||
}; |
|||
|
|||
beforeEach(async () => { |
|||
jest.clearAllMocks(); |
|||
|
|||
(createOpenRouter as jest.Mock).mockReturnValue({ |
|||
chat: jest.fn((model: string) => { |
|||
return model; |
|||
}) |
|||
}); |
|||
|
|||
directFetch = jest |
|||
.spyOn(globalThis, 'fetch') |
|||
.mockResolvedValue(new Response('direct')); |
|||
|
|||
properties = { |
|||
[PROPERTY_API_KEY_OPENROUTER]: 'api-key', |
|||
[PROPERTY_OPENROUTER_ENGINE_WEB_FETCH]: 'parallel', |
|||
[PROPERTY_OPENROUTER_MODEL_WEB_FETCH]: 'model', |
|||
[PROPERTY_WEB_FETCH_ROUTES]: [ |
|||
{ |
|||
domain: 'example.com', |
|||
responseContentType: 'application/json' |
|||
}, |
|||
{ domain: 'example.org' } |
|||
] |
|||
}; |
|||
|
|||
fetchService = new FetchService({ |
|||
getByKey: jest.fn((key: string) => { |
|||
return Promise.resolve(properties[key]); |
|||
}) |
|||
} as unknown as PropertyService); |
|||
|
|||
await fetchService.onModuleInit(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
directFetch.mockRestore(); |
|||
}); |
|||
|
|||
it('should get the body from the text of the model', async () => { |
|||
mockGenerateText({ text: `\`\`\`json\n${BODY}\n\`\`\`` }); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe(BODY); |
|||
|
|||
expect(response.headers.get('content-type')).toBe('application/json'); |
|||
expect(directFetch).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should get the body from the content of a source first', async () => { |
|||
mockGenerateText({ |
|||
sources: [{ providerMetadata: { openrouter: { content: BODY } } }], |
|||
text: '{"text":true}' |
|||
}); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe(BODY); |
|||
}); |
|||
|
|||
it('should use the text of the model after a source with a failure message', async () => { |
|||
mockGenerateText({ |
|||
sources: [ |
|||
{ providerMetadata: { openrouter: { content: FAILURE_MESSAGE } } } |
|||
], |
|||
text: BODY |
|||
}); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe(BODY); |
|||
}); |
|||
|
|||
it('should get the body from the content of a source on a truncated text', async () => { |
|||
mockGenerateText({ |
|||
finishReason: 'length', |
|||
sources: [{ providerMetadata: { openrouter: { content: BODY } } }], |
|||
text: '{"items":[' |
|||
}); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe(BODY); |
|||
|
|||
expect(directFetch).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should log the failure message of a source after an empty text', async () => { |
|||
const debug = jest.spyOn(Logger.prototype, 'debug'); |
|||
|
|||
mockGenerateText({ |
|||
sources: [ |
|||
{ providerMetadata: { openrouter: { content: FAILURE_MESSAGE } } } |
|||
], |
|||
text: '' |
|||
}); |
|||
|
|||
await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
expect(debug.mock.calls.join()).toContain('HTTP 405'); |
|||
|
|||
debug.mockRestore(); |
|||
}); |
|||
|
|||
it('should redact the API keys in the log of a rejected body', async () => { |
|||
const debug = jest.spyOn(Logger.prototype, 'debug'); |
|||
|
|||
mockGenerateText({ |
|||
text: '{"url":"https://www.example.com/data.json?apikey=secret&api_token=secret","status":"failed","error":"HTTP 405"}' |
|||
}); |
|||
|
|||
await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
const logs = debug.mock.calls.join(); |
|||
|
|||
expect(logs).toContain('apikey=*******'); |
|||
expect(logs).toContain('api_token=*******'); |
|||
expect(logs).not.toContain('secret'); |
|||
|
|||
debug.mockRestore(); |
|||
}); |
|||
|
|||
it('should call the model with temperature 0 and the configured engine', async () => { |
|||
mockGenerateText({ text: BODY }); |
|||
|
|||
await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
expect(generateText).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
model: 'model', |
|||
temperature: 0, |
|||
tools: { |
|||
web_fetch: expect.objectContaining({ |
|||
args: { parameters: { engine: 'parallel' } } |
|||
}) |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it.each([ |
|||
['a failure message', { text: FAILURE_MESSAGE }], |
|||
['a text without JSON', { text: 'The fetch did not succeed.' }], |
|||
['an empty text', { text: '' }], |
|||
['a truncated body', { finishReason: 'length', text: BODY }], |
|||
['a content filter', { finishReason: 'content-filter', text: BODY }] |
|||
])('should use the direct request on %s', async (_, result) => { |
|||
mockGenerateText(result); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe('direct'); |
|||
|
|||
expect(directFetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('should use the direct request on an error of the model', async () => { |
|||
(generateText as jest.Mock).mockRejectedValue(new Error('Timeout')); |
|||
|
|||
const response = await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
await expect(response.text()).resolves.toBe('direct'); |
|||
}); |
|||
|
|||
it('should get a body which is not JSON for a route without a content type', async () => { |
|||
mockGenerateText({ text: 'Plain text' }); |
|||
|
|||
const response = await fetchService.fetch('https://example.org/page'); |
|||
|
|||
await expect(response.text()).resolves.toBe('Plain text'); |
|||
|
|||
expect(directFetch).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should reject a failure message for a route without a content type', async () => { |
|||
mockGenerateText({ text: FAILURE_MESSAGE }); |
|||
|
|||
const response = await fetchService.fetch('https://example.org/page'); |
|||
|
|||
await expect(response.text()).resolves.toBe('direct'); |
|||
}); |
|||
|
|||
it('should not use the model without an API key', async () => { |
|||
delete properties[PROPERTY_API_KEY_OPENROUTER]; |
|||
|
|||
await fetchService.fetch(URL_WITH_ROUTE); |
|||
|
|||
expect(generateText).not.toHaveBeenCalled(); |
|||
expect(directFetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('should not use the model for a request other than GET', async () => { |
|||
await fetchService.fetch(URL_WITH_ROUTE, { method: 'POST' }); |
|||
|
|||
expect(generateText).not.toHaveBeenCalled(); |
|||
expect(directFetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('should not use the model for a domain without a route', async () => { |
|||
await fetchService.fetch('https://www.ghostfol.io'); |
|||
|
|||
expect(generateText).not.toHaveBeenCalled(); |
|||
expect(directFetch).toHaveBeenCalledTimes(1); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue