Skip to content
20 changes: 20 additions & 0 deletions docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ export default agent({
});
```

## Enable OpenAI-hosted web search

Declare `web_search` in the agent's provider tools. Veryfront routes the call
through OpenAI's Responses API, including for models that otherwise use Chat
Completions:

```ts
import { agent } from "veryfront/agent";

export default agent({
model: "openai/gpt-4.1",
providerTools: ["web_search"],
Comment thread
kojiwakayama marked this conversation as resolved.
});
```

This tool requires an OpenAI endpoint that implements the Responses and hosted
web-search contracts. See the
[`ext-llm-openai` reference](../../extensions/ext-llm-openai/README.md#hosted-web-search)
for supported identifiers, arguments, and replay limits.

## Explicit local AI

Local inference is explicit. Use a `local/*` model when you want the server to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ describe("anthropic-provider", () => {
type: "finish",
finishReason: { unified: "tool-calls", raw: "tool_use" },
usage: {
inputTokens: undefined,
outputTokens: 4,
totalTokens: 4,
},
Expand Down
42 changes: 37 additions & 5 deletions extensions/ext-llm-anthropic/src/anthropic-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assertEquals } from "#veryfront/testing/assert.ts";
import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { ProviderRequestError } from "veryfront/provider/shared";
import { streamAnthropicCompatibleParts } from "./anthropic-stream.ts";

type AnthropicStreamOptions = Parameters<typeof streamAnthropicCompatibleParts>[1];
Expand Down Expand Up @@ -145,7 +146,6 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
delta: { type: "thinking_delta", thinking: " more" },
}),
data({ type: "content_block_stop", index: 0 }),
"data: {malformed\r\n\r\n",
data({
type: "content_block_start",
index: 1,
Expand Down Expand Up @@ -194,6 +194,7 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
totalTokens: 13,
cacheCreationInputTokens: 2,
cacheReadInputTokens: 3,
cachedInputTokens: 3,
},
},
]);
Expand Down Expand Up @@ -247,6 +248,7 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
outputTokens: 5,
totalTokens: 13,
cacheReadInputTokens: 3,
cachedInputTokens: 3,
billableInputTokens: 8,
billableOutputTokens: 5,
providerInputCostUsd: 0.0004,
Expand Down Expand Up @@ -449,7 +451,6 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
type: "finish",
finishReason: { unified: "tool-calls", raw: "tool_use" },
usage: {
inputTokens: undefined,
outputTokens: 4,
totalTokens: 4,
},
Expand Down Expand Up @@ -497,7 +498,6 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
type: "finish",
finishReason: { unified: "tool-calls", raw: "tool_use" },
usage: {
inputTokens: undefined,
outputTokens: 4,
totalTokens: 4,
},
Expand Down Expand Up @@ -544,11 +544,43 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
type: "finish",
finishReason: { unified: "tool-calls", raw: "tool_use" },
usage: {
inputTokens: undefined,
outputTokens: 4,
totalTokens: 4,
},
},
]);
});

it("reports malformed SSE JSON as a typed provider error", async () => {
await assertRejects(
() => collectParts(streamFromText('data: {"type":\n\n')),
ProviderRequestError,
"SSE event framing was malformed",
);
});

it("reports a malformed trailing SSE buffer as a typed provider error", async () => {
await assertRejects(
() => collectParts(streamFromText('data: {"type":')),
ProviderRequestError,
"trailing SSE event was malformed",
);
});

it("aborts the stream when a malformed event follows valid ones", async () => {
await assertRejects(
() =>
collectParts(streamFromText([
data({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "Done." },
}),
"data: {malformed\r\n\r\n",
data({ type: "message_stop" }),
].join(""))),
ProviderRequestError,
"SSE event framing was malformed",
);
});
});
28 changes: 26 additions & 2 deletions extensions/ext-llm-anthropic/src/anthropic-stream.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import {
mergeUsage,
parseFinalSseChunk,
parseSseChunk,
ProviderRequestError,
readGatewayBillingMode,
readRecord,
stringifyJsonValue,
} from "veryfront/provider/shared";
import type { RuntimeUsage } from "veryfront/provider/shared";

function invalidAnthropicStream(issue: string): ProviderRequestError {
return new ProviderRequestError({
provider: "anthropic",
status: 200,
message: `anthropic request failed: invalid successful stream (${issue})`,
retryable: false,
});
}

function parseAnthropicSseBuffer(
buffer: string,
trailing = false,
): ReturnType<typeof parseSseChunk> {
try {
return trailing ? parseFinalSseChunk(buffer) : parseSseChunk(buffer);
} catch {
throw invalidAnthropicStream(
trailing ? "trailing SSE event was malformed" : "SSE event framing was malformed",
);
}
}

type AnthropicStreamToolCallState = {
id: string;
name: string;
Expand Down Expand Up @@ -249,7 +273,7 @@ export async function* streamAnthropicCompatibleParts(
return;
}

const parsed = parseSseChunk(`${buffer}\n\n`);
const parsed = parseAnthropicSseBuffer(buffer, true);
buffer = parsed.remainder;
for (const event of parsed.events) {
if (event === "[DONE]") {
Expand Down Expand Up @@ -299,7 +323,7 @@ export async function* streamAnthropicCompatibleParts(
}

buffer += decoder.decode(read.chunk, { stream: true });
const parsed = parseSseChunk(buffer);
const parsed = parseAnthropicSseBuffer(buffer);
buffer = parsed.remainder;

for (const event of parsed.events) {
Expand Down
8 changes: 4 additions & 4 deletions extensions/ext-llm-google/src/google-provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it } from "@std/testing/bdd";
import { assertEquals } from "@std/assert";

import { ProviderOverloadedError, ProviderQuotaError } from "veryfront/provider/shared";
import { ProviderOverloadedError, ProviderRateLimitError } from "veryfront/provider/shared";

import { createGoogleEmbeddingRuntime, createGoogleModelRuntime } from "./google-provider.ts";

Expand Down Expand Up @@ -631,7 +631,7 @@ describe("ext-llm-google/google-provider", () => {
assertEquals(err.retryable, true);
});

it("classifies Google 429 RESOURCE_EXHAUSTED as ProviderQuotaError (non-retryable)", async () => {
it("classifies Google 429 RESOURCE_EXHAUSTED as ProviderRateLimitError (retryable)", async () => {
const runtime = createGoogleModelRuntime({
apiKey: "k",
baseURL: "https://example.google.test/v1beta",
Expand All @@ -644,9 +644,9 @@ describe("ext-llm-google/google-provider", () => {
}, "gemini-1.5-pro");
const err = await expectError(
runtime.doGenerate({ prompt: [userPrompt] }),
ProviderQuotaError,
ProviderRateLimitError,
);
assertEquals(err.retryable, false);
assertEquals(err.retryable, true);
});
});

Expand Down
35 changes: 33 additions & 2 deletions extensions/ext-llm-google/src/google-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assertEquals } from "#veryfront/testing/assert.ts";
import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { ProviderRequestError } from "veryfront/provider/shared";
import { streamGoogleCompatibleParts } from "./google-stream.ts";

function streamFromText(text: string): ReadableStream<Uint8Array> {
Expand Down Expand Up @@ -43,7 +44,6 @@ describe("ext-llm-google/google-stream", () => {
},
}],
}),
"data: {malformed\r\n\r\n",
data({
candidates: [{
content: {
Expand Down Expand Up @@ -123,4 +123,35 @@ describe("ext-llm-google/google-stream", () => {
},
}]);
});

it("reports malformed SSE JSON as a typed provider error", async () => {
await assertRejects(
() => collectParts(streamFromText('data: {"candidates":\r\n\r\n')),
ProviderRequestError,
"SSE event framing was malformed",
);
});

it("reports a malformed trailing SSE buffer as a typed provider error", async () => {
await assertRejects(
() => collectParts(streamFromText('data: {"candidates":')),
ProviderRequestError,
"trailing SSE event was malformed",
);
});

it("aborts the stream when a malformed event follows valid ones", async () => {
await assertRejects(
() =>
collectParts(streamFromText([
data({
candidates: [{ content: { role: "model", parts: [{ text: "Done." }] } }],
}),
"data: {malformed\r\n\r\n",
data({ candidates: [{ finishReason: "STOP" }] }),
].join(""))),
ProviderRequestError,
"SSE event framing was malformed",
);
});
});
28 changes: 26 additions & 2 deletions extensions/ext-llm-google/src/google-stream.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import {
parseFinalSseChunk,
parseSseChunk,
ProviderRequestError,
readRecord,
type RuntimeUsage,
stringifyJsonValue,
} from "veryfront/provider/shared";

function invalidGoogleStream(issue: string): ProviderRequestError {
return new ProviderRequestError({
provider: "google",
status: 200,
message: `google request failed: invalid successful stream (${issue})`,
retryable: false,
});
}

function parseGoogleSseBuffer(
buffer: string,
trailing = false,
): ReturnType<typeof parseSseChunk> {
try {
return trailing ? parseFinalSseChunk(buffer) : parseSseChunk(buffer);
} catch {
throw invalidGoogleStream(
trailing ? "trailing SSE event was malformed" : "SSE event framing was malformed",
);
}
}

export function normalizeGoogleFinishReason(
raw: unknown,
): string | { unified: string; raw: string } | null {
Expand Down Expand Up @@ -86,7 +110,7 @@ export async function* streamGoogleCompatibleParts(

for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true });
const parsed = parseSseChunk(buffer);
const parsed = parseGoogleSseBuffer(buffer);
buffer = parsed.remainder;

for (const event of parsed.events) {
Expand Down Expand Up @@ -166,7 +190,7 @@ export async function* streamGoogleCompatibleParts(
}

if (buffer.trim().length > 0) {
const parsed = parseSseChunk(`${buffer}\n\n`);
const parsed = parseGoogleSseBuffer(buffer, true);
for (const event of parsed.events) {
if (event === "[DONE]") {
continue;
Expand Down
51 changes: 49 additions & 2 deletions extensions/ext-llm-openai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Category:** LLM | **Contract:** `LLMProvider` | **Built-in**

Provides OpenAI models for Veryfront agents and chat, enabling `openai/*` models for chat, embeddings, and the Responses API via the `LLMProviderRegistry`.
Provides OpenAI models for Veryfront agents and chat, enabling `openai/*` models for chat, embeddings, the Responses API, and OpenAI-hosted web search via the `LLMProviderRegistry`.

## Registration

Expand Down Expand Up @@ -43,14 +43,61 @@ const result = await ai.embed("openai/text-embedding-3-small", {

### Responses API

For models that support OpenAI's Responses API (structured output, native tools):
For models that support OpenAI's Responses API (structured output, function
tools, and supported hosted tools):

```ts
const response = await ai.responses("openai/gpt-4.1", {
prompt: [{ role: "user", content: "What is 2+2?" }],
});
```

### Hosted Web Search

The agent-facing tool name is `web_search`. It resolves to the current
`openai.web_search` provider tool and routes that request through the Responses
API, including for models that otherwise use Chat Completions:

```ts
import { agent } from "veryfront/agent";

export default agent({
model: "openai/gpt-4.1",
providerTools: ["web_search"],
});
```

| Contract | Supported value |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| Agent tool name | `web_search` |
| Current provider id | `openai.web_search` |
| Low-level compatibility ids | `openai.web_search_2025_08_26`, `openai.web_search_preview`, `openai.web_search_preview_2025_03_11` |
| Optional argument | `searchContextSize`: `"low"` \| `"medium"` \| `"high"` |
| Per-request limit | One OpenAI web-search provider tool |

Other OpenAI-hosted tool types are not normalized by this extension and fail
before the provider request. OpenAI-compatible base URLs can use this path only
when the endpoint implements OpenAI's Responses and hosted web-search
contracts.

Responses requests are stateless (`store: false`). The runtime retains the
complete ordered response output in provider metadata and replays it on the
next turn. Reasoning requests explicitly include encrypted reasoning content,
and web-search requests include source URLs, so manual callers must preserve
assistant-message provider metadata between turns. Raw replay is limited to
4,096 output items and 8 MiB. Whenever canonical calls or results survive,
their IDs, names, semantic values, multiplicity, and order must match the raw
output before transport.

Provider-executed Responses calls and results cannot be converted into Chat
Completions history. Switching a conversation containing those parts to a
Chat Completions-only route fails as a configuration error before transport;
keep that conversation on a Responses-capable route or begin a new compacted
history that no longer contains the provider-executed parts.

See [Providers: Enable OpenAI-hosted web search](../../docs/guides/providers.md#enable-openai-hosted-web-search)
for the agent setup.

## Supported Models

Any model accessible through the OpenAI Chat Completions, Responses, or Embeddings API:
Expand Down
Loading