feat(web-fetch): add opt-in AI/ML API Toolbox scraping with restored test state (#2468)

* feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend

WebFetch can already replace its raw HTTP fetch with Firecrawl's scrape
endpoint. This adds the AI/ML API Toolbox as a second scraping backend,
selected with WEB_FETCH_PROVIDER=aimlapi and authenticated with the
existing AIMLAPI_API_KEY.

Toolbox runs are billed per page, so the backend is opt-in only: an
AI/ML API key on its own never changes how WebFetch fetches pages. When
selected it takes precedence over a Firecrawl key.

- Runs the search1api/crawl tool, chosen by measurement against the
  other page-to-Markdown tools ($0.0013 per run, main-content Markdown
  with links, unbilled is_error runs for missing pages), with
  max_cost_usd capped at $0.005.
- Polls queued (202) runs, reports is_error runs and over-cost
  refusals as WebFetch errors, and retries 429/502/503/504 with the
  same idempotency key so a retry is never charged twice.
- Sends the key and the X-AIMLAPI-Source / X-AIMLAPI-Partner-ID
  attribution headers only to the fixed tools.aimlapi.com endpoint.
- Like the Firecrawl path, a failed scrape is reported rather than
  retried over plain HTTP.

* fix(web-fetch): never send an aimlapi proxy profile's key to the Toolbox

An aimlapi provider profile that points at the user's own proxy exports
the proxy's credential as AIMLAPI_API_KEY. The WebFetch Toolbox backend
now treats that key as not configured when the route is aimlapi and
OPENAI_BASE_URL (or OPENAI_API_BASE) is not the canonical
https://api.aimlapi.com/v1, so it is never sent to tools.aimlapi.com.

* fix(web-fetch): match the search backend's credential and redirect handling

Apply the same hardening #2465 gave the AI/ML API web search backend:

- send only the first usable key of a comma-separated AIMLAPI_API_KEY pool
  (firstUsableCredential), instead of the whole list as one Bearer token;
- treat the OPENAI_API_BASE alias like OPENAI_BASE_URL when deciding that an
  aimlapi profile points at a proxy, so its credential is withheld;
- send both Toolbox requests with redirect: 'error', so the key and the
  attribution headers never reach another host;
- redact the active key from Toolbox error text before it is shown.

Tests cover each case.

* docs(web-fetch): describe the requested cost limit and first-key pool use

Word the $0.005 max_cost_usd as a limit each run requests, as the merged
search docs do, and note that a pooled AIMLAPI_API_KEY contributes only its
first usable key.

* docs(web-fetch): document the alias, pool and redirect rules in the setup guide

Describe the requested $0.005 cost limit, the OPENAI_API_BASE alias in the
proxy check, first-key use of a pooled AIMLAPI_API_KEY, and that Toolbox
requests never follow redirects.

* test(web-fetch): keep the backend test's model stub and env changes local

Replace the process-global mock.module() of services/api/claude.js with a
spyOn() on queryHaiku, which mock.restore() removes, so test files that run
later in the same process get the real queryHaiku again. Calls are counted
on a local stub, since the spy reuses an earlier file's leftover module mock.

Snapshot the fixture's variables with snapshotEnv() after taking the shared
mutation lock and clear them, including CLAUDE_CODE_PROVIDER_ROUTE_ID,
OPENAI_BASE_URL and OPENAI_API_BASE, so an ambient aimlapi proxy profile no
longer withholds the test key. They are restored before the lock is released.

* fix(env-file): accept AIMLAPI_API_KEY in --provider-env-file

.env.example lists AIMLAPI_API_KEY for the file-based setup, and the AI/ML
API WebFetch backend pairs it with WEB_FETCH_PROVIDER, but the loader
rejected the key as unsupported. Allow it, and cover loading both values.

* test(web-fetch): restore inherited model mock after backend calls

* docs(web-fetch): clarify retry scope and external URL disclosure

---------

Co-authored-by: Stan <stan@aimlapi.com>
Co-authored-by: Hugo <hugo@aimlapi.com>
This commit is contained in:
JATMN
2026-10-29 16:54:40 -07:00
committed by GitHub
co-authored by Stan Hugo
parent 438d4ca616
commit f235c835d1
10 changed files with 902 additions and 2 deletions
+7
View File
@@ -573,6 +573,13 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Self-hosted Firecrawl endpoint (optional — omit to use cloud API)
# FIRECRAWL_API_URL=https://your-firecrawl-instance.com
# WebFetch page scraping through the AI/ML API Toolbox (opt-in, paid per page:
# about $0.0013 a run; each run requests a $0.005 cost limit). Uses
# AIMLAPI_API_KEY (the first key of a comma-separated pool) and takes
# precedence over a Firecrawl key for WebFetch. AIMLAPI_API_KEY alone does not
# turn it on.
# WEB_FETCH_PROVIDER=aimlapi
# Ollama Web Search. A signed-in local Ollama route needs no key; the key
# enables https://ollama.com/api/web_search as a hosted fallback.
# OLLAMA_BASE_URL=http://localhost:11434
+9
View File
@@ -508,6 +508,15 @@ With Firecrawl enabled:
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
`WebFetch` can also scrape pages through the [AI/ML API](https://aimlapi.com) Toolbox with your AI/ML API key. Each fetched page is one paid Toolbox run (about $0.0013; each run requests a $0.005 cost limit), so it is used only when you choose it:
```bash
export AIMLAPI_API_KEY=your-aimlapi-key
export WEB_FETCH_PROVIDER=aimlapi
```
`WEB_FETCH_PROVIDER=aimlapi` takes precedence over a Firecrawl key for `WebFetch`. Having `AIMLAPI_API_KEY` set without it does not change how pages are fetched. See [AI/ML API Setup](docs/aimlapi-setup.md#webfetch-through-the-aiml-api-toolbox).
## Headless gRPC Server
OpenClaude can run as a headless gRPC service with bidirectional streaming —
+35
View File
@@ -86,6 +86,41 @@ for the request and response format.
- `/model` lists chat-capable models discovered from the catalog.
- Send any prompt to confirm responses come back from the selected model.
## WebFetch through the AI/ML API Toolbox
`WebFetch` normally fetches pages over plain HTTP and converts the HTML to
Markdown, which can miss JavaScript-rendered content. With an AI/ML API key you
can have it scrape pages through the
[AI/ML API Toolbox](https://docs.aimlapi.com/quickstart/toolbox) instead:
```bash
export AIMLAPI_API_KEY="your-aimlapi-key"
export WEB_FETCH_PROVIDER=aimlapi
```
- **Opt-in.** Each fetched page is one Toolbox run of `search1api/crawl`, billed
to your AI/ML API balance (about $0.0013 per page on 2026-10-19). Every run
requests a cost limit with `max_cost_usd: 0.005`. An AI/ML API key on its own
never turns this on.
- **URL disclosure.** The full requested URL, including query parameters, is
sent to the external AI/ML API Toolbox. Do not use this backend for private,
internal, or token-bearing URLs.
- `WEB_FETCH_PROVIDER=aimlapi` takes precedence over `FIRECRAWL_API_KEY` /
`FIRECRAWL_API_URL` for `WebFetch`. `WebSearch` is not affected.
- Like the Firecrawl backend, a failed scrape (a page that does not exist, a
refused or failed run) is reported as a `WebFetch` error instead of being
retried over plain HTTP. Within one `WebFetch` call, retries of a run request
reuse the same input and idempotency key.
- The key and the attribution headers below are sent only to
`https://tools.aimlapi.com`, which is fixed and cannot be overridden. When
the active aimlapi profile points at your own proxy (a non-canonical
`OPENAI_BASE_URL` or `OPENAI_API_BASE`), its key is treated as the proxy's
and is not sent; set up a canonical AI/ML API key to use this backend. When
`AIMLAPI_API_KEY` holds a comma-separated pool, only its first usable key is
sent, with no rotation across keys. Requests never follow redirects. A
scraped page is untrusted web content and goes through the same `WebFetch`
processing as a page fetched over plain HTTP.
## Notes
- Model discovery uses the public, unauthenticated `GET /models` endpoint and surfaces only chat-completions models; image, audio, embeddings, and other modalities are intentionally not routed through the coding workflow.
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test'
import * as claudeApi from '../../services/api/claude.js'
import { snapshotEnv } from '../../test/envSnapshot.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { asMockFetch } from '../../test/typedMocks.js'
import { createAssistantMessage } from '../../utils/messages/factories.js'
// WebFetch hands scraped markdown to the secondary model. Each test routes
// queryHaiku to this stub only for the duration of the WebFetch call. A spy
// can reuse an earlier file's mock, so preserve that mock's implementation
// and count calls on our own stub.
const haikuStub = mock(async ({ userPrompt }: { userPrompt: string }) =>
createAssistantMessage({
content: `summary of: ${userPrompt.includes('# Scraped page') ? 'scraped page' : 'other'}`,
}),
)
const fetchMock = mock()
const originalFetch = globalThis.fetch
// The route and both base-URL spellings decide whether AIMLAPI_API_KEY may be
// sent: an ambient aimlapi proxy profile would withhold the test key, so they
// are cleared along with the backend selectors.
const ENV_KEYS = [
'AIMLAPI_API_KEY',
'FIRECRAWL_API_KEY',
'FIRECRAWL_API_URL',
'WEB_FETCH_PROVIDER',
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
] as const
let restoreEnv: (() => void) | undefined
beforeEach(async () => {
await acquireSharedMutationLock('tools/WebFetchTool/WebFetchTool.backend.test.ts')
restoreEnv = snapshotEnv(ENV_KEYS, { clear: true })
haikuStub.mockClear()
fetchMock.mockReset()
fetchMock.mockImplementation(async (input: unknown) => {
const host = new URL(String(input)).hostname
if (host === 'tools.aimlapi.com') {
return Response.json({
id: 'run-1',
status: 'completed',
is_error: false,
output: { results: { content: '# Scraped page' } },
cost: { quoted_usd: 0.0013, actual_usd: 0.0013, over_max: false },
})
}
if (host === 'api.firecrawl.dev') {
return Response.json({ success: true, data: { markdown: '# Scraped page' } })
}
throw new Error(`unexpected request to ${host}`)
})
globalThis.fetch = asMockFetch(fetchMock)
})
afterEach(() => {
try {
globalThis.fetch = originalFetch
restoreEnv?.()
restoreEnv = undefined
} finally {
releaseSharedMutationLock()
}
})
async function runWebFetch(url = 'https://example.com/page') {
const nonce = `${Date.now()}-${Math.random()}`
const { WebFetchTool } = await import(`./WebFetchTool.js?ts=${nonce}`)
const previousQueryHaiku = claudeApi.queryHaiku
const haikuSpy = spyOn(claudeApi, 'queryHaiku')
try {
let result!: Awaited<ReturnType<typeof WebFetchTool.call>>
await haikuSpy.withImplementation(haikuStub, async () => {
result = await WebFetchTool.call(
{ url, prompt: 'summarize' },
{
abortController: new AbortController(),
options: { isNonInteractiveSession: true },
} as Parameters<typeof WebFetchTool.call>[1],
)
})
return result
} finally {
// withImplementation restores inherited mocks, including on rejection.
// Only remove the spy when this call created it.
if (haikuSpy !== previousQueryHaiku) haikuSpy.mockRestore()
}
}
function requestedHosts(): string[] {
return fetchMock.mock.calls.map(call => new URL(String(call[0])).hostname)
}
test('an AI/ML API key alone keeps WebFetch on the Firecrawl backend', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
process.env.FIRECRAWL_API_KEY = 'fc-test-key'
const { data } = await runWebFetch()
expect(requestedHosts()).toEqual(['api.firecrawl.dev'])
expect(data.result).toBe('summary of: scraped page')
})
test('WEB_FETCH_PROVIDER=aimlapi scrapes through the AI/ML API Toolbox, ahead of Firecrawl', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
process.env.FIRECRAWL_API_KEY = 'fc-test-key'
process.env.WEB_FETCH_PROVIDER = 'aimlapi'
const { data } = await runWebFetch()
expect(requestedHosts()).toEqual(['tools.aimlapi.com'])
expect(data).toMatchObject({
bytes: Buffer.byteLength('# Scraped page'),
code: 200,
result: 'summary of: scraped page',
url: 'https://example.com/page',
})
})
test('an AI/ML API failure is reported, not retried through another backend', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
process.env.FIRECRAWL_API_KEY = 'fc-test-key'
process.env.WEB_FETCH_PROVIDER = 'aimlapi'
fetchMock.mockImplementation(async () =>
Response.json({
id: 'run-2',
status: 'completed',
is_error: true,
error: { kind: 'provider_error', retryable: false, message: 'The provider answered HTTP 400.' },
}),
)
await expect(runWebFetch()).rejects.toThrow('The provider answered HTTP 400.')
expect(requestedHosts()).toEqual(['tools.aimlapi.com'])
expect(haikuStub).not.toHaveBeenCalled()
})
@@ -0,0 +1,43 @@
import { test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
REAL_PROVIDER_TEST_TIMEOUT_MS,
runTestFileWithRealProviders,
} from '../../test/providerModuleIsolation.js'
for (const inheritedMock of [false, true]) {
test(`backend tests restore queryHaiku (inherited mock: ${inheritedMock})`, async () => {
const root = await mkdtemp(join(tmpdir(), 'webfetch-backend-isolation-'))
const entry = join(root, 'isolation.test.ts')
try {
// Exercise the actual suite's setup, calls and teardown in a disposable
// process so this deliberately inherited module mock cannot leak either.
await writeFile(entry, `
import { afterEach, expect, mock } from 'bun:test'
import * as claudeApi from ${JSON.stringify(join(import.meta.dir, '../../services/api/claude.ts'))}
const inheritedMock = ${inheritedMock}
const original = inheritedMock ? mock(async () => 'prior response') : claudeApi.queryHaiku
if (inheritedMock) {
mock.module(${JSON.stringify(join(import.meta.dir, '../../services/api/claude.ts'))}, () => ({
...claudeApi, queryHaiku: original,
}))
}
await import(${JSON.stringify(join(import.meta.dir, 'WebFetchTool.backend.test.ts'))})
afterEach(async () => {
expect(claudeApi.queryHaiku).toBe(original)
if (inheritedMock) {
expect(await claudeApi.queryHaiku({ userPrompt: 'after backend tests' })).toBe('prior response')
} else {
expect('mock' in claudeApi.queryHaiku).toBe(false)
}
})
`)
await runTestFileWithRealProviders(entry)
} finally {
await rm(root, { recursive: true, force: true })
}
// Allow the child deadline plus time for assertions and fixture cleanup.
}, { timeout: REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000 })
}
+21 -2
View File
@@ -22,6 +22,7 @@ import {
MAX_MARKDOWN_LENGTH,
} from './utils.js'
import { firecrawlScrape } from '../firecrawl/client.js'
import { isAimlapiWebFetchSelected, scrapeWithAimlapi } from './aimlapiScrape.js'
function isFirecrawlEnabled(): boolean {
return Boolean(process.env.FIRECRAWL_API_KEY) || Boolean(process.env.FIRECRAWL_API_URL)
@@ -41,6 +42,23 @@ async function scrapeWithFirecrawl(
return { markdown, bytes: Buffer.byteLength(markdown) }
}
type PageScraper = (
url: string,
signal?: AbortSignal,
) => Promise<{ markdown: string; bytes: number }>
/**
* The scraping backend that replaces raw HTTP, if any. AI/ML API runs are
* billed per page, so that backend needs the explicit WEB_FETCH_PROVIDER=aimlapi
* choice and then wins over a configured Firecrawl key.
*/
function getPageScraper(): PageScraper | null {
if (isAimlapiWebFetchSelected()) {
return (url, signal) => scrapeWithAimlapi(url, { signal })
}
return isFirecrawlEnabled() ? scrapeWithFirecrawl : null
}
const inputSchema = lazySchema(() =>
z.strictObject({
url: z.url().describe('The URL to fetch content from'),
@@ -231,8 +249,9 @@ ${DESCRIPTION}`
) {
const start = Date.now()
if (isFirecrawlEnabled()) {
const { markdown, bytes } = await scrapeWithFirecrawl(url, abortController.signal)
const scrapePage = getPageScraper()
if (scrapePage) {
const { markdown, bytes } = await scrapePage(url, abortController.signal)
const result = await applyPromptToMarkdown(
prompt,
markdown,
@@ -0,0 +1,325 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { asMockFetch, callArgs } from '../../test/typedMocks.js'
import {
AIMLAPI_WEB_FETCH_MAX_COST_USD,
AIMLAPI_WEB_FETCH_TOOL_ID,
isAimlapiWebFetchSelected,
scrapeWithAimlapi,
} from './aimlapiScrape.js'
const originalFetch = globalThis.fetch
const originalEnv = {
AIMLAPI_API_KEY: process.env.AIMLAPI_API_KEY,
WEB_FETCH_PROVIDER: process.env.WEB_FETCH_PROVIDER,
CLAUDE_CODE_PROVIDER_ROUTE_ID: process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
beforeEach(() => {
// An ambient aimlapi proxy profile would withhold the key from every test.
delete process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
})
afterEach(() => {
globalThis.fetch = originalFetch
for (const [key, value] of Object.entries(originalEnv)) restoreEnv(key, value)
})
function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
})
}
function completedRun(content: string): Record<string, unknown> {
return {
id: 'run-1',
tool: AIMLAPI_WEB_FETCH_TOOL_ID,
status: 'completed',
is_error: false,
untrusted: true,
output: {
crawlParameters: { url: 'https://example.com' },
results: { title: 'Example', link: 'https://example.com', content },
},
cost: { quoted_usd: 0.0013, actual_usd: 0.0013, credits: 2600, over_max: false },
}
}
function requestBody(fetchMock: Parameters<typeof asMockFetch>[0], index: number): Record<string, unknown> {
const init = callArgs(fetchMock, index)[1] as RequestInit
return JSON.parse(String(init.body)) as Record<string, unknown>
}
describe('AI/ML API WebFetch backend selection', () => {
test('an AI/ML API key alone does not select the paid backend', () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
delete process.env.WEB_FETCH_PROVIDER
expect(isAimlapiWebFetchSelected()).toBe(false)
})
test('WEB_FETCH_PROVIDER=aimlapi selects it, ignoring case and spaces', () => {
process.env.WEB_FETCH_PROVIDER = ' AIMLAPI '
expect(isAimlapiWebFetchSelected()).toBe(true)
process.env.WEB_FETCH_PROVIDER = 'firecrawl'
expect(isAimlapiWebFetchSelected()).toBe(false)
})
})
describe('scrapeWithAimlapi', () => {
test('runs the pinned tool with a cost cap, bearer auth and attribution headers', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const fetchMock = mock(async (input: unknown, init?: RequestInit) => {
expect(String(input)).toBe('https://tools.aimlapi.com/v1/tools/run')
expect(init?.method).toBe('POST')
expect(init?.redirect).toBe('error')
const headers = init?.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer aiml-test-key')
expect(headers['X-AIMLAPI-Source']).toBe('agent/openclaude')
expect(headers['X-AIMLAPI-Partner-ID']).toBe('part_62yQoGYDq4Yqnrj2R1iGrDNJ')
return json(completedRun('# Héllo'))
})
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).resolves.toEqual({
markdown: '# Héllo',
bytes: Buffer.byteLength('# Héllo'),
})
const body = requestBody(fetchMock, 0)
expect(body).toMatchObject({
tool: 'search1api/crawl',
input: { url: 'https://example.com' },
max_cost_usd: AIMLAPI_WEB_FETCH_MAX_COST_USD,
})
expect(typeof body.idempotency_key).toBe('string')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('requires an AI/ML API key and sends nothing without one', async () => {
delete process.env.AIMLAPI_API_KEY
const fetchMock = mock(async () => json(completedRun('unused')))
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'WEB_FETCH_PROVIDER=aimlapi needs an AI/ML API key',
)
expect(fetchMock).not.toHaveBeenCalled()
})
test('does not send a key exported by an aimlapi profile that points at a proxy', async () => {
process.env.AIMLAPI_API_KEY = 'proxy-credential'
process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
process.env.OPENAI_BASE_URL = 'https://my-proxy.example.com/v1'
const fetchMock = mock(async () => json(completedRun('unused')))
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'WEB_FETCH_PROVIDER=aimlapi needs an AI/ML API key',
)
expect(fetchMock).not.toHaveBeenCalled()
})
test('uses the key of an aimlapi profile on the canonical endpoint', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-profile-key'
process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
process.env.OPENAI_BASE_URL = 'https://api.aimlapi.com/v1'
const fetchMock = mock(async (_input: unknown, init?: RequestInit) => {
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer aiml-profile-key')
return json(completedRun('# Canonical'))
})
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).resolves.toMatchObject({
markdown: '# Canonical',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('does not send a proxy profile key when only the OPENAI_API_BASE alias is set', async () => {
process.env.AIMLAPI_API_KEY = 'proxy-credential'
process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
process.env.OPENAI_API_BASE = 'https://my-proxy.example.com/v1'
const fetchMock = mock(async () => json(completedRun('unused')))
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'WEB_FETCH_PROVIDER=aimlapi needs an AI/ML API key',
)
expect(fetchMock).not.toHaveBeenCalled()
})
test('sends only the first key of a pooled AIMLAPI_API_KEY', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-first-key, aiml-second-key'
const fetchMock = mock(async (_input: unknown, init?: RequestInit) => {
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer aiml-first-key')
return json(completedRun('# Pooled'))
})
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).resolves.toMatchObject({
markdown: '# Pooled',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('refuses a redirect, so the key and attribution never leave the Toolbox host', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const fetchMock = mock(async (_input: unknown, init?: RequestInit) => {
// What fetch does with a 3xx response when redirect is 'error'.
if (init?.redirect === 'error') throw new TypeError('unexpected redirect')
return new Response(null, { status: 302, headers: { Location: 'https://elsewhere.example/' } })
})
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow('unexpected redirect')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('redacts the key when the Toolbox echoes it in an error', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-echoed-key'
globalThis.fetch = asMockFetch(mock(async () =>
json({ error: { code: 'invalid_api_key', message: 'Key aiml-echoed-key is not valid.' } }, 401),
))
const error = String(await scrapeWithAimlapi('https://example.com').catch(e => e))
expect(error).toContain('AI/ML API Toolbox run error 401 (invalid_api_key): Key [redacted] is not valid.')
expect(error).not.toContain('aiml-echoed-key')
})
test('polls a queued (202) run until it completes', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const responses = [
json({ id: 'run 7', status: 'queued', next_poll_hint_ms: 1 }, 202),
json({ id: 'run 7', status: 'running', next_poll_hint_ms: 1 }),
json({ ...completedRun('# Done'), id: 'run 7' }),
]
const fetchMock = mock(async () => responses.shift()!)
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).resolves.toMatchObject({
markdown: '# Done',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
const [pollUrl, pollInit] = callArgs(fetchMock, 1) as [string, RequestInit]
expect(pollUrl).toBe('https://tools.aimlapi.com/v1/tools/runs/run%207')
expect(pollInit.method).toBe('GET')
expect(pollInit.redirect).toBe('error')
expect((pollInit.headers as Record<string, string>)['X-AIMLAPI-Partner-ID']).toBe(
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
)
})
test('reports an is_error run with its message instead of returning content', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
globalThis.fetch = asMockFetch(mock(async () =>
json({
id: 'run-2',
status: 'completed',
is_error: true,
error: {
kind: 'no_results',
retryable: false,
message: 'The provider found no data for this input (HTTP 404).',
},
cost: { quoted_usd: 0.0013, actual_usd: 0, credits: 0, over_max: false },
}),
))
await expect(scrapeWithAimlapi('https://example.com/missing')).rejects.toThrow(
'search1api/crawl failed for https://example.com/missing: The provider found no data for this input (HTTP 404).',
)
})
test('reports a run that ended in a failed status', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
globalThis.fetch = asMockFetch(mock(async () =>
json({ id: 'run-3', status: 'timed_out', cost: { over_max: true } }),
))
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'run ended with status timed_out. The run hit the $0.005 max_cost_usd cap.',
)
})
test('surfaces an over-cost refusal without retrying it', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const fetchMock = mock(async () =>
json(
{
error: {
message: 'The cheapest source costs 0.0013 USD, above the limit of 0.001 USD.',
type: 'invalid_request_error',
code: 'max_cost_exceeded',
},
},
400,
),
)
globalThis.fetch = asMockFetch(fetchMock)
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'AI/ML API Toolbox run error 400 (max_cost_exceeded): The cheapest source costs 0.0013 USD, above the limit of 0.001 USD.',
)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
test('retries a rate-limited run with the same idempotency key', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const responses = [
json({ error: { code: 'rate_limited', message: 'Too many requests' } }, 429),
json(completedRun('# After retry')),
]
const fetchMock = mock(async () => responses.shift()!)
globalThis.fetch = asMockFetch(fetchMock)
await expect(
scrapeWithAimlapi('https://example.com', { backoffMs: 1 }),
).resolves.toMatchObject({ markdown: '# After retry' })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(requestBody(fetchMock, 1).idempotency_key).toBe(
requestBody(fetchMock, 0).idempotency_key,
)
})
test('gives up on rate limiting after the last attempt', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
const fetchMock = mock(async () =>
json({ error: { code: 'rate_limited', message: 'Too many requests' } }, 429),
)
globalThis.fetch = asMockFetch(fetchMock)
await expect(
scrapeWithAimlapi('https://example.com', { backoffMs: 1, maxAttempts: 2 }),
).rejects.toThrow('AI/ML API Toolbox run error 429 (rate_limited): Too many requests')
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('rejects a completed run without page content', async () => {
process.env.AIMLAPI_API_KEY = 'aiml-test-key'
globalThis.fetch = asMockFetch(mock(async () =>
json({ ...completedRun(''), output: { results: {} } }),
))
await expect(scrapeWithAimlapi('https://example.com')).rejects.toThrow(
'search1api/crawl returned no page content for https://example.com.',
)
})
})
+302
View File
@@ -0,0 +1,302 @@
/**
* AI/ML API Toolbox backend for WebFetch: fetches a page as Markdown through a
* Toolbox tool run instead of raw HTTP, the same role Firecrawl's scrape
* endpoint plays.
*
* Every run is billed to the user's AI/ML API balance, so this backend is
* opt-in only: it runs when WEB_FETCH_PROVIDER=aimlapi is set, never just
* because an AI/ML API key is present.
*/
import { randomUUID } from 'node:crypto'
import {
AIMLAPI_SOURCE,
isCanonicalAimlapiInferenceBaseUrl,
PARTNER_HEADER_NAME,
resolvePartnerId,
SOURCE_HEADER_NAME,
} from '../../integrations/aimlapi/config.js'
import { sleep } from '../../integrations/aimlapi/transport.js'
import { firstUsableCredential } from '../../services/api/credentialPool.js'
import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js'
/**
* Fixed and not user-overridable, so the AI/ML API key and the attribution
* headers can only ever be sent to tools.aimlapi.com.
*/
export const AIMLAPI_TOOLBOX_BASE_URL = 'https://tools.aimlapi.com/v1/tools'
/**
* The Toolbox tool WebFetch runs. Picked by measurement (2026-10-19) against
* context-dev/web-scrape-markdown, octen/extract, mrscraper/scrape-markdown,
* exa/contents, keenable/v1-fetch, surf/web-fetch and
* dataforseo/onpage-content-parsing on a plain article, a JavaScript-rendered
* page and a large Wikipedia article:
* - the lowest fixed price, $0.0013 per run (context-dev $0.003293, keenable
* $0.0052); octen and mrscraper are priced only after the run;
* - main-content Markdown that keeps links, like Firecrawl's scrape. It
* rendered the JavaScript-only page, and on the large page it fit more of the
* article into WebFetch's 100k-character window than the other Markdown
* tools did;
* - a 404 or an unresolvable host comes back as an unbilled `is_error` run,
* where exa and octen report success with an empty result.
* It was slower than context-dev (3.9-10.8 s against 2.4-10.2 s).
*/
export const AIMLAPI_WEB_FETCH_TOOL_ID = 'search1api/crawl'
/**
* Spending cap sent with every run. The tool costs $0.0013 per run; the cap
* leaves room for a small price change, and a run priced above it is refused
* before it starts (400 max_cost_exceeded) instead of being charged.
*/
export const AIMLAPI_WEB_FETCH_MAX_COST_USD = 0.005
// How long POST /run may hold the request open before answering 202.
const RUN_WAIT_MS = 30_000
// Upper bound for the whole scrape, polling included.
const DEFAULT_TIMEOUT_MS = 120_000
const DEFAULT_MAX_ATTEMPTS = 3
const DEFAULT_BACKOFF_MS = 500
const MAX_RETRY_DELAY_MS = 10_000
const DEFAULT_POLL_INTERVAL_MS = 1_000
const MIN_POLL_INTERVAL_MS = 250
const MAX_POLL_INTERVAL_MS = 5_000
const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'stopped', 'timed_out'])
// Rate limits and gateway errors. Retrying them reuses the idempotency key, so
// a run that did start is returned again instead of being charged twice.
const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504])
interface ToolboxRun {
id?: string
status?: string
is_error?: boolean
error?: { message?: string; kind?: string; retryable?: boolean }
output?: unknown
cost?: { quoted_usd?: number; actual_usd?: number; over_max?: boolean }
next_poll_hint_ms?: number
}
interface ToolboxErrorBody {
error?: { message?: string; code?: string }
}
interface AimlapiScrapeOptions {
apiKey?: string | null
signal?: AbortSignal
timeoutMs?: number
maxAttempts?: number
backoffMs?: number
}
/** True when the user explicitly chose the AI/ML API backend for WebFetch. */
export function isAimlapiWebFetchSelected(): boolean {
return process.env.WEB_FETCH_PROVIDER?.trim().toLowerCase() === 'aimlapi'
}
/**
* An aimlapi provider profile pointed at the user's own proxy exports the
* proxy's credential as AIMLAPI_API_KEY. That key belongs to the proxy, so it
* must never be sent to tools.aimlapi.com: treat it as no key at all.
*/
function isProxiedAimlapiProfileKey(): boolean {
const baseUrl = process.env.OPENAI_BASE_URL?.trim() || process.env.OPENAI_API_BASE?.trim()
return (
process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'aimlapi' &&
Boolean(baseUrl) &&
!isCanonicalAimlapiInferenceBaseUrl(baseUrl!)
)
}
/**
* A provider profile can export a comma-separated credential pool as
* AIMLAPI_API_KEY. Use its first usable key, as the web search backend does;
* there is no rotation or retry across keys for billed runs.
*/
function resolveApiKey(options: AimlapiScrapeOptions): string {
const ambientKey = isProxiedAimlapiProfileKey()
? undefined
: firstUsableCredential(process.env.AIMLAPI_API_KEY)
const apiKey = (options.apiKey ?? ambientKey ?? '').trim()
if (!apiKey) {
throw new Error(
'WEB_FETCH_PROVIDER=aimlapi needs an AI/ML API key. Set AIMLAPI_API_KEY, or unset WEB_FETCH_PROVIDER to fetch pages directly.',
)
}
return apiKey
}
function buildHeaders(apiKey: string): Record<string, string> {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
[SOURCE_HEADER_NAME]: AIMLAPI_SOURCE,
[PARTNER_HEADER_NAME]: resolvePartnerId(),
}
}
async function readJson<T>(response: Response): Promise<T | undefined> {
const text = await response.text()
if (!text) return undefined
try {
return JSON.parse(text) as T
} catch {
return undefined
}
}
function retryDelayMs(response: Response, attempt: number, backoffMs: number): number {
const retryAfterSeconds = Number(response.headers.get('retry-after'))
const delay =
Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? retryAfterSeconds * 1000
: backoffMs * Math.pow(2, attempt)
return Math.min(delay, MAX_RETRY_DELAY_MS)
}
function pollDelayMs(run: ToolboxRun): number {
const hint = run.next_poll_hint_ms
if (typeof hint !== 'number' || !Number.isFinite(hint)) return DEFAULT_POLL_INTERVAL_MS
return Math.min(Math.max(hint, MIN_POLL_INTERVAL_MS), MAX_POLL_INTERVAL_MS)
}
/** Remove an echoed credential before error text is shown. */
function redact(value: string | undefined, apiKey: string): string | undefined {
return value?.split(apiKey).join('[redacted]')
}
async function requestError(response: Response, action: string, apiKey: string): Promise<Error> {
const body = await readJson<ToolboxErrorBody>(response)
const code = body?.error?.code ? ` (${redact(body.error.code, apiKey)})` : ''
const message = body?.error?.message ? `: ${redact(body.error.message, apiKey)}` : ''
return new Error(`AI/ML API Toolbox ${action} error ${response.status}${code}${message}`)
}
async function startRun(
url: string,
apiKey: string,
headers: Record<string, string>,
signal: AbortSignal,
maxAttempts: number,
backoffMs: number,
): Promise<ToolboxRun> {
const body = JSON.stringify({
tool: AIMLAPI_WEB_FETCH_TOOL_ID,
input: { url },
max_cost_usd: AIMLAPI_WEB_FETCH_MAX_COST_USD,
wait_ms: RUN_WAIT_MS,
idempotency_key: randomUUID(),
})
for (let attempt = 0; ; attempt++) {
const response = await fetch(`${AIMLAPI_TOOLBOX_BASE_URL}/run`, {
method: 'POST',
headers,
body,
// Never follow a redirect: the key and attribution headers must not
// reach any host but the Toolbox.
redirect: 'error',
signal,
})
if (response.ok) {
const run = await readJson<ToolboxRun>(response)
if (!run) throw new Error('AI/ML API Toolbox run returned an invalid response')
return run
}
if (!RETRYABLE_HTTP_STATUSES.has(response.status) || attempt >= maxAttempts - 1) {
throw await requestError(response, 'run', apiKey)
}
await response.body?.cancel()
await sleep(retryDelayMs(response, attempt, backoffMs), signal)
}
}
async function waitForRun(
run: ToolboxRun,
apiKey: string,
headers: Record<string, string>,
signal: AbortSignal,
): Promise<ToolboxRun> {
let current = run
while (!TERMINAL_RUN_STATUSES.has(current.status ?? '')) {
if (!current.id) throw new Error('AI/ML API Toolbox run is still pending but has no run id')
await sleep(pollDelayMs(current), signal)
const response = await fetch(
`${AIMLAPI_TOOLBOX_BASE_URL}/runs/${encodeURIComponent(current.id)}`,
{ method: 'GET', headers, redirect: 'error', signal },
)
if (RETRYABLE_HTTP_STATUSES.has(response.status)) {
await response.body?.cancel()
continue
}
if (!response.ok) throw await requestError(response, 'run status', apiKey)
const next = await readJson<ToolboxRun>(response)
if (!next) throw new Error('AI/ML API Toolbox run status returned an invalid response')
current = { ...next, id: next.id ?? current.id }
}
return current
}
function extractMarkdown(output: unknown): string | undefined {
if (typeof output !== 'object' || output === null) return undefined
const results = (output as { results?: unknown }).results
if (typeof results !== 'object' || results === null) return undefined
const content = (results as { content?: unknown }).content
return typeof content === 'string' ? content : undefined
}
function capNote(run: ToolboxRun): string {
return run.cost?.over_max
? ` The run hit the $${AIMLAPI_WEB_FETCH_MAX_COST_USD} max_cost_usd cap.`
: ''
}
/**
* Scrape a page to Markdown with the AI/ML API Toolbox. Throws on any failure,
* like the Firecrawl scrape: WebFetch reports the error rather than silently
* fetching the page another way.
*/
export async function scrapeWithAimlapi(
url: string,
options: AimlapiScrapeOptions = {},
): Promise<{ markdown: string; bytes: number }> {
if (!url.trim()) {
throw new Error('AI/ML API Toolbox URL cannot be empty')
}
const apiKey = resolveApiKey(options)
const headers = buildHeaders(apiKey)
const { signal, cleanup } = createCombinedAbortSignal(options.signal, {
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
})
try {
const started = await startRun(
url,
apiKey,
headers,
signal,
options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
options.backoffMs ?? DEFAULT_BACKOFF_MS,
)
const run = await waitForRun(started, apiKey, headers, signal)
if (run.is_error || run.status !== 'completed') {
const reason = redact(run.error?.message, apiKey) ?? `run ended with status ${redact(run.status, apiKey)}`
const retry = run.error?.retryable ? ' (retryable)' : ''
throw new Error(
`AI/ML API Toolbox ${AIMLAPI_WEB_FETCH_TOOL_ID} failed for ${url}: ${reason}${retry}.${capNote(run)}`,
)
}
const markdown = extractMarkdown(run.output)
if (!markdown) {
throw new Error(
`AI/ML API Toolbox ${AIMLAPI_WEB_FETCH_TOOL_ID} returned no page content for ${url}.${capNote(run)}`,
)
}
return { markdown, bytes: Buffer.byteLength(markdown) }
} finally {
cleanup()
}
}
+17
View File
@@ -15,6 +15,7 @@ import { PROVIDER_SELECTION_FLAGS } from './providerSelectionFlags.js'
const TEST_ENV_KEYS = [
'NODE_OPTIONS',
'AIMLAPI_API_KEY',
'AZURE_OPENAI_API_VERSION',
'CLAUDE_CODE_USE_OPENAI',
'CODEX_AUTH_JSON_PATH',
@@ -57,6 +58,7 @@ const TEST_ENV_KEYS = [
'WEB_CUSTOM_ALLOW_PRIVATE',
'WEB_CUSTOM_MAX_BODY_KB',
'WEB_CUSTOM_TIMEOUT_SEC',
'WEB_FETCH_PROVIDER',
'WEB_HEADERS',
'WEB_JSON_PATH',
'WEB_KEY',
@@ -383,6 +385,21 @@ describe('loadEnvFile', () => {
expect(process.env.MERGE_GATEWAY_API_KEY).toBe('shell-merge-key')
})
it('loads the documented AI/ML API key and WebFetch provider values', () => {
const filePath = writeTempEnvFile(
['AIMLAPI_API_KEY=aimlapi-key', 'WEB_FETCH_PROVIDER=aimlapi'].join('\n'),
)
const loaded = loadEnvFile(filePath)
expect(process.env.AIMLAPI_API_KEY).toBe('aimlapi-key')
expect(process.env.WEB_FETCH_PROVIDER).toBe('aimlapi')
expect(loaded).toEqual({
AIMLAPI_API_KEY: 'aimlapi-key',
WEB_FETCH_PROVIDER: 'aimlapi',
})
})
it('loads the dedicated LLMTR credential without selecting a route', () => {
const filePath = writeTempEnvFile('LLMTR_API_KEY=llmtr-key')
+2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
const ALLOWED_ENV_FILE_KEYS = new Set([
'AIMLAPI_API_KEY',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
@@ -140,6 +141,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([
'WEB_CUSTOM_ALLOW_PRIVATE',
'WEB_CUSTOM_MAX_BODY_KB',
'WEB_CUSTOM_TIMEOUT_SEC',
'WEB_FETCH_PROVIDER',
'WEB_HEADERS',
'WEB_JSON_PATH',
'WEB_KEY',