diff --git a/dato-agent/README.md b/dato-agent/README.md
index 954a4f11..5df55a91 100644
--- a/dato-agent/README.md
+++ b/dato-agent/README.md
@@ -12,12 +12,14 @@ For now, use it only on a sandbox or test project, never on production or on a p
## Setup
-1. A project administrator picks OpenAI or Anthropic in the plugin settings, adds the provider API key and chooses a model.
+1. A project administrator picks OpenAI, Anthropic, or Vercel AI Gateway in the plugin settings, adds the provider API key and chooses a model.
2. Each user connects their own DatoCMS account. When asked which projects to authorize, select only the project where the plugin is installed.
3. Open **Agent (Beta)** or the record sidebar and describe what you need.
The provider API key is shared by the whole project. Each user's DatoCMS connection and recent chats are stored in their browser.
+Vercel AI Gateway uses an API key from **Vercel → AI Gateway → API keys**. Requests are billed to that team's AI Gateway credits, or to bring-your-own provider keys configured on the gateway. The model list starts with a short recommended set (Claude Sonnet 5.5 by default, plus Opus, Haiku, GPT, Gemini, and Grok options). **Show all tool-capable models** reveals the rest of the catalog. Models that are not Claude are translated through the gateway's Anthropic Messages API, so tool calls work the same way, but zero data retention depends on the model: only models marked ZDR on every route keep that guarantee. Fast mode selects the model's `-fast` variant when the catalog publishes one.
+
## Permissions
The agent can never do more than the user's role allows. On top of that, the access level chosen when connecting DatoCMS applies (**Only read content** blocks every write), and the plugin's **Read Only** setting turns off all agent writes for everyone.
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index d739546b..e2b0657e 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -3572,6 +3572,10 @@ export default function AgentFrame(props: AgentFrameProps) {
},
model: activeModel(props.config),
modelMaxOutputTokens: activeModelMaxOutputTokens(props.config),
+ modelReasoningEfforts:
+ props.config.provider === 'vercel'
+ ? props.config.vercelModelReasoningEfforts
+ : undefined,
reasoningEffort: activeReasoningEffort(props.config),
fastMode: activeFastMode(props.config),
readOnly: props.config.readOnly,
@@ -4178,7 +4182,7 @@ export default function AgentFrame(props: AgentFrameProps) {
);
const injectHostContext = Boolean(
hostContext &&
- (turn.provider === 'anthropic' ||
+ (turn.provider !== 'openai' ||
!previousResponseId ||
conversationRef.current.hostContextFingerprint !==
hostContext.fingerprint),
diff --git a/dato-agent/src/entrypoints/ConfigScreen.test.tsx b/dato-agent/src/entrypoints/ConfigScreen.test.tsx
index c9b3aefd..be245c5f 100644
--- a/dato-agent/src/entrypoints/ConfigScreen.test.tsx
+++ b/dato-agent/src/entrypoints/ConfigScreen.test.tsx
@@ -39,6 +39,18 @@ type Option = {
value: string;
};
+function selectableOptions(
+ options: ReadonlyArray | undefined,
+): Option[] {
+ return (options ?? []).flatMap((option) =>
+ 'options' in option && Array.isArray(option.options)
+ ? [...option.options]
+ : 'value' in option
+ ? [option]
+ : [],
+ );
+}
+
vi.mock('datocms-react-ui', () => ({
Canvas: ({ children }: { children: ReactNode }) => <>{children}>,
FieldGroup: ({ children }: { children: ReactNode }) => {children}
,
@@ -120,13 +132,13 @@ vi.mock('datocms-react-ui', () => ({
isDisabled?: boolean;
isLoading?: boolean;
isMulti?: boolean;
- options?: Option[];
+ options?: Array ;
required?: boolean;
};
value?: Option | readonly Option[] | null;
}) => {
const selectedOptions = Array.isArray(value) ? value : value ? [value] : [];
- const availableOptions = selectInputProps?.options ?? [];
+ const availableOptions = selectableOptions(selectInputProps?.options);
const renderedOptions = [
...availableOptions,
...selectedOptions.filter(
@@ -397,6 +409,55 @@ describe('ConfigScreen', () => {
);
});
+ it('offers recommended gateway models and reveals the rest on request', async () => {
+ modelMocks.listProviderModels.mockResolvedValue([
+ {
+ id: 'anthropic/claude-sonnet-5.5',
+ label: 'Claude Sonnet 5.5 (Anthropic)',
+ reasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
+ recommended: true,
+ supportsFastMode: true,
+ },
+ {
+ id: 'openai/gpt-5-nano',
+ label: 'GPT-5 nano (OpenAI)',
+ reasoningEfforts: ['low', 'high'],
+ recommended: false,
+ supportsFastMode: false,
+ },
+ ]);
+ const { ctx } = createCtx({
+ parameters: {
+ ...PARAMETERS,
+ provider: 'vercel',
+ vercelApiKey: 'vck_project',
+ },
+ });
+ render( );
+ await finishModelDiscovery();
+
+ expect(screen.getByLabelText('AI Gateway API key')).toBeRequired();
+ expect(screen.getByText(/Vercel → AI Gateway → API keys/)).toBeVisible();
+ const model = screen.getByLabelText('Model');
+ expect(model).toHaveValue('anthropic/claude-sonnet-5.5');
+ expect(
+ within(model).getByRole('option', {
+ name: 'Claude Sonnet 5.5 (Anthropic)',
+ }),
+ ).toBeVisible();
+ expect(
+ within(model).queryByRole('option', { name: 'GPT-5 nano (OpenAI)' }),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('Show all tool-capable models'));
+ expect(
+ within(screen.getByLabelText('Model')).getByRole('option', {
+ name: 'GPT-5 nano (OpenAI)',
+ }),
+ ).toBeVisible();
+ expect(screen.getByText(/faster gateway variant/i)).toBeVisible();
+ });
+
it('switches vendor fields without losing inactive OpenAI values', async () => {
const { ctx } = createCtx();
render( );
diff --git a/dato-agent/src/entrypoints/ConfigScreen.tsx b/dato-agent/src/entrypoints/ConfigScreen.tsx
index 1ebea212..ceb1d901 100644
--- a/dato-agent/src/entrypoints/ConfigScreen.tsx
+++ b/dato-agent/src/entrypoints/ConfigScreen.tsx
@@ -64,6 +64,11 @@ type ModelOption = {
value: string;
};
+type ModelOptionGroup = {
+ label?: string;
+ options: ModelOption[];
+};
+
type RoleOption = {
label: string;
value: string;
@@ -95,6 +100,7 @@ const MODEL_DISCOVERY_DELAY_MS = 500;
const PROVIDER_OPTIONS: ProviderOption[] = [
{ label: providerLabel('openai'), value: 'openai' },
{ label: providerLabel('anthropic'), value: 'anthropic' },
+ { label: providerLabel('vercel'), value: 'vercel' },
];
const PROVIDER_DETAILS: Record<
@@ -118,6 +124,12 @@ const PROVIDER_DETAILS: Record<
apiKeyPlaceholder: 'sk-ant-…',
modelLabel: 'Claude model',
},
+ vercel: {
+ name: 'Vercel AI Gateway',
+ apiKeyLabel: 'AI Gateway API key',
+ apiKeyPlaceholder: 'vck_…',
+ modelLabel: 'Model',
+ },
};
const REASONING_OPTIONS: ReasoningOption[] = [
@@ -133,18 +145,62 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}
-function modelOptions(
+function toModelOption(model: ProviderModel): ModelOption {
+ return { label: model.label, value: model.id };
+}
+
+function modelSelectGroups(
+ config: AgentConfig,
models: readonly ProviderModel[],
currentModel: string,
-): ModelOption[] {
- if (models.length === 0) {
- return currentModel ? [{ label: currentModel, value: currentModel }] : [];
+): ModelOptionGroup[] {
+ if (config.provider !== 'vercel') {
+ return [
+ {
+ options:
+ models.length > 0
+ ? models.map(toModelOption)
+ : currentModel
+ ? [{ label: currentModel, value: currentModel }]
+ : [],
+ },
+ ];
+ }
+
+ const recommended = models.filter((model) => model.recommended);
+ const others = models.filter((model) => !model.recommended);
+ const groups: ModelOptionGroup[] = [];
+ if (recommended.length > 0) {
+ groups.push({
+ label: 'Recommended',
+ options: recommended.map(toModelOption),
+ });
+ }
+ if (config.vercelShowAllModels && others.length > 0) {
+ groups.push({
+ label: 'All tool-capable models',
+ options: others.map(toModelOption),
+ });
}
- return models.map((model) => ({
- label: model.label,
- value: model.id,
- }));
+ const visibleIds = new Set(
+ groups.flatMap((group) => group.options.map((option) => option.value)),
+ );
+ if (currentModel && !visibleIds.has(currentModel)) {
+ const current = models.find((model) => model.id === currentModel);
+ groups.push({
+ label: 'Current model',
+ options: [{ label: current?.label ?? currentModel, value: currentModel }],
+ });
+ }
+
+ return groups.length > 0 ? groups : [{ options: [] }];
+}
+
+function flattenModelOptions(
+ groups: readonly ModelOptionGroup[],
+): ModelOption[] {
+ return groups.flatMap((group) => [...group.options]);
}
function configFingerprint(config: AgentConfig): string {
@@ -152,24 +208,40 @@ function configFingerprint(config: AgentConfig): string {
}
function providerConfigurationFingerprint(config: AgentConfig): string {
- return JSON.stringify(
- config.provider === 'openai'
- ? {
- provider: config.provider,
- apiKey: config.openAiApiKey,
- model: config.model,
- reasoningEffort: config.reasoningEffort,
- fastMode: config.openAiFastMode,
- }
- : {
- provider: config.provider,
- apiKey: config.anthropicApiKey,
- model: config.anthropicModel,
- modelMaxOutputTokens: config.anthropicModelMaxOutputTokens,
- reasoningEffort: config.anthropicReasoningEffort,
- fastMode: config.anthropicFastMode,
- },
- );
+ switch (config.provider) {
+ case 'openai':
+ return JSON.stringify({
+ provider: config.provider,
+ apiKey: config.openAiApiKey,
+ model: config.model,
+ reasoningEffort: config.reasoningEffort,
+ fastMode: config.openAiFastMode,
+ });
+ case 'anthropic':
+ return JSON.stringify({
+ provider: config.provider,
+ apiKey: config.anthropicApiKey,
+ model: config.anthropicModel,
+ modelMaxOutputTokens: config.anthropicModelMaxOutputTokens,
+ reasoningEffort: config.anthropicReasoningEffort,
+ fastMode: config.anthropicFastMode,
+ });
+ case 'vercel':
+ return JSON.stringify({
+ provider: config.provider,
+ apiKey: config.vercelApiKey,
+ model: config.vercelModel,
+ modelMaxOutputTokens: config.vercelModelMaxOutputTokens,
+ modelReasoningEfforts: config.vercelModelReasoningEfforts,
+ reasoningEffort: config.vercelReasoningEffort,
+ fastMode: config.vercelFastMode,
+ showAllModels: config.vercelShowAllModels,
+ });
+ default: {
+ const unreachable: never = config.provider;
+ return unreachable;
+ }
+ }
}
function discoveryRequestKey(
@@ -475,11 +547,15 @@ function modelDiscoveryHint(
return 'Waiting to load models…';
}
const count = discovery.models.length;
+ const summary =
+ provider === 'vercel'
+ ? `${discovery.models.filter((model) => model.recommended).length} recommended models, ${count} tool-capable model${count === 1 ? '' : 's'} available.`
+ : count === 0
+ ? 'No compatible agent models are available for this API key.'
+ : `${count} compatible model${count === 1 ? '' : 's'} available.`;
return (
<>
- {count === 0
- ? 'No compatible agent models are available for this API key.'
- : `${count} compatible model${count === 1 ? '' : 's'} available.`}{' '}
+ {summary}{' '}
0 &&
+ !selectedModel.reasoningEfforts.includes(activeReasoningEffort(config))
+ ) {
return `Choose a reasoning effort supported by the selected ${details.name} model.`;
}
@@ -549,23 +628,35 @@ function modelFieldError(
return undefined;
}
+function fallbackReasoningEfforts(config: AgentConfig): ReasoningEffort[] {
+ switch (config.provider) {
+ case 'openai':
+ return REASONING_OPTIONS.map((option) => option.value);
+ case 'anthropic':
+ return REASONING_OPTIONS.flatMap((option) =>
+ option.value === 'none' ? [] : [option.value],
+ );
+ case 'vercel':
+ return config.vercelModelReasoningEfforts;
+ default: {
+ const unreachable: never = config.provider;
+ return unreachable;
+ }
+ }
+}
+
function reasoningOptions(
- provider: AgentProvider,
+ config: AgentConfig,
selectedModel: ProviderModel | undefined,
): ReasoningOption[] {
const recommendedEffort: ReasoningEffort =
- provider === 'openai' ? 'medium' : 'high';
+ config.provider === 'openai' ? 'medium' : 'high';
const supportedEfforts =
- selectedModel?.reasoningEfforts ??
- (provider === 'openai'
- ? REASONING_OPTIONS.map((option) => option.value)
- : REASONING_OPTIONS.flatMap((option) =>
- option.value === 'none' ? [] : [option.value],
- ));
+ selectedModel?.reasoningEfforts ?? fallbackReasoningEfforts(config);
return REASONING_OPTIONS.filter(
(option) =>
- (provider === 'openai' || option.value !== 'none') &&
+ (config.provider !== 'anthropic' || option.value !== 'none') &&
supportedEfforts.includes(option.value),
).map((option) =>
option.value === recommendedEffort
@@ -575,10 +666,17 @@ function reasoningOptions(
}
function preferredReasoningEffort(
+ provider: AgentProvider,
options: readonly ReasoningOption[],
): ReasoningEffort | undefined {
+ const high = options.find((option) => option.value === 'high')?.value;
+ if (high) {
+ return high;
+ }
+ if (provider === 'vercel') {
+ return options.at(-1)?.value;
+ }
return (
- options.find((option) => option.value === 'high')?.value ??
options.find((option) => option.value === 'medium')?.value ??
options[0]?.value
);
@@ -592,14 +690,17 @@ function configWithSelectedModel(
config,
selectedModel.id,
selectedModel.maxOutputTokens,
+ config.provider === 'vercel' ? selectedModel.reasoningEfforts : undefined,
);
if (
activeFastMode(withModel) &&
- !providerModelSupportsFastMode(config.provider, selectedModel.id)
+ !providerModelSupportsFastMode(config.provider, selectedModel.id, [
+ selectedModel,
+ ])
) {
withModel = withActiveFastMode(withModel, false);
}
- const availableOptions = reasoningOptions(config.provider, selectedModel);
+ const availableOptions = reasoningOptions(config, selectedModel);
if (
availableOptions.some(
(option) => option.value === activeReasoningEffort(withModel),
@@ -608,14 +709,14 @@ function configWithSelectedModel(
return withModel;
}
- const fallback = preferredReasoningEffort(availableOptions);
+ const fallback = preferredReasoningEffort(config.provider, availableOptions);
return fallback ? withActiveReasoningEffort(withModel, fallback) : withModel;
}
type ProviderConfigurationFieldsProps = {
apiKey: string;
attemptedSave: boolean;
- availableModelOptions: ModelOption[];
+ availableModelGroups: ModelOptionGroup[];
availableReasoningOptions: ReasoningOption[];
canEditSchema: boolean;
config: AgentConfig;
@@ -630,10 +731,53 @@ type ProviderConfigurationFieldsProps = {
setConfig: Dispatch>;
};
+function reasoningEffortHint(
+ config: AgentConfig,
+ selectedModel: ProviderModel | undefined,
+): string {
+ switch (config.provider) {
+ case 'anthropic':
+ return 'Options supported by the selected Claude model.';
+ case 'vercel':
+ return selectedModel && selectedModel.reasoningEfforts.length === 0
+ ? 'This model does not expose reasoning controls.'
+ : 'Options reported by the Vercel AI Gateway catalog for this model.';
+ case 'openai':
+ return 'Availability depends on the selected model.';
+ default: {
+ const unreachable: never = config.provider;
+ return unreachable;
+ }
+ }
+}
+
+function fastModeHintFor(
+ provider: AgentProvider,
+ fastModeSupported: boolean,
+): string {
+ if (fastModeSupported) {
+ return provider === 'vercel'
+ ? 'Switches this model to its faster gateway variant. That variant costs more per token.'
+ : 'Uses premium processing for every model request, including tool steps, and can significantly increase API costs.';
+ }
+ switch (provider) {
+ case 'anthropic':
+ return 'Not available for this Claude model. Anthropic Fast mode currently requires a supported Opus model and preview access.';
+ case 'vercel':
+ return 'Not available for this model. The gateway catalog has no faster variant.';
+ case 'openai':
+ return 'Not available for the selected OpenAI model.';
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
+ }
+}
+
function ProviderConfigurationFields({
apiKey,
attemptedSave,
- availableModelOptions,
+ availableModelGroups,
availableReasoningOptions,
canEditSchema,
config,
@@ -650,8 +794,13 @@ function ProviderConfigurationFields({
const details = PROVIDER_DETAILS[config.provider];
const fastMode = activeFastMode(config);
const fastModeSupported = selectedModel
- ? providerModelSupportsFastMode(config.provider, selectedModel.id)
+ ? providerModelSupportsFastMode(config.provider, selectedModel.id, [
+ selectedModel,
+ ])
: false;
+ const modelOptions = flattenModelOptions(availableModelGroups);
+ const reasoningHint = reasoningEffortHint(config, selectedModel);
+ const fastModeHint = fastModeHintFor(config.provider, fastModeSupported);
return (
@@ -691,7 +840,11 @@ function ProviderConfigurationFields({
? `${details.apiKeyLabel} is required.`
: undefined
}
- hint="Shared across this project. Each editor connects DatoCMS separately in the agent."
+ hint={
+ config.provider === 'vercel'
+ ? 'Create this key under Vercel → AI Gateway → API keys. Requests are billed to the team’s AI Gateway credits, or to bring-your-own provider keys configured there.'
+ : 'Shared across this project. Each editor connects DatoCMS separately in the agent.'
+ }
onChange={(value) =>
setConfig((current) => withActiveApiKey(current, value))
}
@@ -704,15 +857,12 @@ function ProviderConfigurationFields({
type: 'password',
}}
/>
-
+
id="model"
name="model"
label={details.modelLabel}
required
- value={
- availableModelOptions.find((option) => option.value === model) ??
- null
- }
+ value={modelOptions.find((option) => option.value === model) ?? null}
error={modelFieldError(attemptedSave, config, discovery)}
hint={modelDiscoveryHint(
config.provider,
@@ -734,12 +884,33 @@ function ProviderConfigurationFields({
}
}}
selectInputProps={{
- options: availableModelOptions,
+ options: availableModelGroups,
isDisabled: modelSelectDisabled,
isLoading: discovery.loading,
required: true,
}}
/>
+ {config.provider === 'vercel' && (
+
+ setConfig((current) =>
+ current.provider === 'vercel'
+ ? { ...current, vercelShowAllModels: value }
+ : current,
+ )
+ }
+ switchInputProps={{
+ name: 'vercel-show-all-models',
+ value: config.vercelShowAllModels,
+ disabled: !canEditSchema || saving,
+ }}
+ />
+ )}
id="reasoning-effort"
name="reasoning-effort"
@@ -758,16 +929,13 @@ function ProviderConfigurationFields({
);
}
}}
- hint={
- config.provider === 'anthropic'
- ? 'Options supported by the selected Claude model.'
- : 'Availability depends on the selected model.'
- }
+ hint={reasoningHint}
selectInputProps={{
options: availableReasoningOptions,
isDisabled:
!canEditSchema ||
saving ||
+ availableReasoningOptions.length === 0 ||
(config.provider === 'anthropic' && !selectedModel),
}}
/>
@@ -775,13 +943,7 @@ function ProviderConfigurationFields({
id="fast-mode"
name="fast-mode"
label="Fast mode"
- hint={
- fastModeSupported
- ? 'Uses premium processing for every model request, including tool steps, and can significantly increase API costs.'
- : config.provider === 'anthropic'
- ? 'Not available for this Claude model. Anthropic Fast mode currently requires a supported Opus model and preview access.'
- : 'Not available for the selected OpenAI model.'
- }
+ hint={fastModeHint}
value={fastMode}
onChange={(value) =>
setConfig((current) => withActiveFastMode(current, value))
@@ -895,10 +1057,7 @@ export default function ConfigScreen({ ctx }: Props) {
const selectedModel = modelDiscovery.models.find(
(candidate) => candidate.id === model,
);
- const availableReasoningOptions = reasoningOptions(
- config.provider,
- selectedModel,
- );
+ const availableReasoningOptions = reasoningOptions(config, selectedModel);
const reasoningEffort = activeReasoningEffort(config);
useEffect(() => {
@@ -921,9 +1080,9 @@ export default function ConfigScreen({ ctx }: Props) {
});
}, [config.provider, selectedModel]);
- const availableModelOptions = useMemo(
- () => modelOptions(modelDiscovery.models, model),
- [model, modelDiscovery.models],
+ const availableModelGroups = useMemo(
+ () => modelSelectGroups(config, modelDiscovery.models, model),
+ [config, model, modelDiscovery.models],
);
const modelSelectDisabled =
!canEditSchema ||
@@ -1021,7 +1180,7 @@ export default function ConfigScreen({ ctx }: Props) {
{
const secret = 'sk-12345678901234567890';
const client = new QueueResponsesClient([
mcpFailureEvents({
- message: `Bearer oauth-secret ${secret} ${'x'.repeat(2_000)}`,
+ message: `Bearer oauth-secret ${secret} vck_gatewaysecretvalue ${'x'.repeat(2_000)}`,
}),
]);
const runtime = runtimeWith(client);
@@ -4234,6 +4234,9 @@ describe('AgentRuntime', () => {
expect(failedActivity?.activity.error).toContain('[redacted]');
expect(failedActivity?.activity.error).not.toContain('oauth-secret');
expect(failedActivity?.activity.error).not.toContain(secret);
+ expect(failedActivity?.activity.error).not.toContain(
+ 'vck_gatewaysecretvalue',
+ );
});
it.each([
diff --git a/dato-agent/src/lib/agentRuntime.ts b/dato-agent/src/lib/agentRuntime.ts
index 923d6365..05596773 100644
--- a/dato-agent/src/lib/agentRuntime.ts
+++ b/dato-agent/src/lib/agentRuntime.ts
@@ -25,10 +25,12 @@ import type {
} from 'openai/resources/responses/responses';
import { localActivityLabel, mcpActivityLabel } from './activityLabels';
import { READ_ONLY_REJECTION_MESSAGE, validateMcpToolCall } from './approval';
-import type {
- AgentProvider,
- AnthropicReasoningEffort,
- ReasoningEffort,
+import {
+ type AgentProvider,
+ type AnthropicReasoningEffort,
+ DEFAULT_VERCEL_MODEL,
+ REASONING_EFFORTS,
+ type ReasoningEffort,
} from './config';
import { MAX_CONVERSATION_MESSAGE_CHARACTERS } from './conversations';
import {
@@ -71,6 +73,7 @@ import {
import type { LocalFileAttachmentDescriptor } from './mentions';
import { providerModelSupportsFastMode } from './providerModels';
import { type AgentSystemContext, buildSystemPrompt } from './systemPrompt';
+import { createVercelGatewayFetch } from './vercelGateway';
export const DEFAULT_AGENT_MODEL = 'gpt-5.6-terra' as const;
export const DEFAULT_ANTHROPIC_AGENT_MODEL = 'claude-sonnet-4-6' as const;
@@ -79,6 +82,7 @@ export const MAX_MAX_CONTINUATIONS = 100;
export const DEFAULT_MAX_TOOL_CALLS = 20;
export const MAX_AGENT_TOOL_CALLS_PER_TURN = 200;
export const MAX_AGENT_REQUEST_CHARACTERS = 600_000;
+const MAX_GATEWAY_INLINE_TEXT_CHARACTERS = 100_000;
export const MAX_TOOL_RESULT_CHARACTERS = 60_000;
export const DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS = 16_000;
export const DEEP_ANTHROPIC_MAX_OUTPUT_TOKENS = 64_000;
@@ -224,6 +228,21 @@ function attachmentMimeType(attachment: AgentRuntimeAttachment): string {
return inferred ?? declared ?? fileType ?? 'application/octet-stream';
}
+function attachmentProviderName(provider: AgentProvider): string {
+ switch (provider) {
+ case 'openai':
+ return 'OpenAI';
+ case 'anthropic':
+ return 'Anthropic';
+ case 'vercel':
+ return 'Vercel AI Gateway';
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
+ }
+}
+
function attachmentTypeSupported(
provider: AgentProvider,
mimeType: string,
@@ -310,10 +329,10 @@ function normalizedAttachment(
const mimeType = attachmentMimeType(attachment);
const filename = attachment.filename.trim();
const legacyOfficeFile =
- provider === 'anthropic' && isLegacyOfficeAttachment(filename, mimeType);
+ provider !== 'openai' && isLegacyOfficeAttachment(filename, mimeType);
const providerReadable =
!legacyOfficeFile && attachmentTypeSupported(provider, mimeType, filename);
- const providerName = provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
+ const providerName = attachmentProviderName(provider);
const providerUnreadableReason = legacyOfficeFile
? 'Legacy .doc, .xls, and .ppt files cannot be read. Convert this file to DOCX, XLSX, PPTX, or PDF to make its contents readable.'
: providerReadable
@@ -646,8 +665,13 @@ export interface AgentRuntimeConfig {
*/
client?: AgentResponsesClient;
/**
- * Anthropic Messages client injection seam. Used only when provider is
- * `anthropic`.
+ * Effort levels discovered for the selected model. The gateway runtime sends
+ * reasoning controls only when this list is non-empty.
+ */
+ modelReasoningEfforts?: readonly ReasoningEffort[];
+ /**
+ * Anthropic Messages client injection seam. Used when provider is
+ * `anthropic` or `vercel`.
*/
anthropicClient?: AgentAnthropicMessagesClient;
/**
@@ -2450,7 +2474,8 @@ const SENSITIVE_SCHEMA_KEY =
function redactSensitiveString(value: string): string {
return value
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [redacted]')
- .replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]');
+ .replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]')
+ .replace(/\bvck_[A-Za-z0-9_-]{8,}\b/g, '[redacted]');
}
function stringifyModelSchemaResult(result: unknown): string {
@@ -2493,7 +2518,7 @@ function safeErrorMessage(error: unknown): string {
return redactAndTruncateErrorMessage(message);
}
-type ProviderRuntimeName = 'openai' | 'anthropic';
+type ProviderRuntimeName = 'openai' | 'anthropic' | 'vercel';
class ProviderRequestFailure extends Error {
constructor(
@@ -2534,6 +2559,11 @@ const TRANSIENT_ERROR_CODES = new Set([
'timeout_error',
'vector_store_timeout',
]);
+const BILLING_ERROR_CODES = new Set([
+ 'customer_verification_required',
+ 'insufficient_funds',
+ 'quota_for_entity_exceeded',
+]);
const DETERMINISTIC_REQUEST_ERROR_CODES = new Set([
'bad_request',
'bad_request_error',
@@ -2559,7 +2589,18 @@ const DETERMINISTIC_REQUEST_ERROR_CODES = new Set([
]);
function providerLabel(provider: ProviderRuntimeName): string {
- return provider === 'openai' ? 'OpenAI' : 'Anthropic';
+ switch (provider) {
+ case 'openai':
+ return 'OpenAI';
+ case 'anthropic':
+ return 'Anthropic';
+ case 'vercel':
+ return 'Vercel AI Gateway';
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
+ }
}
function errorObject(error: unknown): Record | undefined {
@@ -2627,6 +2668,26 @@ function isInterruptedProviderStream(message: string): boolean {
);
}
+function billingLimitMessage(provider: ProviderRuntimeName): string {
+ return provider === 'vercel'
+ ? 'Vercel AI Gateway has no credits left, or a budget was exceeded. Add credits or raise the budget in the Vercel dashboard.'
+ : `${providerLabel(provider)} rejected this request because the account cannot be billed.`;
+}
+
+function connectionFailureMessage(provider: ProviderRuntimeName): string {
+ switch (provider) {
+ case 'anthropic':
+ return 'Anthropic could not be reached from this browser. ZDR organizations require a server-side provider proxy.';
+ case 'vercel':
+ return 'Vercel AI Gateway could not be reached from this browser.';
+ case 'openai':
+ return 'OpenAI could not be reached. Check your connection and try again.';
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
+ }
+}
function authenticationMessage(provider: ProviderRuntimeName): string {
return `${providerLabel(provider)} rejected the configured API key. Update it in plugin settings.`;
}
@@ -2693,6 +2754,13 @@ function classifyProviderHttpStatus(
retryable: false,
};
}
+ if (status === 402 || (code !== undefined && BILLING_ERROR_CODES.has(code))) {
+ return {
+ code: 'api_error',
+ message: billingLimitMessage(provider),
+ retryable: false,
+ };
+ }
if (status === 403) {
return {
code: 'api_error',
@@ -2733,6 +2801,13 @@ function classifyProviderErrorCode(
retryable: false,
};
}
+ if (code && BILLING_ERROR_CODES.has(code)) {
+ return {
+ code: 'api_error',
+ message: billingLimitMessage(provider),
+ retryable: false,
+ };
+ }
if (code && PERMISSION_ERROR_CODES.has(code)) {
return {
code: 'api_error',
@@ -2767,10 +2842,7 @@ function classifyProviderErrorMessage(
if (isProviderConnectionError(provider, error, message)) {
return {
code: 'api_error',
- message:
- provider === 'anthropic'
- ? 'Anthropic could not be reached from this browser. ZDR organizations require a server-side provider proxy.'
- : 'OpenAI could not be reached. Check your connection and try again.',
+ message: connectionFailureMessage(provider),
retryable: true,
};
}
@@ -4998,18 +5070,30 @@ type DefaultAnthropicClients = {
function createDefaultAnthropicClients(
apiKey: string,
+ options?: { gateway?: boolean },
): DefaultAnthropicClients {
const normalizedApiKey = apiKey.trim();
+ const gateway = options?.gateway === true;
if (!normalizedApiKey) {
throw new Error(
- 'An Anthropic API key or an injected Messages client is required.',
+ gateway
+ ? 'An AI Gateway API key or an injected Messages client is required.'
+ : 'An Anthropic API key or an injected Messages client is required.',
);
}
- const client = new Anthropic({
- apiKey: normalizedApiKey,
- dangerouslyAllowBrowser: true,
- });
+ const client = gateway
+ ? new Anthropic({
+ apiKey: null,
+ authToken: normalizedApiKey,
+ baseURL: 'https://ai-gateway.vercel.sh',
+ dangerouslyAllowBrowser: true,
+ fetch: createVercelGatewayFetch(),
+ })
+ : new Anthropic({
+ apiKey: normalizedApiKey,
+ dangerouslyAllowBrowser: true,
+ });
return {
messages: {
@@ -5062,6 +5146,154 @@ function resolveAnthropicEffort(
return 'high';
}
+function resolveRuntimeModel(config: AgentRuntimeConfig): string {
+ const configured = config.model?.trim();
+ if (configured) {
+ return configured;
+ }
+ return config.provider === 'vercel'
+ ? DEFAULT_VERCEL_MODEL
+ : DEFAULT_ANTHROPIC_AGENT_MODEL;
+}
+
+function resolveRuntimeReasoningEfforts(
+ config: AgentRuntimeConfig,
+ model: string,
+): readonly ReasoningEffort[] {
+ if (config.provider !== 'vercel') {
+ return [];
+ }
+ if (config.modelReasoningEfforts) {
+ return config.modelReasoningEfforts;
+ }
+ return model.startsWith('anthropic/')
+ ? ['low', 'medium', 'high', 'xhigh', 'max']
+ : [];
+}
+
+function resolveRuntimeFastMode(
+ config: AgentRuntimeConfig,
+ model: string,
+): boolean {
+ if (!config.fastMode) {
+ return false;
+ }
+ if (config.provider === 'vercel') {
+ return true;
+ }
+ return providerModelSupportsFastMode('anthropic', model);
+}
+
+function resolveAnthropicTransport(config: AgentRuntimeConfig): {
+ client: AgentAnthropicMessagesClient;
+ fileClient?: AgentAnthropicFileClient;
+} {
+ const gateway = config.provider === 'vercel';
+ const normalizedApiKey = config.apiKey?.trim();
+ const needsDefaultClient =
+ !config.anthropicClient ||
+ (!gateway && !config.anthropicFileClient && Boolean(normalizedApiKey));
+ const defaultClients = needsDefaultClient
+ ? createDefaultAnthropicClients(normalizedApiKey ?? '', { gateway })
+ : undefined;
+ const client = config.anthropicClient ?? defaultClients?.messages;
+ if (!client) {
+ throw new Error(
+ gateway
+ ? 'An AI Gateway API key or an injected Messages client is required.'
+ : 'An Anthropic API key or an injected Messages client is required.',
+ );
+ }
+ return {
+ client,
+ fileClient: gateway
+ ? undefined
+ : (config.anthropicFileClient ?? defaultClients?.files),
+ };
+}
+
+function unreadableAttachmentResult(
+ gateway: boolean,
+ attachment: NormalizedAgentRuntimeAttachment,
+): AnthropicAttachmentTransportResult {
+ return {
+ type: 'metadata_only',
+ reason:
+ attachment.providerUnreadableReason ??
+ (gateway
+ ? 'Vercel AI Gateway could not read this file type.'
+ : 'Anthropic could not read this file type.'),
+ };
+}
+
+function documentExtractionFailure(
+ cause: unknown,
+ gateway: boolean,
+ readOnly: boolean,
+): AnthropicAttachmentTransportResult | undefined {
+ if (!(cause instanceof DocumentTextExtractionError)) {
+ return undefined;
+ }
+ const providerName = gateway ? 'Vercel AI Gateway' : 'Anthropic';
+ const disposition = readOnly
+ ? 'The original file remains attached to this chat'
+ : 'The original file remains available for explicit DatoCMS asset creation';
+ return {
+ type: 'metadata_only',
+ reason: `${cause.message} ${disposition}, but ${providerName} did not receive or read its contents.`,
+ };
+}
+
+async function prepareProviderAttachment(
+ attachment: NormalizedAgentRuntimeAttachment,
+ gateway: boolean,
+ readOnly: boolean,
+ signal?: AbortSignal,
+): Promise<
+ | { type: 'ready'; attachment: NormalizedAgentRuntimeAttachment }
+ | AnthropicAttachmentTransportResult
+> {
+ if (!attachment.providerReadable) {
+ return unreadableAttachmentResult(gateway, attachment);
+ }
+ throwIfAborted(signal);
+ try {
+ return {
+ type: 'ready',
+ attachment: await materializeAnthropicDocumentText(attachment),
+ };
+ } catch (cause) {
+ const failure = documentExtractionFailure(cause, gateway, readOnly);
+ if (failure) {
+ return failure;
+ }
+ throw cause;
+ }
+}
+
+function resolveGatewayEffort(config: AgentRuntimeConfig): ReasoningEffort {
+ const activeEffort = config.reasoningEffort;
+ return REASONING_EFFORTS.find((effort) => effort === activeEffort) ?? 'high';
+}
+
+function anthropicOutputEffort(
+ effort: ReasoningEffort,
+ supported: readonly ReasoningEffort[] | undefined,
+): AnthropicReasoningEffort | undefined {
+ if (effort === 'none') {
+ return undefined;
+ }
+ if (supported && !supported.includes(effort)) {
+ return undefined;
+ }
+ return effort;
+}
+
+/**
+ * Adaptive thinking shares max_tokens with the visible reply and tool input,
+ * so even low-effort turns need room for a full write script. Anthropic bills
+ * only generated tokens; the cap is a ceiling, not a reservation.
+ */
function resolveAnthropicMaxOutputTokens(
effort: AnthropicReasoningEffort,
discoveredMaximum: number | undefined,
@@ -5162,8 +5394,85 @@ function anthropicFileReferenceBlock(
};
}
+async function inlineGatewayAttachment(
+ attachment: NormalizedAgentRuntimeAttachment,
+ signal?: AbortSignal,
+): Promise {
+ throwIfAborted(signal);
+ const bytes = new Uint8Array(await attachment.file.arrayBuffer());
+ throwIfAborted(signal);
+ if (IMAGE_ATTACHMENT_MIME_TYPES.has(attachment.mimeType)) {
+ return {
+ type: 'inline',
+ block: {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: attachment.mimeType as 'image/png',
+ data: base64FromBytes(bytes),
+ },
+ },
+ };
+ }
+ if (attachment.mimeType === 'application/pdf') {
+ return {
+ type: 'inline',
+ block: {
+ type: 'document',
+ source: {
+ type: 'base64',
+ media_type: 'application/pdf',
+ data: base64FromBytes(bytes),
+ },
+ title: attachment.filename,
+ },
+ };
+ }
+ return {
+ type: 'inline',
+ block: {
+ type: 'text',
+ text: gatewayInlineText(attachment.filename, bytes),
+ },
+ };
+}
+
+function gatewayInlineText(filename: string, bytes: Uint8Array): string {
+ const decoded = new TextDecoder().decode(bytes);
+ const header = `Attached file ${filename}:\n`;
+ if (decoded.length <= MAX_GATEWAY_INLINE_TEXT_CHARACTERS) {
+ return `${header}${decoded}`;
+ }
+ return `${header}${decoded.slice(0, MAX_GATEWAY_INLINE_TEXT_CHARACTERS)}\n[Truncated. The file is larger than the inline text limit.]`;
+}
+
+function anthropicRequestCharacters(
+ messages: readonly MessageParam[],
+ system: string,
+ tools: readonly AnthropicTool[],
+): number {
+ const serializedMessages = JSON.stringify(messages, (key, value: unknown) => {
+ if (key === 'data' && typeof value === 'string') {
+ return '';
+ }
+ if (
+ key === 'text' &&
+ typeof value === 'string' &&
+ value.startsWith('Attached file ')
+ ) {
+ const lineBreak = value.indexOf('\n');
+ return lineBreak === -1 ? value : value.slice(0, lineBreak + 1);
+ }
+ return value;
+ });
+ return (
+ serializedMessages.length + system.length + JSON.stringify(tools).length
+ );
+}
+
type AnthropicAttachmentTransportResult =
| { type: 'uploaded'; fileId: string }
+ | { type: 'inline'; block: ContentBlockParam }
| { type: 'metadata_only'; reason: string };
async function anthropicUserMessage(
@@ -5178,13 +5487,24 @@ async function anthropicUserMessage(
return { message: { role: 'user', content: text }, usesFiles: false };
}
- const blocks: AnthropicFileReferenceBlock[] = [];
+ const blocks: ContentBlockParam[] = [];
const availability = [...attachments];
+ let usesFiles = false;
for (const attachment of attachments) {
// biome-ignore lint/performance/noAwaitInLoops: Sequential provider uploads avoid memory and bandwidth spikes in a browser iframe.
const result = await upload(attachment);
if (result.type === 'uploaded') {
- blocks.push(anthropicFileReferenceBlock(attachment, result.fileId));
+ blocks.push(
+ anthropicFileReferenceBlock(
+ attachment,
+ result.fileId,
+ ) as unknown as ContentBlockParam,
+ );
+ usesFiles = true;
+ continue;
+ }
+ if (result.type === 'inline') {
+ blocks.push(result.block);
continue;
}
const index = availability.indexOf(attachment);
@@ -5203,9 +5523,9 @@ async function anthropicUserMessage(
type: 'text',
text: messageWithAttachmentAvailability(text, availability, readOnly),
},
- ] as unknown as ContentBlockParam[],
+ ],
},
- usesFiles: blocks.length > 0,
+ usesFiles,
};
}
@@ -5214,13 +5534,14 @@ async function initialAnthropicMessages(
history: readonly AgentConversationHistoryMessage[],
attachments: readonly AgentRuntimeAttachment[],
readOnly: boolean,
+ provider: 'anthropic' | 'vercel',
upload: (
attachment: NormalizedAgentRuntimeAttachment,
) => Promise,
): Promise<{ messages: MessageParam[]; usesFiles: boolean }> {
const normalizedHistory = normalizeAgentHistory(history);
const normalizedAttachments = validateAndNormalizeAttachments(
- 'anthropic',
+ provider,
normalizedHistory,
attachments,
);
@@ -5386,8 +5707,10 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
private readonly mcpClient: DatoMcpClient;
private readonly context: AgentSystemContext;
private readonly navigation: AgentNavigationCallbacks;
- private readonly reasoningEffort: AnthropicReasoningEffort;
+ private readonly reasoningEffort: ReasoningEffort;
+ private readonly modelReasoningEfforts: readonly ReasoningEffort[];
private readonly fastMode: boolean;
+ private readonly gateway: boolean;
private readonly readOnly: boolean;
private readonly pluginReadOnly: boolean;
private readonly mcpAccessLevel?: McpAccessLevel;
@@ -5406,12 +5729,16 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
private disposed = false;
constructor(config: AgentRuntimeConfig) {
- this.model = config.model?.trim() || DEFAULT_ANTHROPIC_AGENT_MODEL;
+ const model = resolveRuntimeModel(config);
+ const transport = resolveAnthropicTransport(config);
+ this.gateway = config.provider === 'vercel';
+ this.model = model;
this.maxContinuations = resolveMaxContinuations(config.maxContinuations);
- this.reasoningEffort = resolveAnthropicEffort(config);
- this.fastMode =
- Boolean(config.fastMode) &&
- providerModelSupportsFastMode('anthropic', this.model);
+ this.reasoningEffort = this.gateway
+ ? resolveGatewayEffort(config)
+ : resolveAnthropicEffort(config);
+ this.modelReasoningEfforts = resolveRuntimeReasoningEfforts(config, model);
+ this.fastMode = resolveRuntimeFastMode(config, model);
this.pluginReadOnly = Boolean(config.readOnly);
this.mcpAccessLevel = config.mcpAccessLevel;
this.readOnly = !agentWritesAllowed(
@@ -5419,7 +5746,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
config.mcpAccessLevel ?? 'unrestricted',
);
this.maxOutputTokens = resolveAnthropicMaxOutputTokens(
- this.reasoningEffort,
+ this.reasoningEffort === 'none' ? 'high' : this.reasoningEffort,
config.modelMaxOutputTokens,
);
this.additionalInstructions =
@@ -5427,20 +5754,8 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
this.hostContext = normalizeHostContext(config.hostContext);
this.getModelSchema = config.getModelSchema;
this.createDatoAsset = config.createDatoAsset;
- const normalizedApiKey = config.apiKey?.trim();
- const defaultClients =
- !config.anthropicClient ||
- (!config.anthropicFileClient && Boolean(normalizedApiKey))
- ? createDefaultAnthropicClients(normalizedApiKey ?? '')
- : undefined;
- const messagesClient = config.anthropicClient ?? defaultClients?.messages;
- if (!messagesClient) {
- throw new Error(
- 'An Anthropic API key or an injected Messages client is required.',
- );
- }
- this.client = messagesClient;
- this.fileClient = config.anthropicFileClient ?? defaultClients?.files;
+ this.client = transport.client;
+ this.fileClient = transport.fileClient;
const mcpAccessToken = config.mcpAccessToken.trim();
if (!mcpAccessToken) {
@@ -5479,6 +5794,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
args.history ?? [],
args.attachments ?? [],
this.readOnly,
+ this.gateway ? 'vercel' : 'anthropic',
(attachment) => this.uploadAttachment(attachment, args.signal),
);
return yield* this.runLoop(
@@ -5513,7 +5829,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
) {
return yield* this.invalidRequestStream(cause.message);
}
- const error = runtimeFailure('anthropic', cause, args.signal);
+ const error = runtimeFailure(this.providerName(), cause, args.signal);
const result: AgentTurnResult = {
status: error.code === 'aborted' ? 'aborted' : 'failed',
text: '',
@@ -5532,36 +5848,25 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
attachment: NormalizedAgentRuntimeAttachment,
signal?: AbortSignal,
): Promise {
- if (!attachment.providerReadable) {
- return {
- type: 'metadata_only',
- reason:
- attachment.providerUnreadableReason ??
- 'Anthropic could not read this file type.',
- };
+ const prepared = await prepareProviderAttachment(
+ attachment,
+ this.gateway,
+ this.readOnly,
+ signal,
+ );
+ if (prepared.type !== 'ready') {
+ return prepared;
+ }
+ throwIfAborted(signal);
+ if (this.gateway) {
+ return await inlineGatewayAttachment(prepared.attachment, signal);
}
if (!this.fileClient) {
throw new AgentAttachmentValidationError(
'Anthropic file reading is unavailable for this connection. Reconnect with an Anthropic API key or configure an Anthropic Files API transport.',
);
}
- throwIfAborted(signal);
- let providerAttachment = attachment;
- try {
- providerAttachment = await materializeAnthropicDocumentText(attachment);
- } catch (cause) {
- if (cause instanceof DocumentTextExtractionError) {
- return {
- type: 'metadata_only',
- reason: this.readOnly
- ? `${cause.message} The original file remains attached to this chat, but Anthropic did not receive or read its contents.`
- : `${cause.message} The original file remains available for explicit DatoCMS asset creation, but Anthropic did not receive or read its contents.`,
- };
- }
- throw cause;
- }
- throwIfAborted(signal);
- const uploaded = await this.fileClient.upload(providerAttachment, {
+ const uploaded = await this.fileClient.upload(prepared.attachment, {
signal,
});
const fileId = uploaded.id.trim();
@@ -5921,7 +6226,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
}
pending.phase = 'ready';
- const error = runtimeFailure('anthropic', cause, args.signal);
+ const error = runtimeFailure(this.providerName(), cause, args.signal);
const aborted = error.code === 'aborted';
const result: AgentTurnResult = {
status: aborted
@@ -5990,22 +6295,29 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
): AgentAnthropicMessageCreateParamsStreaming {
boundAnthropicToolResultHistory(state);
if (
- JSON.stringify(state.messages).length +
- state.system.length +
- JSON.stringify(tools).length >
+ anthropicRequestCharacters(state.messages, state.system, tools) >
MAX_AGENT_REQUEST_CHARACTERS
) {
throw new AgentContextLimitError(
'The agent reached its model-context safety limit. The operation is incomplete; completed changes are not rolled back. Raw tool outputs were compacted, but signed reasoning and reviewed calls cannot be safely discarded.',
);
}
- const betas = [
- ...(state.usesFiles ? [ANTHROPIC_FILES_API_BETA] : []),
- ...(this.fastMode ? [ANTHROPIC_FAST_MODE_BETA] : []),
- ];
+ const betas = this.gateway
+ ? []
+ : [
+ ...(state.usesFiles ? [ANTHROPIC_FILES_API_BETA] : []),
+ ...(this.fastMode ? [ANTHROPIC_FAST_MODE_BETA] : []),
+ ];
+ const reasoningEffort = anthropicOutputEffort(
+ this.reasoningEffort,
+ this.gateway ? this.modelReasoningEfforts : undefined,
+ );
+ const includeThinking =
+ reasoningEffort !== undefined &&
+ (!this.gateway || this.resolvedModel().startsWith('anthropic/'));
return {
- model: this.model,
+ model: this.resolvedModel(),
max_tokens: this.maxOutputTokens,
// Keep the dispatched request immutable while later tool-loop steps append
// to runtime state (also important for proxy/client implementations that
@@ -6017,14 +6329,38 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
type: 'auto',
disable_parallel_tool_use: true,
},
- thinking: { type: 'adaptive', display: 'omitted' },
- output_config: { effort: this.reasoningEffort },
+ ...(includeThinking
+ ? {
+ thinking: {
+ type: 'adaptive' as const,
+ display: 'omitted' as const,
+ },
+ }
+ : {}),
+ ...(reasoningEffort
+ ? { output_config: { effort: reasoningEffort } }
+ : {}),
...(betas.length > 0 ? { betas } : {}),
- ...(this.fastMode ? { speed: 'fast' } : {}),
+ ...(!this.gateway && this.fastMode ? { speed: 'fast' as const } : {}),
stream: true,
};
}
+ private providerName(): 'anthropic' | 'vercel' {
+ return this.gateway ? 'vercel' : 'anthropic';
+ }
+
+ private resolvedModel(): string {
+ if (
+ this.gateway &&
+ this.fastMode &&
+ !this.model.toLowerCase().endsWith('-fast')
+ ) {
+ return `${this.model}-fast`;
+ }
+ return this.model;
+ }
+
private async *loadTools(
signal?: AbortSignal,
): AsyncGenerator {
@@ -6109,7 +6445,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
signal,
);
} catch (cause) {
- throw new ProviderRequestFailure('anthropic', cause);
+ throw new ProviderRequestFailure(this.providerName(), cause);
}
// A consumer can abort while handling the final post-stream event.
// Never expose tool calls or approvals from a response after that turn
@@ -6440,7 +6776,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
yield { type: 'turn_completed', result };
return result;
} catch (cause) {
- const error = runtimeFailure('anthropic', cause, signal);
+ const error = runtimeFailure(this.providerName(), cause, signal);
const aborted = error.code === 'aborted';
const result: AgentTurnResult = {
status: aborted
@@ -6731,7 +7067,7 @@ export class AnthropicAgentRuntime implements AgentRuntimeHandle {
}
export function createAgentRuntime(
- config: AgentRuntimeConfig & { provider: 'anthropic' },
+ config: AgentRuntimeConfig & { provider: 'anthropic' | 'vercel' },
): AgentRuntimeHandle;
export function createAgentRuntime(
config: AgentRuntimeConfig & { provider?: 'openai' },
@@ -6742,7 +7078,7 @@ export function createAgentRuntime(
export function createAgentRuntime(
config: AgentRuntimeConfig,
): AgentRuntimeHandle {
- return config.provider === 'anthropic'
+ return config.provider === 'anthropic' || config.provider === 'vercel'
? new AnthropicAgentRuntime(config)
: new AgentRuntime(config);
}
diff --git a/dato-agent/src/lib/anthropicAgentRuntime.test.ts b/dato-agent/src/lib/anthropicAgentRuntime.test.ts
index 057cab4b..69b18737 100644
--- a/dato-agent/src/lib/anthropicAgentRuntime.test.ts
+++ b/dato-agent/src/lib/anthropicAgentRuntime.test.ts
@@ -4532,4 +4532,163 @@ describe('Anthropic MCP authentication and access', () => {
await runtime.submitApprovals(args);
expect(sdk.callTool).toHaveBeenCalledOnce();
});
+
+ it('sends gateway models through Messages without Anthropic-only betas', async () => {
+ const anthropic = new QueueAnthropicClient([
+ message('msg-gateway', [textBlock('Done.')]),
+ ]);
+ const files = new QueueAnthropicFileClient(['should-not-upload']);
+ const mcp = mcpClientWith();
+ const runtime = runtimeWith(anthropic, mcp.client, {
+ provider: 'vercel',
+ anthropicFileClient: files,
+ fastMode: true,
+ model: 'openai/gpt-6.1-sol',
+ modelReasoningEfforts: ['low', 'medium', 'high'],
+ reasoningEffort: 'high',
+ });
+
+ const { result } = await drain(
+ runtime.streamTurn({
+ message: 'Read this.',
+ attachments: [
+ attachment({
+ id: 'notes',
+ filename: 'notes.txt',
+ mimeType: 'text/plain',
+ content: 'Hello notes',
+ }),
+ attachment({
+ id: 'picture',
+ filename: 'diagram.png',
+ mimeType: 'image/png',
+ content: 'png-bytes',
+ }),
+ attachment({
+ id: 'brief',
+ filename: 'brief.pdf',
+ mimeType: 'application/pdf',
+ content: '%PDF-brief',
+ }),
+ ],
+ }),
+ );
+
+ expect(result.status).toBe('completed');
+ expect(files.uploads).toEqual([]);
+ const request = anthropic
+ .requests[0] as AgentAnthropicMessageCreateParamsStreaming;
+ expect(request.model).toBe('openai/gpt-6.1-sol-fast');
+ expect(request.betas).toBeUndefined();
+ expect(request.speed).toBeUndefined();
+ expect(request.thinking).toBeUndefined();
+ expect(request.output_config).toEqual({ effort: 'high' });
+ expect(lastMessageContent(request)).toEqual([
+ { type: 'text', text: 'Attached file notes.txt:\nHello notes' },
+ {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: 'image/png',
+ data: btoa('png-bytes'),
+ },
+ },
+ {
+ type: 'document',
+ source: {
+ type: 'base64',
+ media_type: 'application/pdf',
+ data: btoa('%PDF-brief'),
+ },
+ title: 'brief.pdf',
+ },
+ { type: 'text', text: 'Read this.' },
+ ]);
+ });
+
+ it('keeps adaptive thinking for Claude models on the gateway and names credit failures', async () => {
+ const anthropic = new QueueAnthropicClient([
+ providerError(402, 'insufficient funds', 'insufficient_funds'),
+ ]);
+ const mcp = mcpClientWith();
+ const runtime = runtimeWith(anthropic, mcp.client, {
+ provider: 'vercel',
+ model: 'anthropic/claude-sonnet-5.5',
+ reasoningEffort: 'high',
+ });
+
+ const { result } = await drain(runtime.streamTurn({ message: 'Inspect.' }));
+
+ const request = anthropic
+ .requests[0] as AgentAnthropicMessageCreateParamsStreaming;
+ expect(request.model).toBe('anthropic/claude-sonnet-5.5');
+ expect(request.thinking).toEqual({ type: 'adaptive', display: 'omitted' });
+ expect(request.output_config).toEqual({ effort: 'high' });
+ expect(request.betas).toBeUndefined();
+ expect(result.error).toMatchObject({
+ message: expect.stringContaining('no credits left'),
+ retryable: false,
+ });
+ });
+
+ it('keeps a large gateway attachment inside the request character budget', async () => {
+ const anthropic = new QueueAnthropicClient([
+ message('msg-large', [textBlock('Done.')]),
+ ]);
+ const runtime = runtimeWith(anthropic, mcpClientWith().client, {
+ provider: 'vercel',
+ model: 'openai/gpt-6.1-sol',
+ });
+ const imageBytes = 'x'.repeat(601_000);
+ const note = 'n'.repeat(150_000);
+
+ const { result } = await drain(
+ runtime.streamTurn({
+ message: 'Read this.',
+ attachments: [
+ attachment({
+ id: 'picture',
+ filename: 'diagram.png',
+ mimeType: 'image/png',
+ content: imageBytes,
+ }),
+ attachment({
+ id: 'notes',
+ filename: 'notes.txt',
+ mimeType: 'text/plain',
+ content: note,
+ }),
+ ],
+ }),
+ );
+
+ expect(result.status).toBe('completed');
+ const content = lastMessageContent(
+ anthropic.requests[0] as AgentAnthropicMessageCreateParamsStreaming,
+ );
+ const image = content.find(
+ (block): block is { source: { data: string } } =>
+ typeof block === 'object' &&
+ block !== null &&
+ 'source' in block &&
+ typeof block.source === 'object' &&
+ block.source !== null &&
+ 'data' in block.source &&
+ typeof block.source.data === 'string',
+ );
+ const attachedText = content.find(
+ (block): block is { text: string } =>
+ typeof block === 'object' &&
+ block !== null &&
+ 'text' in block &&
+ typeof block.text === 'string' &&
+ block.text.startsWith('Attached file notes.txt:'),
+ );
+ expect(image?.source.data.length).toBeGreaterThan(600_000);
+ expect(attachedText?.text).toContain(
+ 'The file is larger than the inline text limit.',
+ );
+ expect(attachedText?.text.length).toBeLessThan(110_000);
+ expect(attachedText?.text).not.toContain(note);
+ });
});
diff --git a/dato-agent/src/lib/changeSummary.test.ts b/dato-agent/src/lib/changeSummary.test.ts
index 0590ab9c..ce7da0b2 100644
--- a/dato-agent/src/lib/changeSummary.test.ts
+++ b/dato-agent/src/lib/changeSummary.test.ts
@@ -21,6 +21,10 @@ const baseConfig = {
anthropicApiKey: 'sk-ant-test',
anthropicModel: 'claude-sonnet-4-6',
anthropicReasoningEffort: 'high',
+ vercelApiKey: 'vck_test_key_value',
+ vercelModel: 'anthropic/claude-sonnet-5.5',
+ vercelReasoningEffort: 'high',
+ vercelModelReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
} satisfies Pick<
AgentConfig,
| 'provider'
@@ -29,6 +33,10 @@ const baseConfig = {
| 'anthropicApiKey'
| 'anthropicModel'
| 'anthropicReasoningEffort'
+ | 'vercelApiKey'
+ | 'vercelModel'
+ | 'vercelReasoningEffort'
+ | 'vercelModelReasoningEfforts'
>;
function openAiResponse(overrides: Partial = {}): Response {
diff --git a/dato-agent/src/lib/changeSummary.ts b/dato-agent/src/lib/changeSummary.ts
index 563cb7d7..feed828d 100644
--- a/dato-agent/src/lib/changeSummary.ts
+++ b/dato-agent/src/lib/changeSummary.ts
@@ -9,6 +9,7 @@ import type {
ResponseCreateParamsNonStreaming,
} from 'openai/resources/responses/responses';
import type { AgentConfig } from './config';
+import { createVercelGatewayFetch } from './vercelGateway';
export const MAX_CHANGE_SUMMARY_SOURCE_CHARACTERS = 100_000;
export const MAX_CHANGE_SUMMARY_CHARACTERS = 1_200;
@@ -29,6 +30,10 @@ export type ChangeSummaryConfig = Pick<
| 'anthropicApiKey'
| 'anthropicModel'
| 'anthropicReasoningEffort'
+ | 'vercelApiKey'
+ | 'vercelModel'
+ | 'vercelReasoningEffort'
+ | 'vercelModelReasoningEfforts'
>;
type ChangeSummaryRequestOptions = {
@@ -112,11 +117,22 @@ function defaultOpenAiClient(apiKey: string): ChangeSummaryOpenAiClient {
};
}
-function defaultAnthropicClient(apiKey: string): ChangeSummaryAnthropicClient {
- const client = new Anthropic({
- apiKey,
- dangerouslyAllowBrowser: true,
- });
+function defaultAnthropicClient(
+ apiKey: string,
+ gateway = false,
+): ChangeSummaryAnthropicClient {
+ const client = gateway
+ ? new Anthropic({
+ apiKey: null,
+ authToken: apiKey,
+ baseURL: 'https://ai-gateway.vercel.sh',
+ dangerouslyAllowBrowser: true,
+ fetch: createVercelGatewayFetch(),
+ })
+ : new Anthropic({
+ apiKey,
+ dangerouslyAllowBrowser: true,
+ });
return {
async create(params, options) {
@@ -239,14 +255,8 @@ function validateOptions(options: GenerateChangeSummaryOptions): {
apiKey: string;
model: string;
} {
- const apiKey =
- options.config.provider === 'openai'
- ? options.config.openAiApiKey.trim()
- : options.config.anthropicApiKey.trim();
- const model =
- options.config.provider === 'openai'
- ? options.config.model.trim()
- : options.config.anthropicModel.trim();
+ const apiKey = summaryApiKey(options.config);
+ const model = summaryModel(options.config);
if (!apiKey || !model) {
throw new ChangeSummaryError('invalid_configuration');
}
@@ -285,23 +295,81 @@ async function generateOpenAiSummary(
return openAiSummary(response);
}
+function summaryApiKey(config: ChangeSummaryConfig): string {
+ switch (config.provider) {
+ case 'openai':
+ return config.openAiApiKey.trim();
+ case 'anthropic':
+ return config.anthropicApiKey.trim();
+ case 'vercel':
+ return config.vercelApiKey.trim();
+ default: {
+ const unreachable: never = config.provider;
+ return unreachable;
+ }
+ }
+}
+
+function summaryModel(config: ChangeSummaryConfig): string {
+ switch (config.provider) {
+ case 'openai':
+ return config.model.trim();
+ case 'anthropic':
+ return config.anthropicModel.trim();
+ case 'vercel':
+ return config.vercelModel.trim();
+ default: {
+ const unreachable: never = config.provider;
+ return unreachable;
+ }
+ }
+}
+
+function summaryReasoningOptions(
+ config: ChangeSummaryConfig,
+ model: string,
+): Pick {
+ if (config.provider === 'vercel') {
+ const effort = config.vercelReasoningEffort;
+ const supported = config.vercelModelReasoningEfforts.includes(effort);
+ if (!supported || effort === 'none') {
+ return {};
+ }
+ return {
+ ...(model.startsWith('anthropic/')
+ ? {
+ thinking: {
+ type: 'adaptive' as const,
+ display: 'omitted' as const,
+ },
+ }
+ : {}),
+ output_config: { effort },
+ };
+ }
+
+ return {
+ thinking: { type: 'adaptive', display: 'omitted' },
+ output_config: { effort: config.anthropicReasoningEffort },
+ };
+}
+
async function generateAnthropicSummary(
options: GenerateChangeSummaryOptions,
apiKey: string,
model: string,
signal: AbortSignal,
+ gateway = false,
): Promise {
- const client = options.clients?.anthropic ?? defaultAnthropicClient(apiKey);
+ const client =
+ options.clients?.anthropic ?? defaultAnthropicClient(apiKey, gateway);
const message = await client.create(
{
model,
system: CHANGE_SUMMARY_INSTRUCTIONS,
messages: [{ role: 'user', content: options.script }],
max_tokens: MAX_CHANGE_SUMMARY_OUTPUT_TOKENS,
- thinking: { type: 'adaptive', display: 'omitted' },
- // Anthropic model capabilities do not guarantee support for `low`.
- // Configuration only saves an effort supported by the selected model.
- output_config: { effort: options.config.anthropicReasoningEffort },
+ ...summaryReasoningOptions(options.config, model),
stream: false,
},
{ signal },
@@ -316,13 +384,26 @@ export async function generateChangeSummary(
const scoped = createRequestSignal(options.signal);
try {
- return await callWithSignal(
- (signal) =>
- options.config.provider === 'openai'
- ? generateOpenAiSummary(options, apiKey, model, signal)
- : generateAnthropicSummary(options, apiKey, model, signal),
- scoped,
- );
+ return await callWithSignal((signal) => {
+ switch (options.config.provider) {
+ case 'openai':
+ return generateOpenAiSummary(options, apiKey, model, signal);
+ case 'anthropic':
+ return generateAnthropicSummary(
+ options,
+ apiKey,
+ model,
+ signal,
+ false,
+ );
+ case 'vercel':
+ return generateAnthropicSummary(options, apiKey, model, signal, true);
+ default: {
+ const unreachable: never = options.config.provider;
+ return unreachable;
+ }
+ }
+ }, scoped);
} catch (error) {
if (error instanceof ChangeSummaryError) {
throw error;
diff --git a/dato-agent/src/lib/config.test.ts b/dato-agent/src/lib/config.test.ts
index 4518e232..2061ed89 100644
--- a/dato-agent/src/lib/config.test.ts
+++ b/dato-agent/src/lib/config.test.ts
@@ -63,6 +63,57 @@ describe('normalizeConfig', () => {
expect(normalizeConfig({ provider: 'ChatGPT' }).provider).toBe('openai');
expect(normalizeConfig({ provider: 'unknown' }).provider).toBe('openai');
+ expect(normalizeConfig({ provider: 'AI-Gateway' }).provider).toBe('vercel');
+ });
+
+ it('normalizes Vercel AI Gateway configuration', () => {
+ expect(
+ normalizeConfig({
+ provider: ' vercel ',
+ vercelApiKey: ' vck_project ',
+ vercelModel: ' openai/gpt-6.1-sol ',
+ vercelModelMaxOutputTokens: 128_000,
+ vercelReasoningEffort: 'max',
+ vercelModelReasoningEfforts: ['max', 'none', 'nope', 'low'],
+ vercelFastMode: true,
+ vercelShowAllModels: true,
+ }),
+ ).toMatchObject({
+ provider: 'vercel',
+ vercelApiKey: 'vck_project',
+ vercelModel: 'openai/gpt-6.1-sol',
+ vercelModelMaxOutputTokens: 128_000,
+ vercelReasoningEffort: 'max',
+ vercelModelReasoningEfforts: ['none', 'low', 'max'],
+ vercelFastMode: true,
+ vercelShowAllModels: true,
+ });
+
+ const gateway = normalizeConfig({
+ provider: 'vercel',
+ vercelApiKey: 'vck_old',
+ vercelModel: 'google/gemini-4-argon',
+ vercelModelMaxOutputTokens: 64_000,
+ vercelReasoningEffort: 'low',
+ vercelFastMode: false,
+ });
+ expect(providerLabel(gateway.provider)).toBe('Vercel AI Gateway');
+ expect(activeApiKey(gateway)).toBe('vck_old');
+ expect(activeModel(gateway)).toBe('google/gemini-4-argon');
+ expect(activeModelMaxOutputTokens(gateway)).toBe(64_000);
+ expect(activeReasoningEffort(gateway)).toBe('low');
+ const updated = withActiveModel(
+ withActiveApiKey(gateway, 'vck_new'),
+ 'anthropic/claude-haiku-5.5',
+ 32_000,
+ ['high'],
+ );
+ expect(activeModel(updated)).toBe('anthropic/claude-haiku-5.5');
+ expect(updated.vercelModelReasoningEfforts).toEqual(['high']);
+ expect(updated.vercelApiKey).toBe('vck_new');
+ expect(
+ withActiveReasoningEffort(updated, 'none').vercelReasoningEffort,
+ ).toBe('none');
});
it('falls back when either provider effort is unsupported', () => {
diff --git a/dato-agent/src/lib/config.ts b/dato-agent/src/lib/config.ts
index 2dbf21d9..ee3ed0ac 100644
--- a/dato-agent/src/lib/config.ts
+++ b/dato-agent/src/lib/config.ts
@@ -1,6 +1,8 @@
export const DEFAULT_MODEL = 'gpt-5.6-terra';
-export const AGENT_PROVIDERS = ['openai', 'anthropic'] as const;
+export const DEFAULT_VERCEL_MODEL = 'anthropic/claude-sonnet-5.5';
+
+export const AGENT_PROVIDERS = ['openai', 'anthropic', 'vercel'] as const;
export type AgentProvider = (typeof AGENT_PROVIDERS)[number];
@@ -46,6 +48,30 @@ export type AgentConfig = {
anthropicModelMaxOutputTokens: number | null;
anthropicReasoningEffort: AnthropicReasoningEffort;
anthropicFastMode: boolean;
+ vercelApiKey: string;
+ /**
+ * Gateway model IDs use the `provider/model` form returned by
+ * `https://ai-gateway.vercel.sh/v1/models`.
+ */
+ vercelModel: string;
+ /**
+ * Maximum output-token capability reported by the gateway catalog for the
+ * selected model. Older saved configurations omit it and safely use the
+ * runtime fallback until an administrator next saves discovered metadata.
+ */
+ vercelModelMaxOutputTokens: number | null;
+ vercelReasoningEffort: ReasoningEffort;
+ /**
+ * Effort levels reported by the gateway catalog for `vercelModel`. An empty
+ * list means the selected model does not expose reasoning controls.
+ */
+ vercelModelReasoningEfforts: ReasoningEffort[];
+ vercelFastMode: boolean;
+ /**
+ * When false, the model picker shows only the curated recommended models.
+ * The full tool-capable catalog stays available behind this setting.
+ */
+ vercelShowAllModels: boolean;
additionalInstructions: string;
readOnly: boolean;
enableRecordSidebar: boolean;
@@ -67,6 +93,13 @@ export const DEFAULT_CONFIG: AgentConfig = {
anthropicModelMaxOutputTokens: null,
anthropicReasoningEffort: 'high',
anthropicFastMode: false,
+ vercelApiKey: '',
+ vercelModel: DEFAULT_VERCEL_MODEL,
+ vercelModelMaxOutputTokens: null,
+ vercelReasoningEffort: 'high',
+ vercelModelReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
+ vercelFastMode: false,
+ vercelShowAllModels: false,
additionalInstructions: '',
readOnly: false,
enableRecordSidebar: true,
@@ -87,6 +120,27 @@ function positiveSafeInteger(value: unknown): number | null {
: null;
}
+function isReasoningEffort(value: unknown): value is ReasoningEffort {
+ return REASONING_EFFORTS.some((effort) => effort === value);
+}
+
+function normalizeReasoningEffortList(
+ value: unknown,
+): ReasoningEffort[] | null {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const supported = new Set();
+ for (const entry of value) {
+ if (isReasoningEffort(entry)) {
+ supported.add(entry);
+ }
+ }
+
+ return REASONING_EFFORTS.filter((effort) => supported.has(effort));
+}
+
function normalizeRoleIds(value: unknown): string[] | null {
if (!Array.isArray(value)) {
return null;
@@ -110,6 +164,13 @@ function normalizeProvider(value: unknown): AgentProvider {
if (candidate === 'openai' || candidate === 'chatgpt') {
return 'openai';
}
+ if (
+ candidate === 'vercel' ||
+ candidate === 'ai-gateway' ||
+ candidate === 'vercel-ai-gateway'
+ ) {
+ return 'vercel';
+ }
return DEFAULT_CONFIG.provider;
}
@@ -139,6 +200,27 @@ export function normalizeConfig(parameters: unknown): AgentConfig {
typeof source.anthropicFastMode === 'boolean'
? source.anthropicFastMode
: DEFAULT_CONFIG.anthropicFastMode;
+ const vercelApiKey = trimmedString(source.vercelApiKey);
+ const vercelModel =
+ trimmedString(source.vercelModel) || DEFAULT_CONFIG.vercelModel;
+ const vercelModelMaxOutputTokens = positiveSafeInteger(
+ source.vercelModelMaxOutputTokens,
+ );
+ const vercelReasoningEffort =
+ REASONING_EFFORTS.find(
+ (effort) => effort === source.vercelReasoningEffort,
+ ) ?? DEFAULT_CONFIG.vercelReasoningEffort;
+ const vercelModelReasoningEfforts =
+ normalizeReasoningEffortList(source.vercelModelReasoningEfforts) ??
+ DEFAULT_CONFIG.vercelModelReasoningEfforts;
+ const vercelFastMode =
+ typeof source.vercelFastMode === 'boolean'
+ ? source.vercelFastMode
+ : DEFAULT_CONFIG.vercelFastMode;
+ const vercelShowAllModels =
+ typeof source.vercelShowAllModels === 'boolean'
+ ? source.vercelShowAllModels
+ : DEFAULT_CONFIG.vercelShowAllModels;
const additionalInstructions =
typeof source.additionalInstructions === 'string'
? source.additionalInstructions
@@ -164,6 +246,13 @@ export function normalizeConfig(parameters: unknown): AgentConfig {
anthropicModelMaxOutputTokens,
anthropicReasoningEffort,
anthropicFastMode,
+ vercelApiKey,
+ vercelModel,
+ vercelModelMaxOutputTokens,
+ vercelReasoningEffort,
+ vercelModelReasoningEfforts,
+ vercelFastMode,
+ vercelShowAllModels,
additionalInstructions,
readOnly,
enableRecordSidebar,
@@ -171,12 +260,20 @@ export function normalizeConfig(parameters: unknown): AgentConfig {
};
}
+function assertNeverProvider(provider: never): never {
+ throw new Error(`Unsupported agent provider: ${String(provider)}`);
+}
+
export function providerLabel(provider: AgentProvider): string {
switch (provider) {
case 'openai':
return 'OpenAI (ChatGPT)';
case 'anthropic':
return 'Anthropic (Claude)';
+ case 'vercel':
+ return 'Vercel AI Gateway';
+ default:
+ return assertNeverProvider(provider);
}
}
@@ -186,6 +283,10 @@ export function activeApiKey(config: AgentConfig): string {
return config.openAiApiKey;
case 'anthropic':
return config.anthropicApiKey;
+ case 'vercel':
+ return config.vercelApiKey;
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -195,6 +296,10 @@ export function activeModel(config: AgentConfig): string {
return config.model;
case 'anthropic':
return config.anthropicModel;
+ case 'vercel':
+ return config.vercelModel;
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -206,6 +311,10 @@ export function activeModelMaxOutputTokens(
return undefined;
case 'anthropic':
return config.anthropicModelMaxOutputTokens ?? undefined;
+ case 'vercel':
+ return config.vercelModelMaxOutputTokens ?? undefined;
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -215,6 +324,10 @@ export function activeReasoningEffort(config: AgentConfig): ReasoningEffort {
return config.reasoningEffort;
case 'anthropic':
return config.anthropicReasoningEffort;
+ case 'vercel':
+ return config.vercelReasoningEffort;
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -224,6 +337,10 @@ export function activeFastMode(config: AgentConfig): boolean {
return config.openAiFastMode;
case 'anthropic':
return config.anthropicFastMode;
+ case 'vercel':
+ return config.vercelFastMode;
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -236,6 +353,10 @@ export function withActiveApiKey(
return { ...config, openAiApiKey: apiKey };
case 'anthropic':
return { ...config, anthropicApiKey: apiKey };
+ case 'vercel':
+ return { ...config, vercelApiKey: apiKey };
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -243,6 +364,7 @@ export function withActiveModel(
config: AgentConfig,
model: string,
maxOutputTokens?: number,
+ reasoningEfforts?: readonly ReasoningEffort[],
): AgentConfig {
switch (config.provider) {
case 'openai':
@@ -253,6 +375,20 @@ export function withActiveModel(
anthropicModel: model,
anthropicModelMaxOutputTokens: positiveSafeInteger(maxOutputTokens),
};
+ case 'vercel':
+ return {
+ ...config,
+ vercelModel: model,
+ vercelModelMaxOutputTokens: positiveSafeInteger(maxOutputTokens),
+ ...(reasoningEfforts
+ ? {
+ vercelModelReasoningEfforts:
+ normalizeReasoningEffortList([...reasoningEfforts]) ?? [],
+ }
+ : {}),
+ };
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -268,6 +404,10 @@ export function withActiveReasoningEffort(
return config;
}
return { ...config, anthropicReasoningEffort: effort };
+ case 'vercel':
+ return { ...config, vercelReasoningEffort: effort };
+ default:
+ return assertNeverProvider(config.provider);
}
}
@@ -280,6 +420,10 @@ export function withActiveFastMode(
return { ...config, openAiFastMode: fastMode };
case 'anthropic':
return { ...config, anthropicFastMode: fastMode };
+ case 'vercel':
+ return { ...config, vercelFastMode: fastMode };
+ default:
+ return assertNeverProvider(config.provider);
}
}
diff --git a/dato-agent/src/lib/conversations.ts b/dato-agent/src/lib/conversations.ts
index c534d7a0..41f660bf 100644
--- a/dato-agent/src/lib/conversations.ts
+++ b/dato-agent/src/lib/conversations.ts
@@ -125,7 +125,7 @@ export interface Conversation {
* Provider/model identity for the transient response chain. Visible chat text
* remains portable across providers; opaque provider state never is.
*/
- responseProvider?: 'openai' | 'anthropic';
+ responseProvider?: 'openai' | 'anthropic' | 'vercel';
responseModel?: string;
/**
* Identifies the most recent host-provided CMS context snapshot already
@@ -339,7 +339,9 @@ function normalizeHostContextFingerprint(value: unknown): string | undefined {
function normalizeResponseProvider(
value: unknown,
): Conversation['responseProvider'] {
- return value === 'openai' || value === 'anthropic' ? value : undefined;
+ return value === 'openai' || value === 'anthropic' || value === 'vercel'
+ ? value
+ : undefined;
}
function normalizeResponseModel(value: unknown): string | undefined {
diff --git a/dato-agent/src/lib/providerModels.test.ts b/dato-agent/src/lib/providerModels.test.ts
index beca546d..ce0f37b7 100644
--- a/dato-agent/src/lib/providerModels.test.ts
+++ b/dato-agent/src/lib/providerModels.test.ts
@@ -396,4 +396,136 @@ describe('provider model discovery', () => {
'api.anthropic.com',
);
});
+
+ it('validates an AI Gateway key and keeps recommended tool-capable models first', async () => {
+ const fetchModels = vi.fn(async (input) => {
+ const url = String(input);
+ if (url.endsWith('/v1/credits')) {
+ return jsonResponse({ balance: '12.50', total_used: '1.00' });
+ }
+ return jsonResponse({
+ data: [
+ {
+ id: 'openai/gpt-5-nano',
+ name: 'GPT-5 nano',
+ owned_by: 'openai',
+ type: 'language',
+ tags: ['tool-use', 'reasoning'],
+ max_tokens: 32000,
+ reasoning_options: [{ type: 'effort', values: ['low', 'high'] }],
+ },
+ {
+ id: 'anthropic/claude-sonnet-5.5-fast',
+ name: 'Claude Sonnet 5.5 Fast',
+ owned_by: 'anthropic',
+ type: 'language',
+ tags: ['tool-use', 'fast'],
+ max_tokens: 128000,
+ },
+ {
+ id: 'anthropic/claude-sonnet-5.5',
+ name: 'Claude Sonnet 5.5',
+ owned_by: 'anthropic',
+ type: 'language',
+ tags: ['tool-use', 'reasoning', 'file-input'],
+ max_tokens: 128000,
+ reasoning_options: [
+ {
+ type: 'effort',
+ values: ['low', 'medium', 'high', 'xhigh', 'max', 'minimal'],
+ },
+ ],
+ },
+ {
+ id: 'openai/gpt-5.4-batch',
+ name: 'GPT-5.4 batch',
+ owned_by: 'openai',
+ type: 'language',
+ tags: ['tool-use'],
+ },
+ {
+ id: 'google/gemini-embedding',
+ name: 'Embedding',
+ owned_by: 'google',
+ type: 'embedding',
+ tags: ['tool-use'],
+ },
+ ],
+ });
+ });
+
+ await expect(
+ listProviderModels('vercel', ' vck_secretvalue ', undefined, fetchModels),
+ ).resolves.toEqual([
+ {
+ id: 'anthropic/claude-sonnet-5.5',
+ label: 'Claude Sonnet 5.5 (Anthropic)',
+ maxOutputTokens: 128000,
+ reasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
+ recommended: true,
+ supportsFastMode: true,
+ },
+ {
+ id: 'openai/gpt-5-nano',
+ label: 'GPT-5 nano (OpenAI)',
+ maxOutputTokens: 32000,
+ reasoningEfforts: ['low', 'high'],
+ recommended: false,
+ supportsFastMode: false,
+ },
+ ]);
+ expect(
+ providerModelSupportsFastMode('vercel', 'anthropic/claude-sonnet-5.5', [
+ {
+ id: 'anthropic/claude-sonnet-5.5',
+ supportsFastMode: true,
+ },
+ ]),
+ ).toBe(true);
+ expect(
+ preferredProviderModel('vercel', [
+ {
+ id: 'openai/gpt-5-nano',
+ label: 'GPT-5 nano',
+ reasoningEfforts: ['low'],
+ recommended: false,
+ },
+ {
+ id: 'anthropic/claude-sonnet-5.5',
+ label: 'Claude Sonnet 5.5',
+ reasoningEfforts: ['high'],
+ recommended: true,
+ },
+ ])?.id,
+ ).toBe('anthropic/claude-sonnet-5.5');
+ });
+
+ it('rejects an AI Gateway key with no credits and redacts the key', async () => {
+ const fetchModels = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse(
+ { error: { message: 'Invalid key vck_secretvalue' } },
+ 401,
+ ),
+ );
+
+ await expect(
+ listProviderModels('vercel', 'vck_secretvalue', undefined, fetchModels),
+ ).rejects.toThrow('[redacted]');
+ await expect(
+ listProviderModels('vercel', 'vck_secretvalue', undefined, fetchModels),
+ ).rejects.not.toThrow('vck_secretvalue');
+ });
+
+ it('stops model discovery when the AI Gateway balance is empty', async () => {
+ const fetchModels = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ balance: '0.00', total_used: '4.00' }));
+
+ await expect(
+ listProviderModels('vercel', 'vck_project', undefined, fetchModels),
+ ).rejects.toThrow('no credits left');
+ expect(fetchModels).toHaveBeenCalledOnce();
+ });
});
diff --git a/dato-agent/src/lib/providerModels.ts b/dato-agent/src/lib/providerModels.ts
index 20d4f0f4..8e31a496 100644
--- a/dato-agent/src/lib/providerModels.ts
+++ b/dato-agent/src/lib/providerModels.ts
@@ -1,13 +1,34 @@
import {
type AgentProvider,
DEFAULT_MODEL,
+ DEFAULT_VERCEL_MODEL,
REASONING_EFFORTS,
type ReasoningEffort,
} from './config';
+import {
+ VERCEL_GATEWAY_CREDITS_ENDPOINT,
+ VERCEL_GATEWAY_MODELS_ENDPOINT,
+} from './vercelGateway';
const OPENAI_MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const ANTHROPIC_MODELS_ENDPOINT = 'https://api.anthropic.com/v1/models';
const ANTHROPIC_API_VERSION = '2023-06-01';
+
+/**
+ * Curated models for the Dato Agent use case: strong tool use, reasoning, and
+ * document input, ordered from the default balanced model to alternatives.
+ * IDs missing from the live catalog are omitted rather than shown as broken
+ * choices.
+ */
+export const VERCEL_RECOMMENDED_MODELS = [
+ DEFAULT_VERCEL_MODEL,
+ 'anthropic/claude-opus-5.5',
+ 'anthropic/claude-haiku-5.5',
+ 'openai/gpt-6.1-sol',
+ 'openai/gpt-5.6-terra',
+ 'google/gemini-4-argon',
+ 'spacexai/grok-4.7',
+] as const;
const MODEL_LIST_PAGE_SIZE = '1000';
const MAX_MODEL_LIST_PAGES = 50;
@@ -21,6 +42,15 @@ export type ProviderModel = {
label: string;
maxOutputTokens?: number;
reasoningEfforts: ReasoningEffort[];
+ /**
+ * Set for Vercel AI Gateway models that belong to the curated short list.
+ */
+ recommended?: boolean;
+ /**
+ * Set when the gateway catalog publishes an `${id}-fast` variant. Fast mode
+ * selects that variant instead of a provider-specific speed flag.
+ */
+ supportsFastMode?: boolean;
};
function isRecord(value: unknown): value is Record {
@@ -33,10 +63,9 @@ function redactApiKey(message: string, apiKey: string): string {
? message.split(normalizedKey).join('[redacted]')
: message;
- return withoutKnownKey.replace(
- /\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b/g,
- '[redacted]',
- );
+ return withoutKnownKey
+ .replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
+ .replace(/\bvck_[A-Za-z0-9_-]{8,}\b/g, '[redacted]');
}
function parseErrorMessage(
@@ -116,6 +145,7 @@ function openAiReasoningEfforts(modelId: string): ReasoningEffort[] {
export function providerModelSupportsFastMode(
provider: AgentProvider,
modelId: string,
+ models?: readonly Pick[],
): boolean {
const normalizedModelId = modelId.trim().toLowerCase();
@@ -128,6 +158,24 @@ export function providerModelSupportsFastMode(
return /^(?:claude-opus-5|claude-opus-4-8)(?:-\d{8}|-\d{4}-\d{2}-\d{2})?$/.test(
normalizedModelId,
);
+ case 'vercel': {
+ const selected = models?.find(
+ (model) => model.id.trim().toLowerCase() === normalizedModelId,
+ );
+ if (selected?.supportsFastMode) {
+ return true;
+ }
+ return (
+ models?.some(
+ (model) =>
+ model.id.trim().toLowerCase() === `${normalizedModelId}-fast`,
+ ) ?? false
+ );
+ }
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
}
}
@@ -402,6 +450,227 @@ export async function listAnthropicProviderModels(
}
}
+const VERCEL_OWNER_LABELS: Record = {
+ alibaba: 'Alibaba',
+ anthropic: 'Anthropic',
+ deepseek: 'DeepSeek',
+ google: 'Google',
+ meta: 'Meta',
+ moonshotai: 'Moonshot',
+ openai: 'OpenAI',
+ spacexai: 'SpaceXAI',
+};
+
+function formatGatewayOwner(owner: string): string {
+ const normalized = owner.trim().toLowerCase();
+ if (!normalized) {
+ return '';
+ }
+ return (
+ VERCEL_OWNER_LABELS[normalized] ??
+ `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`
+ );
+}
+
+function isReasoningEffort(value: unknown): value is ReasoningEffort {
+ return REASONING_EFFORTS.some((effort) => effort === value);
+}
+
+function gatewayReasoningEfforts(
+ entry: Record,
+): ReasoningEffort[] {
+ if (!Array.isArray(entry.reasoning_options)) {
+ return [];
+ }
+
+ const supported = new Set();
+ for (const option of entry.reasoning_options) {
+ if (
+ !isRecord(option) ||
+ option.type !== 'effort' ||
+ !Array.isArray(option.values)
+ ) {
+ continue;
+ }
+ for (const value of option.values) {
+ if (isReasoningEffort(value)) {
+ supported.add(value);
+ }
+ }
+ }
+
+ return REASONING_EFFORTS.filter((effort) => supported.has(effort));
+}
+
+function gatewayModelLabel(entry: Record, id: string): string {
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
+ const owner =
+ typeof entry.owned_by === 'string' && entry.owned_by.trim()
+ ? entry.owned_by
+ : (id.split('/')[0] ?? '');
+ const ownerLabel = formatGatewayOwner(owner);
+ if (name && ownerLabel) {
+ return `${name} (${ownerLabel})`;
+ }
+ return name || id;
+}
+
+function isSelectableGatewayModel(entry: unknown): entry is Record<
+ string,
+ unknown
+> & {
+ id: string;
+} {
+ if (
+ !isRecord(entry) ||
+ entry.type !== 'language' ||
+ typeof entry.id !== 'string'
+ ) {
+ return false;
+ }
+ const id = entry.id.trim().toLowerCase();
+ if (!id || id.endsWith('-batch') || id.endsWith('-fast')) {
+ return false;
+ }
+ return Array.isArray(entry.tags) && entry.tags.includes('tool-use');
+}
+
+function recommendedModelRank(modelId: string): number {
+ const index = VERCEL_RECOMMENDED_MODELS.indexOf(
+ modelId as (typeof VERCEL_RECOMMENDED_MODELS)[number],
+ );
+ return index === -1 ? VERCEL_RECOMMENDED_MODELS.length : index;
+}
+
+function extractVercelGatewayModels(payload: unknown): ProviderModel[] {
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
+ throw new Error('Vercel AI Gateway returned an invalid model list.');
+ }
+
+ const entries = payload.data.filter(isSelectableGatewayModel);
+ const fastVariantIds = new Set(
+ payload.data.flatMap((entry) => {
+ if (!isRecord(entry) || typeof entry.id !== 'string') {
+ return [];
+ }
+ const id = entry.id.trim().toLowerCase();
+ return id.endsWith('-fast') ? [id] : [];
+ }),
+ );
+
+ return entries
+ .map((entry) => {
+ const id = entry.id.trim();
+ const maxOutputTokens = positiveSafeInteger(entry.max_tokens);
+ const recommended = VERCEL_RECOMMENDED_MODELS.some(
+ (modelId) => modelId === id,
+ );
+ return {
+ id,
+ label: gatewayModelLabel(entry, id),
+ ...(maxOutputTokens ? { maxOutputTokens } : {}),
+ reasoningEfforts: gatewayReasoningEfforts(entry),
+ recommended,
+ supportsFastMode: fastVariantIds.has(`${id.toLowerCase()}-fast`),
+ };
+ })
+ .sort(
+ (left, right) =>
+ recommendedModelRank(left.id) - recommendedModelRank(right.id) ||
+ left.label.localeCompare(right.label) ||
+ left.id.localeCompare(right.id),
+ );
+}
+
+function gatewayErrorMessage(
+ payload: unknown,
+ apiKey: string,
+): string | undefined {
+ const providerMessage = parseErrorMessage(payload, apiKey);
+ if (providerMessage) {
+ return providerMessage;
+ }
+ if (!isRecord(payload)) {
+ return undefined;
+ }
+ if (typeof payload.message === 'string' && payload.message.trim()) {
+ return redactApiKey(payload.message, apiKey);
+ }
+ if (typeof payload.error === 'string' && payload.error.trim()) {
+ return redactApiKey(payload.error, apiKey);
+ }
+ return undefined;
+}
+
+async function assertVercelGatewayCredits(
+ apiKey: string,
+ signal: AbortSignal | undefined,
+ fetchModels: FetchModels,
+): Promise {
+ const response = await fetchModels(VERCEL_GATEWAY_CREDITS_ENDPOINT, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ signal,
+ });
+ const payload = await readJsonResponse(response, 'Vercel AI Gateway');
+
+ if (response.status === 401 || response.status === 403) {
+ throw new Error(
+ gatewayErrorMessage(payload, apiKey) ??
+ 'Vercel AI Gateway rejected the API key. Create one under Vercel → AI Gateway → API keys.',
+ );
+ }
+ if (!response.ok) {
+ throw new Error(
+ gatewayErrorMessage(payload, apiKey) ??
+ `Vercel AI Gateway credit check failed with status ${response.status}.`,
+ );
+ }
+ if (!isRecord(payload)) {
+ throw new Error('Vercel AI Gateway returned an invalid credit balance.');
+ }
+
+ const balance = Number(payload.balance);
+ if (Number.isFinite(balance) && balance <= 0) {
+ throw new Error(
+ 'This AI Gateway key has no credits left. Add credits, or configure bring-your-own provider keys, in the Vercel dashboard.',
+ );
+ }
+}
+
+export async function listVercelGatewayModels(
+ apiKey: string,
+ signal?: AbortSignal,
+ fetchModels: FetchModels = fetch,
+): Promise {
+ const normalizedKey = apiKey.trim();
+ if (!normalizedKey) {
+ throw new Error('Enter an AI Gateway API key to load models.');
+ }
+
+ try {
+ await assertVercelGatewayCredits(normalizedKey, signal, fetchModels);
+ const response = await fetchModels(VERCEL_GATEWAY_MODELS_ENDPOINT, {
+ headers: { Authorization: `Bearer ${normalizedKey}` },
+ signal,
+ });
+ const payload = await readJsonResponse(response, 'Vercel AI Gateway');
+ if (!response.ok) {
+ throw new Error(
+ gatewayErrorMessage(payload, normalizedKey) ??
+ `Vercel AI Gateway model discovery failed with status ${response.status}.`,
+ );
+ }
+ return extractVercelGatewayModels(payload);
+ } catch (error) {
+ if (isBrowserNetworkError(error)) {
+ throw new Error(
+ 'Vercel AI Gateway could not be reached from this browser.',
+ );
+ }
+ throw error;
+ }
+}
+
export async function listProviderModels(
provider: AgentProvider,
apiKey: string,
@@ -413,6 +682,12 @@ export async function listProviderModels(
return listOpenAiProviderModels(apiKey, signal, fetchModels);
case 'anthropic':
return listAnthropicProviderModels(apiKey, signal, fetchModels);
+ case 'vercel':
+ return listVercelGatewayModels(apiKey, signal, fetchModels);
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
}
}
@@ -420,12 +695,23 @@ export function preferredProviderModel(
provider: AgentProvider,
models: readonly ProviderModel[],
): ProviderModel | undefined {
- if (provider === 'anthropic') {
- return (
- models.find((model) => model.id.toLowerCase().includes('sonnet')) ??
- models[0]
- );
+ switch (provider) {
+ case 'anthropic':
+ return (
+ models.find((model) => model.id.toLowerCase().includes('sonnet')) ??
+ models[0]
+ );
+ case 'vercel':
+ return (
+ models.find((model) => model.id === DEFAULT_VERCEL_MODEL) ??
+ models.find((model) => model.recommended) ??
+ models[0]
+ );
+ case 'openai':
+ return models.find((model) => model.id === DEFAULT_MODEL) ?? models[0];
+ default: {
+ const unreachable: never = provider;
+ return unreachable;
+ }
}
-
- return models.find((model) => model.id === DEFAULT_MODEL) ?? models[0];
}
diff --git a/dato-agent/src/lib/unsafeDispatchJournal.ts b/dato-agent/src/lib/unsafeDispatchJournal.ts
index 38c1996d..5b60dd4f 100644
--- a/dato-agent/src/lib/unsafeDispatchJournal.ts
+++ b/dato-agent/src/lib/unsafeDispatchJournal.ts
@@ -41,7 +41,7 @@ export interface UnsafeDispatchJournal {
userEntryId: string;
assistantEntryId: string;
userMessage: string;
- provider: 'openai' | 'anthropic';
+ provider: 'openai' | 'anthropic' | 'vercel';
model: string;
startedAt: string;
};
@@ -307,7 +307,9 @@ function normalizeJournal(value: unknown): UnsafeDispatchJournal {
const conversation = candidate.conversation as Record;
const turn = candidate.turn as Record;
const provider =
- turn.provider === 'openai' || turn.provider === 'anthropic'
+ turn.provider === 'openai' ||
+ turn.provider === 'anthropic' ||
+ turn.provider === 'vercel'
? turn.provider
: undefined;
if (!provider) {
diff --git a/dato-agent/src/lib/vercelGateway.test.ts b/dato-agent/src/lib/vercelGateway.test.ts
new file mode 100644
index 00000000..826fb8ae
--- /dev/null
+++ b/dato-agent/src/lib/vercelGateway.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createVercelGatewayFetch } from './vercelGateway';
+
+describe('createVercelGatewayFetch', () => {
+ it('removes browser SDK headers the gateway preflight rejects', async () => {
+ const fetchModels = vi.fn(
+ async () => new Response('{}', { status: 200 }),
+ );
+ const gatewayFetch = createVercelGatewayFetch(fetchModels);
+
+ await gatewayFetch('https://ai-gateway.vercel.sh/v1/messages', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer vck_example',
+ 'anthropic-version': '2023-06-01',
+ 'anthropic-dangerous-direct-browser-access': 'true',
+ 'x-stainless-lang': 'js',
+ 'X-Stainless-Package-Version': '0.0.0',
+ },
+ });
+
+ const headers = new Headers(fetchModels.mock.calls[0]?.[1]?.headers);
+ expect(headers.get('authorization')).toBe('Bearer vck_example');
+ expect(headers.get('anthropic-version')).toBe('2023-06-01');
+ expect(headers.get('anthropic-dangerous-direct-browser-access')).toBeNull();
+ expect(headers.get('x-stainless-lang')).toBeNull();
+ expect(headers.get('x-stainless-package-version')).toBeNull();
+ });
+});
diff --git a/dato-agent/src/lib/vercelGateway.ts b/dato-agent/src/lib/vercelGateway.ts
new file mode 100644
index 00000000..20c79d24
--- /dev/null
+++ b/dato-agent/src/lib/vercelGateway.ts
@@ -0,0 +1,26 @@
+export const VERCEL_GATEWAY_ORIGIN = 'https://ai-gateway.vercel.sh';
+export const VERCEL_GATEWAY_MODELS_ENDPOINT = `${VERCEL_GATEWAY_ORIGIN}/v1/models`;
+export const VERCEL_GATEWAY_CREDITS_ENDPOINT = `${VERCEL_GATEWAY_ORIGIN}/v1/credits`;
+
+const BLOCKED_GATEWAY_HEADER =
+ /^(?:x-stainless-.+|anthropic-dangerous-direct-browser-access)$/i;
+
+/**
+ * The gateway's CORS allow-list accepts Authorization and Anthropic's own
+ * headers, but not the browser SDK's `x-stainless-*` or
+ * `anthropic-dangerous-direct-browser-access` headers. Those fail the
+ * preflight before a request can run inside the DatoCMS plugin iframe.
+ */
+export function createVercelGatewayFetch(
+ fetchModels: typeof fetch = fetch,
+): typeof fetch {
+ return (input, init) => {
+ const headers = new Headers(init?.headers);
+ for (const name of [...headers.keys()]) {
+ if (BLOCKED_GATEWAY_HEADER.test(name)) {
+ headers.delete(name);
+ }
+ }
+ return fetchModels(input, { ...init, headers });
+ };
+}