feat(core): Improve Instance AI model experiments (no-changelog) (#35034)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir 2026-08-13 10:15:03 +00:00 committed by GitHub
parent 408845c4f6
commit 91ebeecb4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 1942 additions and 235 deletions

View File

@ -209,7 +209,7 @@ the lab bench.** The gate deliberately exposes only PR re-runs. Anything that
isn't PR gating — baselines, model experiments, arbitrary branch runs — goes
through `test-evals-instance-ai.yml`'s own dispatch form ("Instance AI
Evals: Experiments"): full knob set (branch, filter, tier, suite,
iterations, experiment-name, model), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
iterations, experiment-name, model, model-url, model-key, reasoning-effort, supports-structured-outputs), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
model-comparison arms), and SHA-keyed docker cache hits on master. Evals never
run on fork PRs: the event trigger gates on `head.repo.fork`, and the `pr`
re-run path refuses fork PRs in `resolve` (dispatched runs carry secrets).
@ -763,7 +763,7 @@ Adding a new channel requires inviting the bot first; the first run otherwise fa
| Cloud/CDN | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
| GitHub Automation | `N8N_ASSISTANT_APP_ID`, `N8N_ASSISTANT_PRIVATE_KEY` |
| Benchmarking | `BENCHMARK_ARM_*`, `N8N_BENCHMARK_LICENSE_CERT` |
| AI/Evals | `ANTHROPIC_API_KEY`, `EVALS_LANGSMITH_*` |
| AI/Evals | `EVALS_ANTHROPIC_KEY`, `EVALS_OPENAI_KEY`, `EVALS_OPENROUTER_KEY`, `EVALS_XAI_KEY`, `EVALS_BASETEN_KEY`, `EVALS_FIREWORKS_KEY`, `EVALS_TOGETHER_KEY`, `EVALS_DATABRICKS_KEY`, `EVALS_MODAL_KEY`, `EVALS_LYCEUM_KEY`, `EVALS_AZURE_FOUNDRY_KEY`, `EVALS_VERTEX_KEY`, `EVALS_VERTEX_PROJECT_ID`, `EVALS_VERTEX_LOCATION`, `EVALS_LANGSMITH_*` |
### Scoping

View File

@ -60,7 +60,37 @@ on:
type: string
default: ''
model:
description: 'Model override for all Instance AI agents in the lane backends, provider/model format. Empty = backend default.'
description: 'Model override (provider/model). Empty = anthropic/claude-opus-4-8. Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments use custom/<model> + model-url + model-key. See evaluations/README.md.'
required: false
type: string
default: ''
model-url:
description: 'Optional N8N_INSTANCE_AI_MODEL_URL (required for custom/* OpenAI-compatible /v1; also anthropic/* Azure Foundry). See evaluations/README.md.'
required: false
type: string
default: ''
model-key:
description: 'Which EVALS_* secret to use for custom/* (or anthropic/* + model-url). One of: baseten, fireworks, together, modal, databricks, azure, lyceum. Empty = keyless custom, or native openai/openrouter/xai/anthropic prefix routing.'
required: false
type: string
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
type: string
default: ''
eval-concurrency:
description: 'Concurrent scenario executions. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
type: string
default: ''
@ -95,7 +125,31 @@ on:
required: false
default: ''
model:
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). For experiments: anthropic/claude-sonnet-4-6, anthropic/claude-sonnet-5.'
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments: custom/<model> + model-url + model-key.'
required: false
default: ''
model-url:
description: 'Base URL (N8N_INSTANCE_AI_MODEL_URL). Required for custom/*; optional for anthropic/* Azure Foundry.'
required: false
default: ''
model-key:
description: 'EVALS_* secret alias for custom/*: baseten | fireworks | together | modal | databricks | azure | lyceum. Empty = keyless custom or native provider prefix.'
required: false
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
default: ''
eval-concurrency:
description: 'Concurrent scenarios. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
default: ''
@ -105,16 +159,19 @@ jobs:
# 8vcpu/32GB: full-suite N=10 baselines need ~150 min of eval time and
# did not fit the 4vcpu tier at any timeout.
runs-on: blacksmith-8vcpu-ubuntu-2204
# Long ceiling only for high-N baseline captures; regular runs keep a
# tight guard so a wedged run fails fast. Plain comparison (not fromJSON):
# it coerces the string input to a number, and a malformed dispatch value
# (NaN) falls back to the 90-minute guard instead of erroring the job.
timeout-minutes: ${{ (inputs.iterations || '3') >= 5 && 240 || 90 }}
# Long ceiling for high-N baselines (240) and for Baseten (600: auto
# 1 lane / c=2 is ~16× slower than the Anthropic default, so a ~18 min
# full suite becomes ~5 h; +2 h buffer for provider backoff). Plain
# comparison (not fromJSON): coerces the string input to a number;
# malformed NaN falls back to the 90-minute guard instead of erroring.
timeout-minutes: ${{ (inputs['model-key'] || '') == 'baseten' && 600 || ((inputs.iterations || '3') >= 5 && 240 || 90) }}
env:
# Each port hosts an independent n8n container. The eval CLI's
# work-stealing allocator dispatches builds across them, capped per-lane.
# 10 lanes x 2.5 GB caps = 25 GB, leaving ~7 GB for sandbox + CLI + OS.
LANE_PORTS: '5678,5679,5680,5681,5682,5683,5684,5685,5686,5687'
# Contiguous ports from 5678; 5678..5688 avoids Node fetch()'s blocked-
# port list (max 11 lanes). Defaults: 10 lanes / c=32 for Anthropic;
# model-key=baseten auto-throttles to 1 / 2 to stay under Baseten Basic
# verified (~500k TPM).
LANES: ${{ inputs.lanes != '' && inputs.lanes || ((inputs['model-key'] || '') == 'baseten' && '1' || '10') }}
EVAL_CONCURRENCY: ${{ inputs.eval-concurrency != '' && inputs.eval-concurrency || ((inputs['model-key'] || '') == 'baseten' && '2' || '32') }}
permissions:
contents: read
pull-requests: write
@ -174,7 +231,20 @@ jobs:
- name: Start n8n containers
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
INSTANCE_AI_BRAVE_SEARCH_API_KEY: ${{ secrets.INSTANCE_AI_BRAVE_SEARCH_API_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
EVALS_VERTEX_PROJECT_ID: ${{ secrets.EVALS_VERTEX_PROJECT_ID }}
EVALS_VERTEX_LOCATION: ${{ secrets.EVALS_VERTEX_LOCATION }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
@ -187,6 +257,10 @@ jobs:
SANDBOX_NAME_PREFIX: evals-ci-${{ github.head_ref || inputs.branch || github.ref_name }}
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
INSTANCE_AI_MODEL_URL: ${{ inputs.model-url }}
INSTANCE_AI_MODEL_KEY: ${{ inputs.model-key }}
INSTANCE_AI_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS: ${{ inputs.supports-structured-outputs }}
run: |
# Build provider-specific env args
SANDBOX_ARGS=()
@ -216,8 +290,112 @@ jobs:
if [ -n "$INSTANCE_AI_MODEL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL="$INSTANCE_AI_MODEL")
fi
# Anthropic-compatible custom base (Azure Foundry Claude). Drop /v1/messages
# if pasted from the Foundry Details "Endpoint" field — the Anthropic SDK
# appends /v1/messages itself.
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL_URL="$INSTANCE_AI_MODEL_URL")
fi
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
# custom/* experiment knobs: pass through workflow inputs only.
# When unset, the n8n runtime looks up custom-model-defaults.ts and
# omits the field if still unresolved — do not re-implement that map here.
if [ -n "$INSTANCE_AI_REASONING_EFFORT" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_REASONING_EFFORT="$INSTANCE_AI_REASONING_EFFORT")
echo "custom reasoning effort override: $INSTANCE_AI_REASONING_EFFORT"
fi
if [ -n "$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS="$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS")
echo "custom supportsStructuredOutputs override: $INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS"
fi
# EvalMock / pin-data / in-product eval LLMs always use Anthropic Sonnet,
# independent of the builder experiment model (custom/Kimi, openai/*, …).
# ANTHROPIC_API_KEY is the provider-native key resolveEvalModelConfig prefers
# when N8N_INSTANCE_AI_EVAL_MODEL differs from N8N_INSTANCE_AI_MODEL.
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_EVAL_MODEL=anthropic/claude-sonnet-4-6
-e ANTHROPIC_API_KEY="$EVALS_ANTHROPIC_KEY"
)
# Lane builds use the provider matching N8N_INSTANCE_AI_MODEL.
# The eval CLI step keeps EVALS_ANTHROPIC_KEY for Sonnet verifier/mocks.
MODEL_API_KEY="$EVALS_ANTHROPIC_KEY"
MODEL_PROVIDER="${INSTANCE_AI_MODEL%%/*}"
require_secret() {
local name="$1" value="$2"
if [ -z "$value" ]; then
echo "::error::${name} secret is empty (model=${INSTANCE_AI_MODEL:-default} model-key=${INSTANCE_AI_MODEL_KEY:-})"
exit 1
fi
}
resolve_model_key() {
case "$INSTANCE_AI_MODEL_KEY" in
baseten) require_secret EVALS_BASETEN_KEY "$EVALS_BASETEN_KEY"; MODEL_API_KEY="$EVALS_BASETEN_KEY" ;;
fireworks) require_secret EVALS_FIREWORKS_KEY "$EVALS_FIREWORKS_KEY"; MODEL_API_KEY="$EVALS_FIREWORKS_KEY" ;;
together) require_secret EVALS_TOGETHER_KEY "$EVALS_TOGETHER_KEY"; MODEL_API_KEY="$EVALS_TOGETHER_KEY" ;;
modal) require_secret EVALS_MODAL_KEY "$EVALS_MODAL_KEY"; MODEL_API_KEY="$EVALS_MODAL_KEY" ;;
databricks) require_secret EVALS_DATABRICKS_KEY "$EVALS_DATABRICKS_KEY"; MODEL_API_KEY="$EVALS_DATABRICKS_KEY" ;;
azure) require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"; MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY" ;;
lyceum) require_secret EVALS_LYCEUM_KEY "$EVALS_LYCEUM_KEY"; MODEL_API_KEY="$EVALS_LYCEUM_KEY" ;;
'') ;;
*)
echo "::error::unknown model-key '${INSTANCE_AI_MODEL_KEY}' (expected baseten|fireworks|together|modal|databricks|azure|lyceum)"
exit 1
;;
esac
}
if [[ "$INSTANCE_AI_MODEL" == custom/* ]]; then
if [ -z "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::model custom/* requires model-url (OpenAI-compatible base URL ending in /v1)"
exit 1
fi
# model-key selects EVALS_*; empty model-key = keyless custom router.
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
MODEL_API_KEY=""
fi
elif [ "$MODEL_PROVIDER" = "google-vertex-anthropic" ]; then
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::google-vertex-anthropic/* does not use model-url (leave it empty)"
exit 1
fi
require_secret EVALS_VERTEX_KEY "$EVALS_VERTEX_KEY"
require_secret EVALS_VERTEX_PROJECT_ID "$EVALS_VERTEX_PROJECT_ID"
MODEL_API_KEY=""
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_VERTEX_PROJECT_ID="$EVALS_VERTEX_PROJECT_ID"
-e N8N_INSTANCE_AI_VERTEX_LOCATION="${EVALS_VERTEX_LOCATION:-global}"
-e N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON="$EVALS_VERTEX_KEY"
)
elif [ -n "$INSTANCE_AI_MODEL_URL" ]; then
# anthropic/* + model-url = Azure Foundry Claude (or model-key=azure).
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"
MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY"
fi
else
case "$MODEL_PROVIDER" in
openai) require_secret EVALS_OPENAI_KEY "$EVALS_OPENAI_KEY"; MODEL_API_KEY="$EVALS_OPENAI_KEY" ;;
openrouter) require_secret EVALS_OPENROUTER_KEY "$EVALS_OPENROUTER_KEY"; MODEL_API_KEY="$EVALS_OPENROUTER_KEY" ;;
xai) require_secret EVALS_XAI_KEY "$EVALS_XAI_KEY"; MODEL_API_KEY="$EVALS_XAI_KEY" ;;
esac
fi
if [ "$LANES" -lt 1 ] || [ "$LANES" -gt 11 ]; then
echo "::error::lanes must be 1-11 (got $LANES)"
exit 1
fi
PORTS=()
for i in $(seq 0 $((LANES - 1))); do
PORTS+=($((5678 + i)))
done
# Reuse across later steps (Create test users / Assert / Run Evals).
echo "LANE_PORTS=${PORTS[*]}" >> "$GITHUB_ENV"
echo "Starting $LANES lane(s) on ports ${PORTS[*]} (eval concurrency $EVAL_CONCURRENCY)"
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
# Bounded and self-healing: a lane that exhausts its capped heap is
@ -242,8 +420,8 @@ jobs:
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e INSTANCE_AI_BRAVE_SEARCH_API_KEY="$INSTANCE_AI_BRAVE_SEARCH_API_KEY" \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$MODEL_API_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \
-e N8N_INSTANCE_AI_SANDBOX_ENABLED=true \
"${SANDBOX_ARGS[@]}" \
@ -283,7 +461,7 @@ jobs:
- name: Create test users
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
for port in "${PORTS[@]}"; do
curl -sf -X POST "http://localhost:$port/rest/e2e/reset" \
-H "Content-Type: application/json" \
@ -304,7 +482,7 @@ jobs:
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
bad=0
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
@ -323,13 +501,15 @@ jobs:
echo " lane $lane: sandboxEnabled=true sandboxProvider=$SANDBOX_PROVIDER ok"
fi
# /preferences returns the effective model name (user pref || config),
# i.e. the name part of provider/model.
# i.e. everything after the first provider/ segment (openrouter/moonshotai/kimi-k3
# → moonshotai/kimi-k3). Use #*/ not ##*/ so nested OpenRouter ids match.
if [ -n "$INSTANCE_AI_MODEL" ]; then
expected_model="${INSTANCE_AI_MODEL#*/}"
effective=$(curl -sf -b "/tmp/cookies-$port.txt" \
"http://localhost:$port/rest/instance-ai/preferences" \
| jq -r '.data.modelName')
if [ "$effective" != "${INSTANCE_AI_MODEL##*/}" ]; then
echo "::error::lane $lane (port $port): expected model '${INSTANCE_AI_MODEL##*/}', got '$effective'"
if [ "$effective" != "$expected_model" ]; then
echo "::error::lane $lane (port $port): expected model '$expected_model', got '$effective'"
bad=$((bad+1))
else
echo " lane $lane: model=$effective ok"
@ -364,7 +544,10 @@ jobs:
continue-on-error: true
working-directory: packages/@n8n/instance-ai
env:
# Host-side verifier/judges always use Anthropic — never the builder experiment model.
N8N_INSTANCE_AI_EVAL_MODEL: anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_MODEL_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
LANGSMITH_TRACING: 'true'
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
@ -383,13 +566,14 @@ jobs:
EXPERIMENT_NAME: ${{ inputs.experiment-name }}
EVAL_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
URLS=()
for port in "${PORTS[@]}"; do
URLS+=("http://localhost:$port")
done
BASE_URLS=$(IFS=,; printf '%s' "${URLS[*]}")
ARGS=(--base-url "$BASE_URLS" --concurrency 32 --verbose --iterations "${ITERATIONS:-3}")
echo "Lanes: $LANES | eval concurrency: $EVAL_CONCURRENCY"
ARGS=(--base-url "$BASE_URLS" --concurrency "$EVAL_CONCURRENCY" --verbose --iterations "${ITERATIONS:-3}")
# LangTracer is the only CI case source (no disk fallback by design).
ARGS+=(--source langtracer --suite "${SUITE:-baseline}")
# Pin the LangSmith cohort: langtracer mode otherwise derives a
@ -420,6 +604,17 @@ jobs:
if: ${{ always() }}
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
@ -430,9 +625,13 @@ jobs:
# ::add-mask:: is a single-line workflow command. Multi-line secrets
# (e.g. N8N_LICENSE_CERT is PEM-encoded) must be masked one line at
# a time, otherwise only the first line is registered.
for v in "$EVALS_ANTHROPIC_KEY" "$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" \
"$N8N_ENCRYPTION_KEY" "$EVALS_LANGSMITH_API_KEY"; do
for v in "$EVALS_ANTHROPIC_KEY" "$EVALS_OPENAI_KEY" "$EVALS_OPENROUTER_KEY" \
"$EVALS_XAI_KEY" "$EVALS_BASETEN_KEY" "$EVALS_FIREWORKS_KEY" \
"$EVALS_TOGETHER_KEY" "$EVALS_DATABRICKS_KEY" "$EVALS_MODAL_KEY" \
"$EVALS_LYCEUM_KEY" "$EVALS_AZURE_FOUNDRY_KEY" "$EVALS_VERTEX_KEY" \
"$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" "$N8N_ENCRYPTION_KEY" \
"$EVALS_LANGSMITH_API_KEY"; do
[ -z "$v" ] && continue
while IFS= read -r line; do
[ -n "$line" ] && echo "::add-mask::$line"

View File

@ -98,6 +98,7 @@
"@ai-sdk/deepseek": "catalog:",
"@ai-sdk/gateway": "catalog:",
"@ai-sdk/google": "catalog:",
"@ai-sdk/google-vertex": "catalog:",
"@ai-sdk/groq": "catalog:",
"@ai-sdk/mistral": "catalog:",
"@ai-sdk/openai": "catalog:",

View File

@ -25,7 +25,9 @@ export type {
Provider,
ThinkingConfig,
ThinkingConfigFor,
AnthropicThinkingEffort,
AnthropicThinkingConfig,
OpenAIReasoningEffort,
OpenAIThinkingConfig,
GoogleThinkingConfig,
XaiThinkingConfig,

View File

@ -7,6 +7,8 @@ type ProviderOpts = {
baseURL?: string;
fetch?: typeof globalThis.fetch;
headers?: Record<string, string>;
includeUsage?: boolean;
supportsStructuredOutputs?: boolean;
};
// All providers are mocked via vi.mock so require() inside the registry entries
@ -162,6 +164,8 @@ vi.mock('@ai-sdk/openai-compatible', () => ({
baseURL: opts.baseURL,
headers: opts.headers,
fetch: opts.fetch,
includeUsage: opts.includeUsage,
supportsStructuredOutputs: opts.supportsStructuredOutputs,
specificationVersion: 'v3',
}),
}));
@ -184,6 +188,25 @@ vi.mock('@ai-sdk/amazon-bedrock', () => ({
}),
}));
vi.mock('@ai-sdk/google-vertex/anthropic', () => ({
createVertexAnthropic:
(opts?: {
project?: string;
location?: string;
googleAuthOptions?: { credentials?: Record<string, unknown> };
fetch?: typeof globalThis.fetch;
}) =>
(model: string) => ({
provider: 'google-vertex-anthropic',
modelId: model,
project: opts?.project,
location: opts?.location,
googleAuthOptions: opts?.googleAuthOptions,
fetch: opts?.fetch,
specificationVersion: 'v3',
}),
}));
const { mockProxyAgent } = vi.hoisted(() => ({ mockProxyAgent: vi.fn() }));
vi.mock('undici', () => ({
ProxyAgent: mockProxyAgent,
@ -204,9 +227,9 @@ describe('createModel', () => {
});
it('should accept a string config', () => {
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
const model = createModel('anthropic/claude-opus-5') as unknown as Record<string, unknown>;
expect(model.provider).toBe('anthropic');
expect(model.modelId).toBe('claude-sonnet-4-5');
expect(model.modelId).toBe('claude-opus-5');
});
it('should accept an object config with baseURL', () => {
@ -360,7 +383,7 @@ describe('createModel', () => {
expect(model.apiKey).toBe('or-test');
});
it('should create model for nvidia', () => {
it('should create model for nvidia with SDK defaults for usage and structured outputs', () => {
const model = createModel({
id: 'nvidia/nvidia/llama-3.3-nemotron-super-49b-v1',
apiKey: 'nv-test',
@ -370,6 +393,28 @@ describe('createModel', () => {
expect(model.modelId).toBe('nvidia/llama-3.3-nemotron-super-49b-v1');
expect(model.apiKey).toBe('nv-test');
expect(model.baseURL).toBe('https://integrate.api.nvidia.com/v1');
expect(model.includeUsage).toBeUndefined();
expect(model.supportsStructuredOutputs).toBeUndefined();
});
it('should have undefined supportsStructuredOutputs for custom when unset', () => {
const model = createModel({
id: 'custom/Kimi-K3',
apiKey: 'key',
baseURL: 'https://example.com/v1',
}) as unknown as Record<string, unknown>;
expect(model.provider).toBe('custom');
expect(model.supportsStructuredOutputs).toBe(undefined);
});
it('should forward supportsStructuredOutputs for custom when set', () => {
const model = createModel({
id: 'custom/Kimi-K3',
apiKey: 'key',
baseURL: 'https://example.com/v1',
supportsStructuredOutputs: true,
}) as unknown as Record<string, unknown>;
expect(model.supportsStructuredOutputs).toBe(true);
});
});
@ -467,6 +512,58 @@ describe('createModel', () => {
});
});
describe('google-vertex-anthropic', () => {
it('should create model with project, location, and service-account JSON', () => {
const model = createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
location: 'global',
googleCredentials: JSON.stringify({
client_email: 'svc@my-project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\\nABC\\n-----END PRIVATE KEY-----\\n',
}),
}) as unknown as Record<string, unknown>;
expect(model.provider).toBe('google-vertex-anthropic');
expect(model.modelId).toBe('claude-opus-4-8');
expect(model.project).toBe('my-project');
expect(model.location).toBe('global');
expect(model.googleAuthOptions).toEqual({
credentials: {
client_email: 'svc@my-project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n',
},
});
});
it('should default location to global when omitted', () => {
const model = createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
}) as unknown as Record<string, unknown>;
expect(model.location).toBe('global');
expect(model.googleAuthOptions).toBeUndefined();
});
it('should throw if project is missing', () => {
expect(() =>
createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
location: 'global',
}),
).toThrow(/Invalid credentials for provider "google-vertex-anthropic"/);
});
it('should throw if googleCredentials is not valid JSON', () => {
expect(() =>
createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
googleCredentials: 'not-json',
}),
).toThrow(/googleCredentials must be valid JSON/);
});
});
describe('unsupported provider', () => {
it('should throw for ollama', () => {
expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/);

View File

@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import { applyToolProviderOptionDefaults, getProviderQuirks } from '../model/provider-quirks';
import {
HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS,
applyToolProviderOptionDefaults,
getProviderQuirks,
resolveDefaultMaxOutputTokens,
} from '../model/provider-quirks';
describe('getProviderQuirks', () => {
it('returns an empty object for an unknown provider', () => {
@ -93,12 +98,80 @@ describe('thinkingToProviderOptions', () => {
});
});
it('anthropic: enabled mode with effort keeps default budgetTokens', () => {
expect(
getProviderQuirks('anthropic').thinkingToProviderOptions?.(
{ mode: 'enabled', effort: 'medium' },
'anthropic/claude-sonnet-4-5',
),
).toEqual({
anthropic: {
thinking: { type: 'enabled', budgetTokens: 10000 },
effort: 'medium',
},
});
});
it('anthropic: enabled mode with effort forwards explicit budgetTokens', () => {
expect(
getProviderQuirks('anthropic').thinkingToProviderOptions?.(
{ mode: 'enabled', effort: 'medium', budgetTokens: 5000 },
'anthropic/claude-sonnet-4-5',
),
).toEqual({
anthropic: {
thinking: { type: 'enabled', budgetTokens: 5000 },
effort: 'medium',
},
});
});
it('google-vertex-anthropic: emits Anthropic adaptive thinking under the anthropic namespace', () => {
expect(
getProviderQuirks('google-vertex-anthropic').thinkingToProviderOptions?.(
{ mode: 'adaptive', effort: 'medium' },
'google-vertex-anthropic/claude-opus-4-8',
),
).toEqual({
anthropic: {
thinking: { type: 'adaptive', display: 'summarized' },
effort: 'medium',
},
});
});
it('google-vertex-anthropic: tool defaults land under the anthropic namespace', () => {
expect(getProviderQuirks('google-vertex-anthropic').providerOptionsNamespace).toBe('anthropic');
expect(applyToolProviderOptionDefaults(undefined)).toEqual({
anthropic: { eagerInputStreaming: false },
});
});
it('openai: defaults reasoningEffort to medium', () => {
expect(getProviderQuirks('openai').thinkingToProviderOptions?.({}, 'openai/gpt-5')).toEqual({
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
});
it('openai: forwards GPT-5.6 Sol compatible reasoningEffort values', () => {
expect(
getProviderQuirks('openai').thinkingToProviderOptions?.(
{ reasoningEffort: 'medium' },
'openai/gpt-5.6-sol',
),
).toEqual({
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
expect(
getProviderQuirks('openai').thinkingToProviderOptions?.(
{ reasoningEffort: 'xhigh' },
'openai/gpt-5.6-sol',
),
).toEqual({
openai: { reasoningEffort: 'xhigh', reasoningSummary: null },
});
});
it('google: forwards thinkingBudget and thinkingLevel when set', () => {
expect(
getProviderQuirks('google').thinkingToProviderOptions?.(
@ -115,4 +188,38 @@ describe('thinkingToProviderOptions', () => {
xai: { reasoningEffort: 'high' },
});
});
it('custom: maps reasoningEffort to providerOptions.custom', () => {
expect(
getProviderQuirks('custom').thinkingToProviderOptions?.(
{
reasoningEffort: 'high',
},
'custom/Kimi-K3',
),
).toEqual({
custom: { reasoningEffort: 'high' },
});
});
it('custom: omits reasoning effort when unset', () => {
expect(getProviderQuirks('custom').thinkingToProviderOptions?.({}, 'custom/Kimi-K3')).toEqual(
{},
);
});
});
describe('resolveDefaultMaxOutputTokens', () => {
it.each([
'custom/accounts/fireworks/models/kimi-k3',
'openrouter/moonshotai/kimi-k3',
'custom/Kimi-K3',
] as const)('raises the output cap to the Kimi K3 default for %s', (modelId) => {
expect(resolveDefaultMaxOutputTokens(modelId)).toBe(HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS);
});
it('leaves unrelated models unset', () => {
expect(resolveDefaultMaxOutputTokens('custom/deepseek-ai/DeepSeek-V4-Pro')).toBeUndefined();
expect(resolveDefaultMaxOutputTokens('anthropic/claude-sonnet-4-5')).toBeUndefined();
});
});

View File

@ -883,6 +883,7 @@ export class AgentRuntime {
reasoning: staticLoopContext.reasoning,
providerOptions: staticLoopContext.providerOptions,
outputSpec: staticLoopContext.outputSpec,
maxOutputTokens: staticLoopContext.maxOutputTokens,
aiSdkOptions: this.buildAiSdkOptions(toolMap, options),
};
let turn = await sink.callModel(modelCallContext);

View File

@ -39,6 +39,7 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
...(ctx.outputSpec ? { output: ctx.outputSpec } : {}),
...(ctx.maxOutputTokens !== undefined ? { maxOutputTokens: ctx.maxOutputTokens } : {}),
...ctx.aiSdkOptions,
});

View File

@ -69,6 +69,7 @@ export interface ModelCallContext {
reasoning?: LanguageModelCallOptions['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
maxOutputTokens?: number;
aiSdkOptions: {
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;

View File

@ -22,6 +22,7 @@ import {
getProviderQuirks,
PROVIDER_QUIRKS,
providerIdFromModelId,
resolveDefaultMaxOutputTokens,
} from '../model/provider-quirks';
import type { DeferredToolManager } from '../tools/deferred-tool-manager';
import { buildToolMap, toAiSdkProviderTools, toAiSdkTools } from '../tools/tool-adapter';
@ -38,6 +39,7 @@ export interface StaticLoopContext {
reasoning: AgentRuntimeConfig['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
maxOutputTokens?: number;
}
/**
@ -87,6 +89,7 @@ export class RuntimeContextBuilder {
reasoning: this.config.reasoning,
providerOptions: providerOptions as Record<string, JSONObject> | undefined,
outputSpec,
maxOutputTokens: execOptions?.maxOutputTokens ?? resolveDefaultMaxOutputTokens(this.modelId),
};
}

View File

@ -194,6 +194,7 @@ export class StreamSink implements RunOutputSink<void> {
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
...(ctx.outputSpec ? { output: ctx.outputSpec } : {}),
...(ctx.maxOutputTokens !== undefined ? { maxOutputTokens: ctx.maxOutputTokens } : {}),
...ctx.aiSdkOptions,
...this.buildSmoothStreamTransformOptions(),
});

View File

@ -65,6 +65,84 @@ type ProviderRegistry = {
[P in ProviderId]: RegistryEntry<P>;
};
type OpenAiCompatibleCreds = {
apiKey?: string;
baseURL?: string;
headers?: Record<string, string>;
};
/**
* Parse a Vertex service-account JSON string into `googleAuthOptions`.
* Accepts either a full SA JSON blob or the subset `{ client_email, private_key }`.
* Returns undefined when unset so ADC can take over.
*/
function parseGoogleVertexAuthOptions(
googleCredentials: string | undefined,
): { credentials: Record<string, unknown> } | undefined {
if (!googleCredentials?.trim()) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(googleCredentials);
} catch {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be valid JSON',
);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be a JSON object',
);
}
const credentials = { ...(parsed as Record<string, unknown>) };
// SA keys often arrive with literal `\n` escapes when pasted into env files.
if (typeof credentials.private_key === 'string') {
credentials.private_key = credentials.private_key.replace(/\\n/g, '\n');
}
return { credentials };
}
/**
* Shared builder for OpenAI-compatible HTTP providers. Prefer this over
* `@ai-sdk/<provider>` packages that pull optional NAPI binaries or v4-only types.
*/
function buildOpenAiCompatible(
name: string,
defaultBaseURL: string | undefined,
creds: OpenAiCompatibleCreds,
model: string,
fetch: FetchFn | undefined,
options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): LanguageModel {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
const baseURL = creds.baseURL ?? defaultBaseURL;
if (!baseURL) {
throw new Error(`baseURL is required for OpenAI-compatible provider "${name}"`);
}
return createOpenAICompatible({
name,
baseURL,
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
includeUsage: options?.includeUsage,
supportsStructuredOutputs: options?.supportsStructuredOutputs,
})(model);
}
type OpenAiCompatibleProviderId = 'nvidia';
function openAiCompatibleEntry<P extends OpenAiCompatibleProviderId>(
name: P,
defaultBaseURL: string,
options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): RegistryEntry<P> {
return {
build: (creds, model, fetch) =>
buildOpenAiCompatible(name, defaultBaseURL, creds, model, fetch, options),
};
}
/**
* Registry of language model providers.
* Each entry maps a provider id to a builder that loads its @ai-sdk/* package
@ -86,17 +164,10 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
},
},
custom: {
build: (creds, model, fetch) => {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
return createOpenAICompatible({
name: 'custom',
baseURL: creds.baseURL,
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
})(model);
},
build: (creds, model, fetch) =>
buildOpenAiCompatible('custom', undefined, creds, model, fetch, {
supportsStructuredOutputs: creds.supportsStructuredOutputs,
}),
},
anthropic: {
build: (creds, model, fetch) => {
@ -116,6 +187,21 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
return createAnthropic({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
},
},
'google-vertex-anthropic': {
build: (creds, model, fetch) => {
const { createVertexAnthropic } =
require('@ai-sdk/google-vertex/anthropic') as typeof import('@ai-sdk/google-vertex/anthropic');
const googleAuthOptions = parseGoogleVertexAuthOptions(creds.googleCredentials);
return createVertexAnthropic({
project: creds.project,
location: creds.location,
baseURL: creds.baseURL,
headers: creds.headers,
...(googleAuthOptions ? { googleAuthOptions } : {}),
fetch,
})(model);
},
},
google: {
build: (creds, model, fetch) => {
const { createGoogle } = require('@ai-sdk/google') as typeof import('@ai-sdk/google');
@ -165,19 +251,7 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
return createOpenRouter({ apiKey: creds.apiKey, baseURL: creds.baseURL, fetch })(model);
},
},
nvidia: {
build: (creds, model, fetch) => {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
return createOpenAICompatible({
name: 'nvidia',
baseURL: creds.baseURL ?? 'https://integrate.api.nvidia.com/v1',
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
})(model);
},
},
nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}),
'azure-openai': {
build: (creds, model, fetch) => {
const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure');

View File

@ -40,7 +40,7 @@ export function getEffectiveAnthropicCacheTtl(
config: PromptCachingConfig | undefined,
modelId: string,
): '5m' | '1h' | undefined {
if (getModelProvider(modelId) !== 'anthropic' || !isEnabledForProvider(config, 'anthropic')) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(config, 'anthropic')) {
return undefined;
}
return getAnthropicCacheTtl(config);
@ -59,6 +59,12 @@ export function getModelProvider(modelId: string): string {
return modelId.split('/')[0];
}
/** Providers that speak the Anthropic Messages API (including Vertex Claude). */
export function isAnthropicMessagesProvider(modelId: string): boolean {
const provider = getModelProvider(modelId);
return provider === 'anthropic' || provider === 'google-vertex-anthropic';
}
/**
* Deterministic, non-reversible per-agent-version OpenAI `promptCacheKey`.
* Hashes the agent name, model id, and base instructions together so the key
@ -144,7 +150,7 @@ export function buildInstructionPromptCacheOptions(
config: PromptCachingConfig | undefined,
modelId: string,
): ProviderOptions | undefined {
if (getModelProvider(modelId) !== 'anthropic' || !isEnabledForProvider(config, 'anthropic')) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(config, 'anthropic')) {
return undefined;
}
@ -201,10 +207,7 @@ export function applyRuntimeCacheBreakpoints(params: {
staticToolCacheName: string | undefined;
}): { messages: ModelMessage[]; aiTools: ToolSet } {
const { system, messages, aiTools, promptCaching, modelId, staticToolCacheName } = params;
if (
getModelProvider(modelId) !== 'anthropic' ||
!isEnabledForProvider(promptCaching, 'anthropic')
) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(promptCaching, 'anthropic')) {
return { messages, aiTools };
}

View File

@ -19,8 +19,21 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = {
}),
custom: apiKeyCreds.extend({
baseURL: z.string().min(1, 'baseURL is required'),
supportsStructuredOutputs: z.boolean().optional(),
}),
anthropic: apiKeyCreds,
/**
* Claude on Google Vertex (Anthropic Messages via `:rawPredict`).
* `googleCredentials` is a service-account JSON string; omit it to use ADC
* (`gcloud auth application-default login`).
*/
'google-vertex-anthropic': z.object({
project: z.string().min(1, 'project is required'),
location: z.string().min(1, 'location is required').default('global'),
googleCredentials: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
}),
google: apiKeyCreds,
xai: apiKeyCreds,
groq: apiKeyCreds,

View File

@ -3,12 +3,19 @@ import type {
AnthropicThinkingConfig,
GoogleThinkingConfig,
JSONObject,
OpenAIReasoningEffort,
OpenAIThinkingConfig,
ThinkingConfig,
XaiThinkingConfig,
} from '../../types';
export interface ProviderQuirks {
/**
* Namespace used in AI SDK `providerOptions` / `providerMetadata`.
* Defaults to the registry key. Vertex Anthropic still speaks the Anthropic
* Messages wire format, so its quirks emit under `anthropic`.
*/
providerOptionsNamespace?: string;
/** providerMetadata keys on reasoning parts that must be copied to providerOptions and survive replay. */
reasoningReplayKeys?: string[];
/** Defaults merged under this provider's namespace into every tool's providerOptions (explicit tool values win). */
@ -22,6 +29,31 @@ export interface ProviderQuirks {
) => Record<string, Record<string, unknown>>;
}
/** Shared Anthropic Messages thinking mapping (also used by Vertex Anthropic). */
function anthropicThinkingToProviderOptions(
thinking: ThinkingConfig,
modelId: string,
): Record<string, Record<string, unknown>> {
const cfg = thinking as AnthropicThinkingConfig;
const adaptive = cfg.mode === 'adaptive' ? cfg : undefined;
if (anthropicUsesAdaptiveThinking(modelId)) {
return {
anthropic: {
thinking: { type: 'adaptive', display: adaptive?.display ?? 'summarized' },
effort: adaptive?.effort ?? 'medium',
},
};
}
const budgetTokens = cfg.mode === 'adaptive' ? undefined : cfg.budgetTokens;
const effort = cfg.mode === 'adaptive' ? undefined : cfg.effort;
return {
anthropic: {
thinking: { type: 'enabled', budgetTokens: budgetTokens ?? 10000 },
...(effort !== undefined ? { effort } : {}),
},
};
}
/** Anthropic model families that take the adaptive thinking API. */
const ANTHROPIC_ADAPTIVE_THINKING = /claude-(?:opus-(?:5|4-6|4-7|4-8)|sonnet-(?:5|4-6)|fable-5)/;
/** Anthropic model families that predate it and still take a fixed token budget. */
@ -39,6 +71,21 @@ function anthropicUsesAdaptiveThinking(modelId: string): boolean {
return modelId.includes('claude-');
}
/** Map effort to top-level `reasoningEffort` (OpenAI-compatible chat). */
function reasoningEffortQuirk(
provider: ProviderId,
defaultEffort?: OpenAIReasoningEffort,
): ProviderQuirks {
return {
thinkingToProviderOptions: (thinking) => {
const cfg = thinking as OpenAIThinkingConfig;
const reasoningEffort = cfg.reasoningEffort ?? defaultEffort;
if (reasoningEffort === undefined) return {};
return { [provider]: { reasoningEffort } };
},
};
}
/**
* Declarative registry of provider-specific behavior the AI SDK doesn't
* normalize away. Each entry documents why the quirk exists and its upstream
@ -61,24 +108,14 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
// QUIRK(anthropic): the two thinking APIs are mutually exclusive — an
// adaptive model rejects `type: 'enabled'` and vice versa — so the model
// decides the shape and the config only fills in its details.
thinkingToProviderOptions: (thinking, modelId) => {
const cfg = thinking as AnthropicThinkingConfig;
const adaptive = cfg.mode === 'adaptive' ? cfg : undefined;
if (anthropicUsesAdaptiveThinking(modelId)) {
return {
anthropic: {
thinking: { type: 'adaptive', display: adaptive?.display ?? 'summarized' },
effort: adaptive?.effort ?? 'medium',
},
};
}
const budgetTokens = cfg.mode === 'adaptive' ? undefined : cfg.budgetTokens;
return {
anthropic: {
thinking: { type: 'enabled', budgetTokens: budgetTokens ?? 10000 },
},
};
},
thinkingToProviderOptions: anthropicThinkingToProviderOptions,
},
// Vertex Claude uses AnthropicLanguageModel under the hood — providerOptions
// stay under the `anthropic` namespace even though the model id prefix differs.
'google-vertex-anthropic': {
providerOptionsNamespace: 'anthropic',
toolProviderOptionDefaults: { eagerInputStreaming: false },
thinkingToProviderOptions: anthropicThinkingToProviderOptions,
},
openai: {
// QUIRK(openai): the Responses API pairs each function_call item with a
@ -129,6 +166,8 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
return { xai: { reasoningEffort: cfg.reasoningEffort ?? 'high' } };
},
},
// custom/*: only forward an explicit effort — no provider-level default.
custom: reasoningEffortQuirk('custom'),
};
export function getProviderQuirks(providerId: string): ProviderQuirks {
@ -139,6 +178,25 @@ export function providerIdFromModelId(modelId: string): string {
return modelId.split('/')[0];
}
/**
* Default completion-token cap for reasoning-heavy Kimi K3 models.
* Context windows are often 131072 shared input+output; requesting the full
* window as max_tokens overflows once any prompt tokens are present.
*/
export const HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS = 65_536;
/**
* Provider/model-specific default for AI SDK `maxOutputTokens`. Unknown models
* otherwise fall back to a 4096 cap that reasoning-heavy agent turns can exhaust
* before emitting text or tool calls.
*/
export function resolveDefaultMaxOutputTokens(modelId: string): number | undefined {
if (modelId.toLowerCase().includes('kimi-k3')) {
return HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS;
}
return undefined;
}
/** Merge every registered tool providerOptions default under its provider namespace; explicit tool values win. */
export function applyToolProviderOptionDefaults(
toolProviderOptions: Record<string, JSONObject> | undefined,
@ -146,7 +204,8 @@ export function applyToolProviderOptionDefaults(
const result = { ...toolProviderOptions };
for (const [provider, quirks] of Object.entries(PROVIDER_QUIRKS)) {
if (!quirks.toolProviderOptionDefaults) continue;
result[provider] = { ...quirks.toolProviderOptionDefaults, ...result[provider] };
const namespace = quirks.providerOptionsNamespace ?? provider;
result[namespace] = { ...quirks.toolProviderOptionDefaults, ...result[namespace] };
}
return result;
}

View File

@ -74,6 +74,7 @@ export class AnthropicRawUsageReader implements RawUsageReader {
*/
const RAW_USAGE_READERS: Partial<Record<ProviderId, () => RawUsageReader>> = {
anthropic: () => new AnthropicRawUsageReader(),
'google-vertex-anthropic': () => new AnthropicRawUsageReader(),
};
/**

View File

@ -11,6 +11,7 @@ const MODELS_DEV_PROVIDER_ALIASES: Record<string, string> = {
const AGENT_PROVIDER_NAMES: Record<string, string> = {
'aws-bedrock': 'AWS Bedrock',
'azure-openai': 'Azure OpenAI',
'google-vertex-anthropic': 'Google Vertex Anthropic',
};
/** Cost per million tokens. */

View File

@ -27,7 +27,9 @@ export type { BuiltFileStore } from './sdk/file-store';
export type {
Provider,
AnthropicThinkingEffort,
AnthropicThinkingConfig,
OpenAIReasoningEffort,
OpenAIThinkingConfig,
GoogleThinkingConfig,
XaiThinkingConfig,

View File

@ -179,6 +179,12 @@ export interface ExecutionOptions {
maxIterations?: number;
abortSignal?: AbortSignal;
providerOptions?: ProviderOptions;
/**
* Cap on completion tokens for each model call (`max_tokens` /
* `maxOutputTokens`). When unset, provider/model defaults from
* `resolveDefaultMaxOutputTokens` apply.
*/
maxOutputTokens?: number;
/** AI SDK `smoothStream` transform. Enabled by default; pass `false` to disable. */
smoothStream?: SmoothStreamOptions | false;
/**

View File

@ -6,15 +6,16 @@
export type Provider =
| 'anthropic'
| 'cerebras'
| 'custom'
| 'deepinfra'
| 'deepseek'
| 'google'
| 'google-vertex-anthropic'
| 'groq'
| 'mistral'
| 'openai'
| 'openrouter'
| 'perplexity'
| 'togetherai'
| 'vercel'
| 'xai'
| (string & {});
@ -26,6 +27,8 @@ export type Provider =
* - `'adaptive'`: the model decides how much to think per request.
* - `'enabled'` (default): a fixed token budget controlled by `budgetTokens`.
*/
export type AnthropicThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type AnthropicThinkingConfig =
| {
mode: 'adaptive';
@ -35,18 +38,43 @@ export type AnthropicThinkingConfig =
* reasoning streams and replay metadata are available.
*/
display?: 'omitted' | 'summarized';
/** Reasoning effort for adaptive thinking. Defaults to 'medium'. */
effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/**
* Adaptive-thinking effort (`output_config.effort` on the Anthropic API).
* When omitted, Anthropic/SDK defaults apply.
*/
effort?: AnthropicThinkingEffort;
}
| {
mode?: 'enabled';
/** Token budget for extended thinking. Defaults to 10000. */
/** Token budget for extended thinking. Defaults to 10000 when `effort` is omitted. */
budgetTokens?: number;
/**
* Maps to Anthropic `output_config.effort`. Used with `type: 'enabled'` for
* Anthropic-compatible gateways that reject `adaptive`.
*/
effort?: AnthropicThinkingEffort;
};
/**
* OpenAI / AI SDK reasoning effort.
* GPT-5.6 Sol supports none|low|medium|high|xhigh|max (no `minimal`).
* Older reasoning models may also accept `minimal`.
*/
export type OpenAIReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max';
export interface OpenAIThinkingConfig {
/** Reasoning effort level. Defaults to 'medium'. */
reasoningEffort?: 'low' | 'medium' | 'high';
/**
* Reasoning effort level. Mapped to AI SDK `providerOptions.<provider>.reasoningEffort`.
* OpenAI quirks may default when unset; `custom` only forwards an explicit value.
*/
reasoningEffort?: OpenAIReasoningEffort;
}
export interface GoogleThinkingConfig {
@ -58,7 +86,7 @@ export interface GoogleThinkingConfig {
export interface XaiThinkingConfig {
/** Reasoning effort level. */
reasoningEffort?: 'low' | 'high';
reasoningEffort?: 'low' | 'medium' | 'high';
}
/**
@ -66,7 +94,7 @@ export interface XaiThinkingConfig {
* Known providers get their specific config; unknown providers default
* to OpenAI-style (reasoningEffort) since most providers follow that API.
*/
export type ThinkingConfigFor<P> = P extends 'anthropic'
export type ThinkingConfigFor<P> = P extends 'anthropic' | 'google-vertex-anthropic'
? AnthropicThinkingConfig
: P extends 'google'
? GoogleThinkingConfig

View File

@ -78,6 +78,16 @@ export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
providerTools: ['anthropic.web_search'],
attachments: { image: true, pdf: true, audio: false },
},
// Claude on Vertex — same Messages capabilities as Anthropic; providerTools
// stay empty because Vertex only exposes a subset and Instance AI does not
// wire Vertex-specific Anthropic provider tools yet.
'google-vertex-anthropic': {
thinking: 'budgetTokens',
promptCaching: 'ttl',
webSearch: false,
providerTools: [],
attachments: { image: true, pdf: true, audio: false },
},
openai: {
thinking: 'reasoningEffort',
promptCaching: true,
@ -93,7 +103,7 @@ export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
attachments: { image: true, pdf: true, audio: true },
},
xai: {
thinking: false,
thinking: 'reasoningEffort',
promptCaching: false,
webSearch: false,
providerTools: [],
@ -127,6 +137,14 @@ export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
providerTools: [],
attachments: NO_ATTACHMENTS,
},
// OpenAI-compatible custom endpoints (N8N_INSTANCE_AI_MODEL_URL / custom/*).
custom: {
thinking: 'reasoningEffort',
promptCaching: false,
webSearch: false,
providerTools: [],
attachments: NO_ATTACHMENTS,
},
cohere: {
thinking: false,
promptCaching: false,

View File

@ -16,6 +16,27 @@ export class InstanceAiConfig {
@Env('N8N_INSTANCE_AI_MODEL_API_KEY')
modelApiKey: string = '';
/**
* Google Cloud project for `google-vertex-anthropic/*` models.
* Falls back to `GOOGLE_VERTEX_PROJECT`, then `project_id` in the service-account JSON.
*/
@Env('N8N_INSTANCE_AI_VERTEX_PROJECT_ID')
vertexProjectId: string = '';
/**
* Vertex location for `google-vertex-anthropic/*` models (e.g. `global`, `us-east5`).
* Empty falls back to `GOOGLE_VERTEX_LOCATION`, then `global`.
*/
@Env('N8N_INSTANCE_AI_VERTEX_LOCATION')
vertexLocation: string = '';
/**
* Service-account JSON for `google-vertex-anthropic/*` models.
* Omit to use ADC (`gcloud auth application-default login`).
*/
@Env('N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON')
vertexServiceAccountJson: string = '';
/** Comma-separated name=url pairs for MCP servers (e.g. "github=https://mcp.github.com/sse"). */
@Env('N8N_INSTANCE_AI_MCP_SERVERS')
mcpServers: string = '';

View File

@ -322,6 +322,9 @@ describe('GlobalConfig', () => {
model: 'anthropic/claude-opus-4-8',
modelUrl: '',
modelApiKey: '',
vertexProjectId: '',
vertexLocation: '',
vertexServiceAccountJson: '',
mcpServers: '',
localGatewayDisabled: false,
browserUseEnabled: true,

View File

@ -11,9 +11,14 @@ persisted in settings takes precedence over `N8N_INSTANCE_AI_SANDBOX_PROVIDER`.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `N8N_INSTANCE_AI_MODEL` | string | `anthropic/claude-opus-4-8` | LLM model in `provider/model` format for built-in providers, or a bare model name when `N8N_INSTANCE_AI_MODEL_URL` is set. Must be set for the module to enable. |
| `N8N_INSTANCE_AI_MODEL` | string | `anthropic/claude-opus-4-8` | LLM model in `provider/model` format for built-in providers, or a bare model name when `N8N_INSTANCE_AI_MODEL_URL` is set. Must be set for the module to enable. Opus 5: `anthropic/claude-opus-5`. Vertex Claude: `google-vertex-anthropic/claude-opus-4-8`. |
| `N8N_INSTANCE_AI_MODEL_URL` | string | `''` | Base URL for an OpenAI-compatible endpoint (e.g. `http://localhost:1234/v1` for LM Studio). When set, model requests go to this URL instead of the built-in provider. |
| `N8N_INSTANCE_AI_MODEL_API_KEY` | string | `''` | API key for the custom model endpoint. Optional — some local servers don't require one. |
| `N8N_INSTANCE_AI_VERTEX_PROJECT_ID` | string | `''` | Google Cloud project for `google-vertex-anthropic/*`. Falls back to `GOOGLE_VERTEX_PROJECT`, then `project_id` in the service-account JSON. |
| `N8N_INSTANCE_AI_VERTEX_LOCATION` | string | `''` | Vertex location for `google-vertex-anthropic/*` (e.g. `global`, `us-east5`). Empty falls back to `GOOGLE_VERTEX_LOCATION`, then `global`. |
| `N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON` | string | `''` | Service-account JSON for Vertex Claude. Omit to use ADC (`gcloud auth application-default login`). |
| `N8N_INSTANCE_AI_REASONING_EFFORT` | string | unset | Optional reasoning effort for `custom/*` (`none`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`). Unset = known-model map in `src/utils/custom-model-defaults.ts`; still unresolved = omit. |
| `N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS` | string | unset | Optional `true`/`false` for `custom/*` structured-output support. Unset = known-model map; still unresolved = omit. |
| `N8N_INSTANCE_AI_MCP_SERVERS` | string | `''` | Comma-separated MCP server configs. Format: `name=url,name=url` |
| `N8N_INSTANCE_AI_LOCAL_GATEWAY_DISABLED` | boolean | `false` | Disable the local gateway (filesystem, shell, browser) for all users |
@ -32,6 +37,7 @@ persisted in settings takes precedence over `N8N_INSTANCE_AI_SANDBOX_PROVIDER`.
|----------|------|---------|-------------|
| `N8N_INSTANCE_AI_RUN_DEBUG_ENABLED` | boolean | `false` | Capture orchestrator LLM steps and workflow code snapshots for the dev debug panel and eval LLM debug reports. |
| `N8N_INSTANCE_AI_EVAL_TIMING` | boolean | `false` | When `true`, logs a per-execution `[EvalMock][timing]` phase breakdown (hints / bypass-pin / http-mock / ai-turn) for the eval mock-execution path, to attribute mocked-execution latency. A no-op otherwise. |
| `EVAL_MODAL_LLM_HEADERS` | string | `''` | Eval-only JSON object of extra HTTP headers for Modal (or other custom) LLM endpoints. Used by eval helpers (`createEvalAgent`, mock generation, verification) with `N8N_INSTANCE_AI_MODEL_URL`. |
### Memory
@ -235,6 +241,13 @@ N8N_INSTANCE_AI_GATEWAY_API_KEY=my-secret-key
N8N_INSTANCE_AI_MODEL=your-tool-capable-model
N8N_INSTANCE_AI_MODEL_URL=http://localhost:1234/v1
# Direct Google Vertex Claude (no AI assistant proxy)
# Keep N8N_AI_ASSISTANT_BASE_URL unset/empty.
N8N_INSTANCE_AI_MODEL=google-vertex-anthropic/claude-opus-4-8
N8N_INSTANCE_AI_VERTEX_PROJECT_ID=my-gcp-project
N8N_INSTANCE_AI_VERTEX_LOCATION=global
N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
# Output filtering — secrets + email only, with a custom placeholder
N8N_INSTANCE_AI_OUTPUT_REDACTION_PII=email
N8N_INSTANCE_AI_OUTPUT_REDACTION_PLACEHOLDER=‹redacted›

View File

@ -28,6 +28,8 @@ export default defineConfig(
// Local eval scratch output — never linted, never committed.
'.data/**',
'evaluations/.data/**',
'evaluations/.output/**',
'.output/**',
// Deep-imports ai-workflow-builder.ee's evaluations source, so it sits outside
// evaluations/tsconfig.json (see its exclude) and the eslint project service.
'evaluations/cli/pairwise.ts',

View File

@ -296,6 +296,8 @@ Not yet covered: an automatic "unexpected artifact" fail (a build producing an a
|----------|----------|-------------|
| `N8N_INSTANCE_AI_MODEL` | Yes | Model used by Instance AI and, by default, the eval helper calls for mock generation and verification |
| `N8N_INSTANCE_AI_MODEL_API_KEY` | No | Generic eval-model API key override |
| `N8N_INSTANCE_AI_MODEL_URL` | No | OpenAI-compatible base URL for custom eval models (used with `custom/...` model ids) |
| `EVAL_MODAL_LLM_HEADERS` | No | Eval-only JSON object of extra HTTP headers for Modal (or other custom) LLM endpoints |
| `OPENAI_API_KEY` | No | Provider-specific key used automatically when `N8N_INSTANCE_AI_MODEL` starts with `openai/` |
| `ANTHROPIC_API_KEY` | No | Provider-specific key used automatically when `N8N_INSTANCE_AI_MODEL` starts with `anthropic/` |
| `N8N_EVAL_EMAIL` | No | n8n login email (defaults to E2E test owner) |
@ -1000,6 +1002,32 @@ Evals run automatically on PRs that change Instance AI code (path-filtered). The
The job is **non-blocking**. Results are posted as a PR comment and uploaded as artifacts. When `LANGSMITH_API_KEY` is set via the `EVALS_LANGSMITH_API_KEY` secret, runs also land as LangSmith experiments tagged with commit SHA + branch, so you can compare against master side-by-side.
For model A/B experiments, dispatch **Instance AI Evals: Experiments** (`test-evals-instance-ai.yml`). Native providers use `model` alone (`anthropic/*`, `openai/*`, `openrouter/*`, `xai/*`, `google-vertex-anthropic/*`). OpenAI-compatible vendors use **`custom/<model>` + `model-url` + `model-key`** — no first-class provider prefixes.
| Experiment | `model` | `model-url` | `model-key` → secret |
|------------|---------|-------------|----------------------|
| Anthropic / OpenAI / OpenRouter / xAI | `anthropic/…`, `openai/…`, etc. | empty | (prefix → `EVALS_*`) |
| Google Vertex Claude | `google-vertex-anthropic/claude-opus-4-8` | empty | (prefix → `EVALS_VERTEX_KEY` + `EVALS_VERTEX_PROJECT_ID`; optional `EVALS_VERTEX_LOCATION`, default `global`) |
| Baseten | `custom/<model>` | `https://inference.baseten.co/v1` | `baseten` → `EVALS_BASETEN_KEY` |
| Fireworks | `custom/accounts/fireworks/models/…` | `https://api.fireworks.ai/inference/v1` | `fireworks` → `EVALS_FIREWORKS_KEY` |
| Together | `custom/moonshotai/Kimi-K3` | `https://api.together.ai/v1` | `together` → `EVALS_TOGETHER_KEY` |
| Modal | `custom/…` | `https://….modal.direct…/v1` | `modal` → `EVALS_MODAL_KEY` |
| Databricks | `custom/workspace.default.kimi-k3` | `https://….databricks.com/ai-gateway/mlflow/v1` | `databricks` → `EVALS_DATABRICKS_KEY` |
| Lyceum | `custom/moonshotai/Kimi-K3` | OpenAI-compatible `/v1` base URL | `lyceum` → `EVALS_LYCEUM_KEY` |
| Azure OpenAI | `custom/<deployment>` | `https://….openai.azure.com/openai/v1` | `azure` → `EVALS_AZURE_FOUNDRY_KEY` |
| Keyless custom router | `custom/<model>` | `https://host/v1` | empty (no API key) |
| Azure Foundry Claude | `anthropic/<deployment>` | Foundry Anthropic base | `azure` (or omit — defaults to Foundry key) |
`lanes` / `eval-concurrency` default to **10 / 32**. For `model-key=baseten` they auto-throttle to **1 / 2** (~0.5M TPM — fits [Baseten Basic verified](https://docs.baseten.co/inference/model-apis/rate-limits-and-budgets)); override the inputs if you have more headroom.
Verifier/mocks always use `EVALS_ANTHROPIC_KEY`.
For `custom/*`, optional dispatch inputs `reasoning-effort` and `supports-structured-outputs`
override `N8N_INSTANCE_AI_REASONING_EFFORT` / `N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS`.
When unset, the runtime looks up
`packages/@n8n/instance-ai/src/utils/custom-model-defaults.ts` (substring match on the model id).
If still unresolved, the field is omitted from the request (no blanket custom default).
## Architecture
```

View File

@ -14,6 +14,7 @@ vi.mock('../../src/utils/eval-agents', () => ({
import { createEvalAgent, resolveEvalModelConfig } from '../../src/utils/eval-agents';
import {
VERIFIER_MAX_OUTPUT_TOKENS,
VERIFY_ATTEMPT_TIMEOUTS_MS,
VERIFY_INACTIVITY_TIMEOUT_MS,
verifyChecklist,
@ -39,7 +40,12 @@ const GOOD_OUTPUT = {
],
};
type StreamFn = Mock<(messages: unknown, opts: { abortSignal: AbortSignal }) => Promise<unknown>>;
type StreamFn = Mock<
(
messages: unknown,
opts: { abortSignal: AbortSignal; maxOutputTokens?: number },
) => Promise<unknown>
>;
/** Wire createEvalAgent().structuredOutput().stream() to the given stream mock. */
function mockVerifierAgent(stream: StreamFn): void {
@ -155,6 +161,10 @@ describe('verifyChecklist (agents stream path)', () => {
expect(attempts).toHaveLength(1);
expect(attempts[0].status).toBe('success');
expect(stream).toHaveBeenCalledTimes(1);
expect(stream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxOutputTokens: VERIFIER_MAX_OUTPUT_TOKENS }),
);
});
it('falls back to parsing the streamed text when no structured output arrives', async () => {

View File

@ -36,6 +36,12 @@ export const VERIFY_ATTEMPT_TIMEOUTS_MS = [60_000, 120_000, 240_000];
/** Abort when the stream goes silent for this long AFTER its first chunk (agents path only).
* Pre-first-chunk time is bounded by the attempt cap, not this window. */
export const VERIFY_INACTIVITY_TIMEOUT_MS = 45_000;
/**
* Completion budget for checklist JSON. Some OpenAI-compatible defaults (~250)
* truncate mid-result (~298 tokens observed); 4096 leaves headroom for thinking
* tokens plus multi-item checklists.
*/
export const VERIFIER_MAX_OUTPUT_TOKENS = 4_096;
const VERIFIER_DEBUG = process.env.N8N_EVAL_VERIFIER_DEBUG === '1';
function jitteredPauseMs(attempt: number): number {
@ -168,6 +174,7 @@ async function runNativeOpenAiVerifier(
const model = resolveEvalModelConfig();
const requestBody = {
model: model.providerModelId,
max_output_tokens: VERIFIER_MAX_OUTPUT_TOKENS,
...(supportsOpenAiReasoning(model.providerModelId) ? { reasoning: { effort: 'high' } } : {}),
input: [
{
@ -455,6 +462,7 @@ export async function verifyChecklist(
const streamResult = await agent.stream(messages, {
abortSignal: abortController.signal,
smoothStream: false,
maxOutputTokens: VERIFIER_MAX_OUTPUT_TOKENS,
});
const streamed = await consumeVerifierStream(
streamResult.stream,

View File

@ -246,9 +246,9 @@ export async function runWorkflowChecks(args: {
}
function hasAnthropicKey(): boolean {
return Boolean(
process.env.N8N_INSTANCE_AI_MODEL_API_KEY ??
process.env.N8N_AI_ANTHROPIC_KEY ??
process.env.ANTHROPIC_API_KEY,
);
return [
process.env.N8N_AI_ANTHROPIC_KEY,
process.env.ANTHROPIC_API_KEY,
process.env.N8N_INSTANCE_AI_MODEL_API_KEY,
].some((value) => Boolean(value?.trim()));
}

View File

@ -476,7 +476,8 @@ unsolicited `sticky()`, forbidden builder constructs (e.g. `.map()`), and
repeated `.onTrue()` / `.onFalse()` overwrites on the same IF variable. Fix
every reported error and warning before calling `build-workflow`.
- Code nodes need not always be necessary. You can use other n8n nodes to do the same thing.
- Avoid code node where possible, use n8n nodes that help do the same thing.
If it makes it simpler, go ahead and use code node.
- SDK builder code is a restricted subset of TypeScript that builds a static
graph; it is not a Code node and does not run. Build strings with template
literals; do runtime joining, aggregation, or transforms in a Code node or

View File

@ -22,16 +22,25 @@ describe('applyAgentThinking', () => {
vi.clearAllMocks();
});
it('enables adaptive thinking for Anthropic', () => {
it('enables adaptive thinking with medium effort for Anthropic', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'anthropic/claude-opus-4-8');
applyAgentThinking(agent, 'anthropic/claude-opus-5');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', {
mode: 'adaptive',
effort: 'medium',
});
});
it('enables adaptive thinking for dotted Anthropic provider IDs', () => {
it('enables adaptive thinking with medium effort for Google Vertex Anthropic', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'google-vertex-anthropic/claude-opus-4-8');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('google-vertex-anthropic', {
mode: 'adaptive',
effort: 'medium',
});
});
it('enables adaptive thinking with medium effort for dotted Anthropic provider IDs', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'anthropic.messages/claude-opus-4-8');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', {
@ -40,7 +49,7 @@ describe('applyAgentThinking', () => {
});
});
it('enables adaptive thinking for AI SDK Anthropic model objects', () => {
it('enables adaptive thinking with medium effort for AI SDK Anthropic model objects', () => {
const agent = new Agent('test');
applyAgentThinking(agent, {
modelId: 'claude-opus-4-8',
@ -52,17 +61,85 @@ describe('applyAgentThinking', () => {
});
});
it('enables OpenAI reasoning for supported models', () => {
it.each(['openai/gpt-5.6-sol', 'openai/gpt-5.6-terra', 'openai/gpt-5.6-luna'] as const)(
'enables medium OpenAI reasoning effort for %s',
(modelId) => {
const agent = new Agent('test');
applyAgentThinking(agent, modelId);
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('openai', {
reasoningEffort: 'medium',
});
},
);
it('enables high OpenAI reasoning effort for non-GPT-5.6 models', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'openai/gpt-5.5');
applyAgentThinking(agent, 'openai/gpt-4.1');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('openai', {
reasoningEffort: 'high',
});
});
it('enables mapped low reasoning effort for known custom Kimi K3 models', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'custom/Kimi-K3');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('custom', {
reasoningEffort: 'low',
});
});
it('enables mapped low reasoning effort for Databricks AI Gateway Kimi-K3', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'custom/workspace.default.kimi-k3');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('custom', {
reasoningEffort: 'low',
});
});
it('skips thinking for unknown custom models without an env override', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'custom/unknown-model');
expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled();
});
it('prefers N8N_INSTANCE_AI_REASONING_EFFORT over the known-model map', () => {
const previous = process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
process.env.N8N_INSTANCE_AI_REASONING_EFFORT = 'high';
try {
const agent = new Agent('test');
applyAgentThinking(agent, 'custom/Kimi-K3');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('custom', {
reasoningEffort: 'high',
});
} finally {
if (previous === undefined) delete process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
else process.env.N8N_INSTANCE_AI_REASONING_EFFORT = previous;
}
});
it('skips providers without thinking support', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'google/gemini-2.5-pro');
expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled();
});
it('skips OpenRouter models (thinking unsupported)', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'openrouter/moonshotai/kimi-k3');
expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled();
});
it('enables medium reasoning effort for Grok 4.5 via xAI', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'xai/grok-4.5');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('xai', {
reasoningEffort: 'medium',
});
});
it('skips xAI models that are not Grok 4.5', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'xai/grok-3');
expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled();
});
});

View File

@ -807,4 +807,44 @@ describe('createInstanceAgent', () => {
expect(mockAgentInstances[0]?.mcpConnectionFailures).not.toHaveBeenCalled();
});
it('sticks Modal endpoint requests to the conversation thread via Modal-Session-ID', async () => {
// Sub-agents (build-agent, eval-setup, etc.) reuse context.modelId / orchestration
// context.modelId, so mutating the sticky header here covers those paths too.
const modalModel = {
id: 'custom/moonshotai/Kimi-K3' as const,
url: 'https://n8ngmbh--ep-kimi-k3-server.us-west.modal.direct/v1',
apiKey: 'wk-test.ws-test',
};
const context = {
threadId: 'thread-sticky-1',
runLabel: 'modal-sticky-run',
localGatewayStatus: undefined,
licenseHints: undefined,
localMcpServer: undefined,
modelId: modalModel,
};
const orchestrationContext = {
runId: 'modal-sticky-run',
threadId: 'thread-sticky-1',
modelId: modalModel,
};
await createInstanceAgent({
modelId: modalModel,
context,
orchestrationContext,
memoryConfig: {},
mcpManager: createMcpManagerStub(),
thinkingEnabled: false,
} as never);
const stickyModel = {
...modalModel,
headers: { 'Modal-Session-ID': 'thread-sticky-1' },
};
expect(mockAgentInstances[0]?.model).toHaveBeenCalledWith(stickyModel);
expect(context.modelId).toEqual(stickyModel);
expect(orchestrationContext.modelId).toEqual(stickyModel);
});
});

View File

@ -2,6 +2,8 @@ import type { Agent, ModelConfig } from '@n8n/agents';
import { PROVIDER_CAPABILITIES } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { resolveCustomModelExperimentDefaultsFromEnv } from '../utils/custom-model-defaults';
function normalizeProvider(provider: string): string {
return provider.split('.')[0] ?? provider;
}
@ -33,18 +35,60 @@ function resolveModelProvider(modelId: ModelConfig): string | undefined {
return provider && model ? normalizeProvider(provider) : undefined;
}
function resolveModelIdString(modelId: ModelConfig): string | undefined {
if (typeof modelId === 'string') return modelId;
if (!isRecord(modelId)) return undefined;
const id = getStringProperty(modelId, 'id');
if (id) return id;
const provider = getStringProperty(modelId, 'provider') ?? getProviderFromConfig(modelId);
const model = getStringProperty(modelId, 'modelId');
return provider && model ? `${provider}/${model}` : undefined;
}
/** Grok 4.5 via xAI (`xai/grok-4.5`). */
function isGrok45Model(modelId: ModelConfig): boolean {
const id = resolveModelIdString(modelId)?.toLowerCase() ?? '';
return id.includes('grok-4.5');
}
/** GPT-5.6 family via OpenAI (`openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, `openai/gpt-5.6-luna`). */
function isGpt56Model(modelId: ModelConfig): boolean {
const id = resolveModelIdString(modelId)?.toLowerCase() ?? '';
return id.includes('gpt-5.6');
}
export function applyAgentThinking(agent: Agent, modelId: ModelConfig): void {
const provider = resolveModelProvider(modelId);
if (!provider || !PROVIDER_CAPABILITIES[provider]?.thinking) return;
if (provider === 'openai') {
agent.thinking('openai', { reasoningEffort: 'high' });
if (provider === 'custom') {
// No blanket custom default: env override → known-model map → omit.
const resolvedModelId = resolveModelIdString(modelId) ?? '';
const { reasoningEffort } = resolveCustomModelExperimentDefaultsFromEnv(resolvedModelId);
if (reasoningEffort !== undefined) {
agent.thinking('custom', { reasoningEffort });
}
return;
}
if (provider === 'anthropic') {
agent.thinking('anthropic', { mode: 'adaptive', effort: 'medium' });
if (provider === 'openai') {
agent.thinking('openai', {
reasoningEffort: isGpt56Model(modelId) ? 'medium' : 'high',
});
return;
}
if (provider === 'anthropic' || provider === 'google-vertex-anthropic') {
agent.thinking(provider, { mode: 'adaptive', effort: 'medium' });
return;
}
if (provider === 'xai') {
if (isGrok45Model(modelId)) {
agent.thinking('xai', { reasoningEffort: 'medium' });
}
}
}

View File

@ -24,7 +24,26 @@ import type {
CreateInstanceAgentOptions,
InstanceAiContext,
InstanceAiToolRegistry,
ModelConfig,
} from '../types';
import { withModalSession } from '../utils/modal-session';
function resolveModalSessionModelId(
modelId: ModelConfig,
context: InstanceAiContext,
orchestrationThreadId: string | undefined,
): ModelConfig {
const threadId = [context.threadId, orchestrationThreadId]
.map((value) => value?.trim())
.find((value): value is string => value !== undefined && value.length > 0);
if (!threadId) return modelId;
const stickyModelId = withModalSession(modelId, threadId);
if (stickyModelId === modelId) return modelId;
context.modelId = stickyModelId;
return stickyModelId;
}
// ── Agent factory ───────────────────────────────────────────────────────────
@ -53,7 +72,7 @@ export async function createInstanceAgent(
options: CreateInstanceAgentOptions,
): Promise<{ agent: Agent; mcpConnectionFailures: Array<{ server: string; error: string }> }> {
const {
modelId,
modelId: rawModelId,
context,
orchestrationContext,
mcpServers = [],
@ -61,6 +80,11 @@ export async function createInstanceAgent(
memoryConfig,
} = options;
const modelId = resolveModalSessionModelId(rawModelId, context, orchestrationContext?.threadId);
if (orchestrationContext && orchestrationContext.modelId !== modelId) {
orchestrationContext.modelId = modelId;
}
// Build native n8n domain tools (context captured via closures — per-run).
// Thread the trace handle in so domain tools (e.g. build-workflow) can emit
// explicit child runs that land on the active trace — orchestration tools

View File

@ -182,6 +182,8 @@ const loadValidateAttachments = lazyModule(
);
export { MAX_STEPS } from './constants/max-steps';
export { parseModelHeadersJson } from './utils/parse-model-headers';
export { resolveCustomModelExperimentDefaultsFromEnv } from './utils/custom-model-defaults';
export { WorkflowSaveConflictError } from './errors/workflow-save-conflict.error';
export { WorkflowNotFoundError } from './errors/workflow-not-found.error';
export {
@ -612,6 +614,7 @@ export type {
LocalMcpServer,
McpServerConfig,
ModelConfig,
VertexAnthropicModelConfig,
InstanceAiMemoryConfig,
CreateInstanceAgentOptions,
TaskStorage,

View File

@ -1400,16 +1400,22 @@ export interface InstanceAiMemoryConfig {
type NativeLanguageModelConfig = Extract<NativeModelConfig, { specificationVersion: string }>;
/** Direct Google Vertex Claude (Anthropic Messages via `:rawPredict`). */
export type VertexAnthropicModelConfig = {
id: `google-vertex-anthropic/${string}`;
project: string;
location: string;
googleCredentials?: string;
};
/** Model identifier: plain string for built-in providers, object for OpenAI-compatible endpoints,
* or a pre-built LanguageModel instance (e.g. from @ai-sdk/anthropic with a custom baseURL).
* Vertex Claude, or a pre-built LanguageModel instance (e.g. from @ai-sdk/anthropic with a custom baseURL).
*
* The LanguageModel variant exists for proxy routes that need a provider-native transport.
* For example, Vertex AI Anthropic routes use the native Messages API at `/v1/messages`, so
* we must use `@ai-sdk/anthropic` directly instead of routing through an OpenAI-compatible
* `/chat/completions` adapter. */
* The LanguageModel variant exists for proxy routes that need a provider-native transport. */
export type ModelConfig =
| string
| { id: `${string}/${string}`; url: string; apiKey?: string; headers?: Record<string, string> }
| VertexAnthropicModelConfig
| NativeLanguageModelConfig;
/** Configuration for routing requests through an AI service proxy (LangSmith tracing, Brave Search, etc.). */

View File

@ -0,0 +1,102 @@
import {
parseReasoningEffort,
parseSupportsStructuredOutputs,
resolveCustomModelExperimentDefaults,
resolveCustomModelExperimentDefaultsFromEnv,
} from '../custom-model-defaults';
describe('resolveCustomModelExperimentDefaults', () => {
it('maps known Kimi K3 models to low effort and structured outputs', () => {
expect(resolveCustomModelExperimentDefaults('custom/moonshotai/Kimi-K3')).toEqual({
reasoningEffort: 'low',
supportsStructuredOutputs: true,
});
});
it('maps GLM 5.2 models to medium effort', () => {
expect(resolveCustomModelExperimentDefaults('custom/zai-org/GLM-5.2-Fast')).toEqual({
reasoningEffort: 'medium',
supportsStructuredOutputs: true,
});
});
it('maps DeepSeek models to structured outputs only', () => {
expect(resolveCustomModelExperimentDefaults('custom/deepseek-ai/DeepSeek-V4-Pro')).toEqual({
supportsStructuredOutputs: true,
});
});
it('returns nothing for unknown custom models', () => {
expect(resolveCustomModelExperimentDefaults('custom/unknown-model')).toEqual({});
});
it('lets explicit overrides win over the model map', () => {
expect(
resolveCustomModelExperimentDefaults('custom/moonshotai/Kimi-K3', {
reasoningEffort: 'high',
supportsStructuredOutputs: false,
}),
).toEqual({
reasoningEffort: 'high',
supportsStructuredOutputs: false,
});
});
});
describe('resolveCustomModelExperimentDefaultsFromEnv', () => {
const previousEffort = process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
const previousStructured = process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS;
afterEach(() => {
if (previousEffort === undefined) delete process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
else process.env.N8N_INSTANCE_AI_REASONING_EFFORT = previousEffort;
if (previousStructured === undefined) {
delete process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS;
} else {
process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS = previousStructured;
}
});
it('reads env overrides ahead of the model map', () => {
process.env.N8N_INSTANCE_AI_REASONING_EFFORT = 'high';
process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS = 'false';
expect(resolveCustomModelExperimentDefaultsFromEnv('custom/moonshotai/Kimi-K3')).toEqual({
reasoningEffort: 'high',
supportsStructuredOutputs: false,
});
});
it('falls back to the model map when env is unset', () => {
delete process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
delete process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS;
expect(resolveCustomModelExperimentDefaultsFromEnv('custom/moonshotai/Kimi-K3')).toEqual({
reasoningEffort: 'low',
supportsStructuredOutputs: true,
});
});
it('falls back to the model map when env overrides are non-empty but invalid', () => {
process.env.N8N_INSTANCE_AI_REASONING_EFFORT = 'nope';
process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS = 'maybe';
expect(resolveCustomModelExperimentDefaultsFromEnv('custom/moonshotai/Kimi-K3')).toEqual({
reasoningEffort: 'low',
supportsStructuredOutputs: true,
});
});
});
describe('parse helpers', () => {
it('parses reasoning effort values', () => {
expect(parseReasoningEffort(' medium ')).toBe('medium');
expect(parseReasoningEffort('')).toBeUndefined();
expect(parseReasoningEffort('nope')).toBeUndefined();
});
it('parses structured-output flags', () => {
expect(parseSupportsStructuredOutputs('true')).toBe(true);
expect(parseSupportsStructuredOutputs('0')).toBe(false);
expect(parseSupportsStructuredOutputs('')).toBeUndefined();
expect(parseSupportsStructuredOutputs('maybe')).toBeUndefined();
});
});

View File

@ -25,6 +25,8 @@ const MODEL_ENV_KEYS = [
'N8N_INSTANCE_AI_MODEL',
'N8N_INSTANCE_AI_EVAL_MODEL',
'N8N_INSTANCE_AI_MODEL_API_KEY',
'N8N_INSTANCE_AI_MODEL_URL',
'EVAL_MODAL_LLM_HEADERS',
'N8N_AI_ANTHROPIC_KEY',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
@ -73,12 +75,12 @@ describe('eval agent model config', () => {
process.env.OPENAI_API_KEY = 'openai-key';
createEvalAgent('test-agent', {
model: 'openai/gpt-5.5',
model: 'openai/gpt-5.6-sol',
instructions: 'Do the task.',
});
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('openai', {
reasoningEffort: 'high',
reasoningEffort: 'medium',
});
});
@ -121,4 +123,91 @@ describe('eval agent model config', () => {
url: undefined,
});
});
it('supports custom endpoints that authenticate with EVAL_MODAL_LLM_HEADERS only', () => {
process.env.N8N_INSTANCE_AI_MODEL = 'custom/moonshotai/Kimi-K3';
process.env.N8N_INSTANCE_AI_MODEL_URL = 'https://example.modal.direct/v1';
process.env.EVAL_MODAL_LLM_HEADERS = '{"Modal-Key":"wk-test","Modal-Secret":"ws-test"}';
const config = resolveEvalModelConfig();
expect(config).toMatchObject({
modelId: 'custom/moonshotai/Kimi-K3',
apiKey: '',
url: 'https://example.modal.direct/v1',
headers: { 'Modal-Key': 'wk-test', 'Modal-Secret': 'ws-test' },
});
createEvalAgent('test-agent', { instructions: 'Do the task.' });
expect(mockAgentInstances[0]?.model).toHaveBeenCalledWith({
id: 'custom/moonshotai/Kimi-K3',
apiKey: '',
url: 'https://example.modal.direct/v1',
headers: { 'Modal-Key': 'wk-test', 'Modal-Secret': 'ws-test' },
});
});
it('supports keyless custom/* OpenAI-compatible routers (URL only, no API key)', () => {
process.env.N8N_INSTANCE_AI_MODEL = 'custom/Kimi-K3';
process.env.N8N_INSTANCE_AI_MODEL_URL = 'https://router.example.com/v1';
const config = resolveEvalModelConfig();
expect(config).toMatchObject({
modelId: 'custom/Kimi-K3',
provider: 'custom',
providerModelId: 'Kimi-K3',
apiKey: '',
url: 'https://router.example.com/v1',
});
createEvalAgent('test-agent', { instructions: 'Do the task.' });
expect(mockAgentInstances[0]?.model).toHaveBeenCalledWith({
id: 'custom/Kimi-K3',
apiKey: '',
url: 'https://router.example.com/v1',
});
});
it('still requires an API key for custom/* without a model URL', () => {
process.env.N8N_INSTANCE_AI_MODEL = 'custom/Kimi-K3';
expect(() => resolveEvalModelConfig()).toThrow(
/Missing API key for eval model "custom\/Kimi-K3"/,
);
});
it('keeps Anthropic eval model separate from a custom/* builder (no builder URL/key leak)', () => {
process.env.N8N_INSTANCE_AI_MODEL = 'custom/Kimi-K3';
process.env.N8N_INSTANCE_AI_MODEL_URL = 'https://router.example.com/v1';
process.env.N8N_INSTANCE_AI_MODEL_API_KEY = '';
process.env.N8N_INSTANCE_AI_EVAL_MODEL = 'anthropic/claude-sonnet-4-6';
process.env.ANTHROPIC_API_KEY = 'anthropic-eval-key';
const config = resolveEvalModelConfig();
expect(config).toEqual({
modelId: 'anthropic/claude-sonnet-4-6',
provider: 'anthropic',
providerModelId: 'claude-sonnet-4-6',
apiKey: 'anthropic-eval-key',
url: undefined,
headers: undefined,
});
});
it('prefers Anthropic keys over a non-Anthropic builder MODEL_API_KEY for the eval model', () => {
process.env.N8N_INSTANCE_AI_MODEL = 'openai/gpt-5.6-sol';
process.env.N8N_INSTANCE_AI_MODEL_API_KEY = 'openai-builder-key';
process.env.N8N_INSTANCE_AI_EVAL_MODEL = 'anthropic/claude-sonnet-4-6';
process.env.ANTHROPIC_API_KEY = 'anthropic-eval-key';
expect(resolveEvalModelConfig()).toMatchObject({
modelId: 'anthropic/claude-sonnet-4-6',
apiKey: 'anthropic-eval-key',
url: undefined,
});
});
});

View File

@ -0,0 +1,65 @@
import { MODAL_SESSION_HEADER, isModalModelEndpoint, withModalSession } from '../modal-session';
describe('isModalModelEndpoint', () => {
it('accepts Modal public hostnames', () => {
expect(isModalModelEndpoint('https://n8ngmbh--ep-kimi-k3-server.us-west.modal.direct/v1')).toBe(
true,
);
expect(isModalModelEndpoint('https://my-app--server.modal.run/v1')).toBe(true);
});
it('rejects non-Modal and invalid URLs', () => {
expect(isModalModelEndpoint('https://api.together.ai/v1')).toBe(false);
expect(isModalModelEndpoint('https://modal.example.com/v1')).toBe(false);
expect(isModalModelEndpoint('')).toBe(false);
expect(isModalModelEndpoint(undefined)).toBe(false);
expect(isModalModelEndpoint('not-a-url')).toBe(false);
});
});
describe('withModalSession', () => {
const modalConfig = {
id: 'custom/moonshotai/Kimi-K3' as const,
url: 'https://n8ngmbh--ep-kimi-k3-server.us-west.modal.direct/v1',
apiKey: 'wk-test.ws-test',
};
it('adds Modal-Session-ID for Modal endpoint configs', () => {
expect(withModalSession(modalConfig, 'thread-abc')).toEqual({
...modalConfig,
headers: { [MODAL_SESSION_HEADER]: 'thread-abc' },
});
});
it('preserves existing headers and overwrites a prior Modal-Session-ID', () => {
const withAuth = {
...modalConfig,
headers: { 'Modal-Key': 'wk-test', [MODAL_SESSION_HEADER]: 'old-thread' },
};
expect(withModalSession(withAuth, 'thread-new')).toEqual({
...modalConfig,
headers: {
'Modal-Key': 'wk-test',
[MODAL_SESSION_HEADER]: 'thread-new',
},
});
});
it('returns the same reference when the thread id is already set', () => {
const sticky = withModalSession(modalConfig, 'thread-abc');
expect(withModalSession(sticky, 'thread-abc')).toBe(sticky);
});
it('is a no-op for non-Modal endpoints and non-endpoint configs', () => {
const databricks = {
id: 'custom/workspace.default.kimi-k3' as const,
url: 'https://dbc-a5846527-f2d2.cloud.databricks.com/ai-gateway/mlflow/v1',
apiKey: 'dapi-test',
};
expect(withModalSession(databricks, 'thread-abc')).toBe(databricks);
expect(withModalSession('anthropic/claude-opus-4-8', 'thread-abc')).toBe(
'anthropic/claude-opus-4-8',
);
expect(withModalSession(modalConfig, ' ')).toBe(modalConfig);
});
});

View File

@ -0,0 +1,119 @@
import { z } from 'zod';
const customModelReasoningEffortSchema = z.enum([
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);
export type CustomModelReasoningEffort = z.infer<typeof customModelReasoningEffortSchema>;
export type CustomModelExperimentDefaults = {
reasoningEffort?: CustomModelReasoningEffort;
supportsStructuredOutputs?: boolean;
};
const customModelDefaultEntrySchema = z
.object({
match: z.string().min(1),
reasoningEffort: customModelReasoningEffortSchema.optional(),
supportsStructuredOutputs: z.boolean().optional(),
})
.strict();
type CustomModelDefaultEntry = z.infer<typeof customModelDefaultEntrySchema>;
/**
* Known custom/* model experiment defaults. First case-insensitive substring
* match wins. Add entries here when a new custom model needs stable knobs.
*/
const CUSTOM_MODEL_DEFAULTS = [
{
match: 'kimi-k3',
reasoningEffort: 'low',
supportsStructuredOutputs: true,
},
{
match: 'glm-5.2',
reasoningEffort: 'medium',
supportsStructuredOutputs: true,
},
{
match: 'deepseek',
supportsStructuredOutputs: true,
},
] satisfies CustomModelDefaultEntry[];
export function parseReasoningEffort(
value: string | undefined,
): CustomModelReasoningEffort | undefined {
const trimmed = value?.trim().toLowerCase();
if (!trimmed) return undefined;
const parsed = customModelReasoningEffortSchema.safeParse(trimmed);
return parsed.success ? parsed.data : undefined;
}
export function parseSupportsStructuredOutputs(value: string | undefined): boolean | undefined {
const trimmed = value?.trim().toLowerCase();
if (!trimmed) return undefined;
if (trimmed === 'true' || trimmed === '1') return true;
if (trimmed === 'false' || trimmed === '0') return false;
return undefined;
}
/**
* Resolve optional custom/* experiment knobs.
* Explicit overrides win; otherwise the first case-insensitive substring match
* in the shared model map is used. Unresolved fields stay undefined so callers
* can omit them from the request.
*/
export function resolveCustomModelExperimentDefaults(
modelId: string,
overrides: CustomModelExperimentDefaults = {},
): CustomModelExperimentDefaults {
const matched = CUSTOM_MODEL_DEFAULTS.find((entry) =>
modelId.toLowerCase().includes(entry.match.toLowerCase()),
);
const reasoningEffort = overrides.reasoningEffort ?? matched?.reasoningEffort;
const supportsStructuredOutputs =
overrides.supportsStructuredOutputs ?? matched?.supportsStructuredOutputs;
return {
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(supportsStructuredOutputs !== undefined ? { supportsStructuredOutputs } : {}),
};
}
export function resolveCustomModelExperimentDefaultsFromEnv(
modelId: string,
envOverrides: {
reasoningEffort?: string;
supportsStructuredOutputs?: string;
} = {},
): CustomModelExperimentDefaults {
const reasoningEffortRaw =
envOverrides.reasoningEffort ?? process.env.N8N_INSTANCE_AI_REASONING_EFFORT;
const supportsStructuredOutputsRaw =
envOverrides.supportsStructuredOutputs ??
process.env.N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS;
const reasoningEffort = parseReasoningEffort(reasoningEffortRaw);
const supportsStructuredOutputs = parseSupportsStructuredOutputs(supportsStructuredOutputsRaw);
const customConfig: CustomModelExperimentDefaults = {};
if (reasoningEffort !== undefined) {
customConfig.reasoningEffort = reasoningEffort;
}
if (supportsStructuredOutputs !== undefined) {
customConfig.supportsStructuredOutputs = supportsStructuredOutputs;
}
return resolveCustomModelExperimentDefaults(modelId, customConfig);
}

View File

@ -2,6 +2,7 @@
import { Agent, Tool, type GenerateResult, type ModelConfig } from '@n8n/agents';
import { parseModelHeadersJson } from './parse-model-headers';
import { applyAgentThinking } from '../agent/apply-agent-thinking';
export { Tool };
@ -30,6 +31,7 @@ export interface EvalModelConfig {
providerModelId: string;
apiKey: string;
url?: string;
headers?: Record<string, string>;
}
function getModelId(model?: string): string {
@ -41,16 +43,36 @@ function getModelId(model?: string): string {
return modelId;
}
/**
* True when the resolved model is the Instance AI builder model (or there is
* no separate builder model). False when resolving a dedicated eval model
* (N8N_INSTANCE_AI_EVAL_MODEL / explicit arg) that differs from the builder —
* in that case we must not reuse the builder's API key or custom base URL.
*/
function isResolvingBuilderModel(modelId: string): boolean {
const builderModel = process.env.N8N_INSTANCE_AI_MODEL?.trim();
if (!builderModel) return true;
return modelId === builderModel;
}
function getApiKey(modelId: string): string {
const [provider] = modelId.split('/');
const providerKeyEnv = PROVIDER_API_KEY_ENV[provider];
const providerKey = providerKeyEnv ? process.env[providerKeyEnv] : undefined;
const key =
process.env.N8N_INSTANCE_AI_MODEL_API_KEY ??
(provider === 'anthropic' ? process.env.N8N_AI_ANTHROPIC_KEY : undefined) ??
providerKey;
const anthropicLegacy = provider === 'anthropic' ? process.env.N8N_AI_ANTHROPIC_KEY : undefined;
const genericKey = process.env.N8N_INSTANCE_AI_MODEL_API_KEY;
// Builder model: prefer the lane's N8N_INSTANCE_AI_MODEL_API_KEY.
// Separate eval model (e.g. Anthropic mocks while builder is custom/openai):
// prefer provider-native keys so an OpenAI/empty builder key is not sent to Anthropic.
const key = isResolvingBuilderModel(modelId)
? (genericKey ?? anthropicLegacy ?? providerKey)
: (anthropicLegacy ?? providerKey ?? genericKey);
if (!key) {
// custom/* OpenAI-compatible routers may be keyless (URL only) or
// header-auth (URL + headers). Both are valid without an API key.
if (isResolvingBuilderModel(modelId) && allowsKeylessCustomEndpoint(provider)) return '';
throw new Error(
`Missing API key for eval model "${modelId}". Set N8N_INSTANCE_AI_MODEL_API_KEY${
provider === 'anthropic'
@ -70,6 +92,17 @@ function getModelUrl(): string | undefined {
return url;
}
function getModelHeaders(): Record<string, string> | undefined {
return parseModelHeadersJson(process.env.EVAL_MODAL_LLM_HEADERS);
}
function allowsKeylessCustomEndpoint(provider: string): boolean {
if (!getModelUrl()) return false;
if (getModelHeaders()) return true;
return provider === 'custom';
}
export function resolveEvalModelConfig(model?: string): EvalModelConfig {
const modelId = getModelId(model);
const [provider, ...rest] = modelId.split('/');
@ -78,12 +111,16 @@ export function resolveEvalModelConfig(model?: string): EvalModelConfig {
if (joinedProviderModelId.length > 0) {
providerModelId = joinedProviderModelId;
}
// Builder endpoint (URL/headers) only applies when resolving that builder model.
// A dedicated Anthropic eval model must hit Anthropic, not the custom/Foundry base.
const attachBuilderEndpoint = isResolvingBuilderModel(modelId);
return {
modelId,
provider,
providerModelId,
apiKey: getApiKey(modelId),
url: getModelUrl(),
url: attachBuilderEndpoint ? getModelUrl() : undefined,
headers: attachBuilderEndpoint ? getModelHeaders() : undefined,
};
}
@ -108,8 +145,13 @@ const CACHE_PROVIDER_OPTS = {
*/
function resolveAgentModel(model?: string, fallbackModelConfig?: ModelConfig): ModelConfig {
try {
const { modelId, apiKey, url } = resolveEvalModelConfig(model);
return { id: modelId, apiKey, url };
const { modelId, apiKey, url, headers } = resolveEvalModelConfig(model);
return {
id: modelId,
apiKey,
url,
...(headers ? { headers } : {}),
};
} catch (error) {
if (fallbackModelConfig) return fallbackModelConfig;
throw error;

View File

@ -0,0 +1,50 @@
import { isRecord } from '@n8n/utils/is-record';
import type { ModelConfig } from '../types';
export const MODAL_SESSION_HEADER = 'Modal-Session-ID';
export function isEndpointModelConfig(modelId: ModelConfig): modelId is {
id: `${string}/${string}`;
url: string;
apiKey?: string;
headers?: Record<string, string>;
supportsStructuredOutputs?: boolean;
} {
if (!isRecord(modelId)) return false;
if ('doGenerate' in modelId || 'specificationVersion' in modelId) return false;
if (!('url' in modelId) || !('id' in modelId)) return false;
const id = modelId.id;
const url = modelId.url;
if (typeof id !== 'string' || !id.includes('/')) return false;
if (typeof url !== 'string') return false;
return true;
}
export function isModalModelEndpoint(url: string | undefined): boolean {
if (!url?.trim()) return false;
try {
const host = new URL(url).hostname.toLowerCase();
return host.endsWith('.modal.direct') || host.endsWith('.modal.run');
} catch {
return false;
}
}
export function withModalSession(modelId: ModelConfig, threadId: string): ModelConfig {
const trimmedThreadId = threadId.trim();
if (!trimmedThreadId || !isEndpointModelConfig(modelId)) return modelId;
if (!isModalModelEndpoint(modelId.url)) return modelId;
const existing = modelId.headers?.[MODAL_SESSION_HEADER];
if (existing === trimmedThreadId) return modelId;
return {
...modelId,
headers: {
...modelId.headers,
[MODAL_SESSION_HEADER]: trimmedThreadId,
},
};
}

View File

@ -0,0 +1,28 @@
/**
* Parse eval-only LLM header JSON (`EVAL_MODAL_LLM_HEADERS`) — a JSON object of
* HTTP header names to string values (e.g. Modal proxy auth).
*/
export function parseModelHeadersJson(raw: string | undefined): Record<string, string> | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return undefined;
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof key !== 'string' || !key.trim()) continue;
if (typeof value !== 'string') continue;
headers[key.trim()] = value;
}
return Object.keys(headers).length > 0 ? headers : undefined;
}

View File

@ -566,12 +566,20 @@ describe('setupSandboxWorkspace', () => {
});
it('retries packing linked workspace packages after a null pack result', async () => {
const utilsTarball = Buffer.from('utils');
const workflowTarball = Buffer.from('workflow');
const sdkTarball = Buffer.from('sdk');
const packSandboxLinkedWorkspacePackages = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce([
{
filename: 'n8n-utils.tgz',
tarball: utilsTarball,
version: '1.41.0',
packageName: '@n8n/utils',
packagePath: '/host/utils',
},
{
filename: 'n8n-workflow.tgz',
tarball: workflowTarball,
@ -622,6 +630,9 @@ describe('setupSandboxWorkspace', () => {
await linkWorkspaceSdkIfEnabled(workspace, '/workspace', logger);
expect(packSandboxLinkedWorkspacePackages).toHaveBeenCalledTimes(2);
expect(writeFile).toHaveBeenCalledWith('/workspace/n8n-utils.tgz', utilsTarball, {
recursive: true,
});
expect(writeFile).toHaveBeenCalledWith('/workspace/n8n-workflow.tgz', workflowTarball, {
recursive: true,
});
@ -631,7 +642,7 @@ describe('setupSandboxWorkspace', () => {
expect(runInSandbox).toHaveBeenCalledWith(
workspace,
expect.stringContaining(
"npm install '/workspace/n8n-workflow.tgz' '/workspace/workflow-sdk.tgz'",
"npm install '/workspace/n8n-utils.tgz' '/workspace/n8n-workflow.tgz' '/workspace/workflow-sdk.tgz'",
),
'/workspace',
);

View File

@ -11,9 +11,10 @@
* packages into tarballs on the host so the sandbox can `npm install` them
* post-creation and override the registry copies.
*
* Both `@n8n/workflow-sdk` and its `n8n-workflow` dependency are linked:
* packing only the SDK still leaves npm's `n8n-workflow` in place, which
* breaks when master is ahead of the registry (e.g. unreleased exports).
* `@n8n/workflow-sdk`, `n8n-workflow`, and `@n8n/utils` are linked together:
* packing only the SDK still leaves npm's copies of those deps in place, which
* breaks when master is ahead of the registry (e.g. unreleased exports like
* `@n8n/utils/sleep`).
*
* We use `pnpm pack` (not `npm pack`) because the workspace `package.json`
* uses pnpm protocols (`workspace:*`, `catalog:`) that npm can't resolve;
@ -35,7 +36,11 @@ const execFileAsync = promisify(execFile);
const ENV_FLAG = 'N8N_INSTANCE_AI_SANDBOX_LINK_SDK';
/** Packages installed into the sandbox when workspace linking is enabled. */
export const SANDBOX_LINKED_WORKSPACE_PACKAGES = ['n8n-workflow', '@n8n/workflow-sdk'] as const;
export const SANDBOX_LINKED_WORKSPACE_PACKAGES = [
'@n8n/utils',
'n8n-workflow',
'@n8n/workflow-sdk',
] as const;
export interface WorkspacePackageTarball {
/** Raw tarball bytes, ready to upload to the sandbox. */
@ -130,7 +135,7 @@ export async function packSandboxLinkedWorkspacePackages(
const tarball = await packWorkspacePackage(logger, packageName);
if (!tarball) {
throw new Error(
`${ENV_FLAG} is enabled, but ${packageName} could not be packed. Run \`pnpm build\` for packages/workflow and packages/@n8n/workflow-sdk, or unset ${ENV_FLAG}.`,
`${ENV_FLAG} is enabled, but ${packageName} could not be packed. Run \`pnpm build\` for packages/@n8n/utils, packages/workflow, and packages/@n8n/workflow-sdk, or unset ${ENV_FLAG}.`,
);
}
packed.push(tarball);
@ -159,9 +164,18 @@ export async function packWorkspaceSdk(
function resolvePackagePath(name: string): string | null {
try {
const pkgJsonPath = hostRequire.resolve(`${name}/package.json`);
return path.dirname(pkgJsonPath);
return path.dirname(hostRequire.resolve(`${name}/package.json`));
} catch {
// Packages that omit a `package.json` export still live under node_modules.
for (const base of hostRequire.resolve.paths(name) ?? []) {
const candidate = path.join(base, name, 'package.json');
try {
hostRequire(candidate);
return path.dirname(candidate);
} catch {
// keep looking
}
}
return null;
}
}

View File

@ -190,7 +190,7 @@ export async function linkWorkspaceSdkIfEnabled(
if (!packedPackages?.length) {
linkedPackagesPromise = null;
throw new Error(
'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but workspace packages could not be packed. Run `pnpm build` in packages/workflow and packages/@n8n/workflow-sdk, or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.',
'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but workspace packages could not be packed. Run `pnpm build` in packages/@n8n/utils, packages/workflow, and packages/@n8n/workflow-sdk, or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.',
);
}

View File

@ -13,5 +13,5 @@
},
"types": ["node", "vitest/globals", "vite/client"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.json"]
}

View File

@ -13,7 +13,13 @@ import { BUILDER_NOT_CONFIGURED_CODE } from '@n8n/api-types';
import { AgentsBuilderSettingsService } from '../agents-builder-settings.service';
import { BuilderNotConfiguredError } from '../errors';
const ENV_KEYS = ['N8N_AI_ANTHROPIC_KEY', 'ANTHROPIC_API_KEY'] as const;
const ENV_KEYS = [
'N8N_AI_ANTHROPIC_KEY',
'ANTHROPIC_API_KEY',
'N8N_INSTANCE_AI_MODEL',
'N8N_INSTANCE_AI_MODEL_API_KEY',
'N8N_INSTANCE_AI_MODEL_URL',
] as const;
function makeJwt(exp: number): string {
const header = Buffer.from(JSON.stringify({ alg: 'HS256' })).toString('base64url');
@ -110,6 +116,24 @@ describe('AgentsBuilderSettingsService', () => {
);
});
it('mode=default + proxy disabled + Instance AI env set → returns Instance AI model config', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);
process.env.N8N_INSTANCE_AI_MODEL = 'custom/Kimi-K3';
process.env.N8N_INSTANCE_AI_MODEL_API_KEY = 'custom-key';
process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env';
const result = await service.resolveModelConfig(user);
expect(result).toEqual({
config: {
id: 'custom/Kimi-K3',
apiKey: 'custom-key',
},
isProxied: false,
});
});
it('mode=default + proxy disabled + env set → returns env-var anthropic config', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);

View File

@ -368,7 +368,7 @@ describe('AgentsBuilderService session isolation', () => {
it.each([
['Anthropic', 'anthropic/claude-sonnet-host-resolved'],
['OpenAI', 'openai/gpt-5.5'],
['OpenAI', 'openai/gpt-5.6-sol'],
['Google', 'google/gemini-2.5-pro'],
])('enables generic reasoning for a %s builder model', async (_provider, modelConfig) => {
const { service, user, credentialProvider } = setup();

View File

@ -40,6 +40,24 @@ function readEnvAnthropicKey(): string | null {
return key && key.length > 0 ? key : null;
}
/**
* Dev/CI backstop: reuse the Instance AI orchestrator model env so local setups
* that already point Instance AI at a custom OpenAI-compatible endpoint also
* configure the agent-builder settings path without a separate credential.
*/
function readEnvInstanceAiModelConfig(): ModelConfig | null {
const model = process.env.N8N_INSTANCE_AI_MODEL?.trim();
const apiKey = process.env.N8N_INSTANCE_AI_MODEL_API_KEY?.trim();
if (!model || !model.includes('/') || !apiKey) return null;
const modelUrl = process.env.N8N_INSTANCE_AI_MODEL_URL?.trim();
const id = model as `${string}/${string}`;
if (modelUrl) {
return { id, url: modelUrl, apiKey };
}
return { id, apiKey };
}
interface BuilderTelemetryProxyConfig {
apiUrl: string;
getAuthHeaders: () => Promise<Record<string, string>>;
@ -56,7 +74,10 @@ interface ResolvedBuilderModelConfig {
* the builder LLM runs on:
* 1. `mode: 'custom'` — admin-picked credential. Wins regardless of platform.
* 2. `mode: 'default'` + AI proxy enabled — runs through the n8n AI assistant proxy.
* 3. `mode: 'default'` + env-var backstop set (`N8N_AI_ANTHROPIC_KEY` /
* 3. `mode: 'default'` + Instance AI model env (`N8N_INSTANCE_AI_MODEL` +
* `N8N_INSTANCE_AI_MODEL_API_KEY`) — same provider/model as Instance AI
* (e.g. `custom/Kimi-K3` or `openai/gpt-5-mini`).
* 4. `mode: 'default'` + Anthropic env backstop (`N8N_AI_ANTHROPIC_KEY` /
* `ANTHROPIC_API_KEY`) — direct Anthropic API calls.
*/
@Service()
@ -151,6 +172,11 @@ export class AgentsBuilderSettingsService {
return await this.resolveProxyModel(user);
}
const instanceAiEnv = readEnvInstanceAiModelConfig();
if (instanceAiEnv) {
return { config: instanceAiEnv, isProxied: false };
}
const envKey = readEnvAnthropicKey();
if (envKey) {
return {

View File

@ -90,6 +90,8 @@ describe('InstanceAiSettingsService', () => {
vi.stubEnv('N8N_INSTANCE_AI_MODEL', '');
vi.stubEnv('OPENAI_API_KEY', '');
vi.stubEnv('ANTHROPIC_API_KEY', '');
vi.stubEnv('GOOGLE_VERTEX_PROJECT', '');
vi.stubEnv('GOOGLE_VERTEX_LOCATION', '');
persistedSettingsValue = undefined;
logger.scoped.mockReturnValue(logger);
Container.set(Logger, logger);
@ -107,6 +109,9 @@ describe('InstanceAiSettingsService', () => {
searxngUrl: '',
daytonaApiUrl: '',
daytonaApiKey: '',
vertexProjectId: '',
vertexLocation: '',
vertexServiceAccountJson: '',
});
globalConfig.deployment.type = 'default';
instanceCredentialBroker.listForUse.mockResolvedValue([]);
@ -1797,6 +1802,23 @@ describe('InstanceAiSettingsService', () => {
expect((await service.getAdminSettings()).modelEnvConfigured).toBe(true);
});
it('reports Vertex as env-configured only when a project id is resolvable', async () => {
globalConfig.instanceAi.model = 'google-vertex-anthropic/claude-opus-4-8';
expect((await service.getAdminSettings()).modelEnvConfigured).toBe(false);
globalConfig.instanceAi.vertexServiceAccountJson =
'{"client_email":"svc@example.com","private_key":"k"}';
expect((await service.getAdminSettings()).modelEnvConfigured).toBe(false);
globalConfig.instanceAi.vertexServiceAccountJson =
'{"project_id":"from-json","client_email":"svc@example.com","private_key":"k"}';
expect((await service.getAdminSettings()).modelEnvConfigured).toBe(true);
globalConfig.instanceAi.vertexServiceAccountJson = '';
globalConfig.instanceAi.vertexProjectId = 'from-env';
expect((await service.getAdminSettings()).modelEnvConfigured).toBe(true);
});
it('reports environment configuration for the selected sandbox provider', async () => {
globalConfig.instanceAi.sandboxProvider = 'daytona';
globalConfig.instanceAi.daytonaApiKey = 'dtn-key';
@ -1985,6 +2007,66 @@ describe('InstanceAiSettingsService', () => {
globalConfig.instanceAi.modelApiKey = '';
await expect(service.resolveModelConfig(mock<User>())).resolves.toBe('openai/gpt-4');
});
it('builds google-vertex-anthropic configs from Vertex environment variables', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
vi.stubEnv('N8N_INSTANCE_AI_MODEL', 'google-vertex-anthropic/claude-opus-4-8');
Object.assign(globalConfig.instanceAi, {
model: 'google-vertex-anthropic/claude-opus-4-8',
modelUrl: '',
modelApiKey: '',
vertexProjectId: 'instance-ai-494613',
vertexLocation: 'global',
vertexServiceAccountJson: '{"client_email":"svc@example.com","private_key":"k"}',
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'instance-ai-494613',
location: 'global',
googleCredentials: '{"client_email":"svc@example.com","private_key":"k"}',
});
});
it('derives the Vertex project from service-account JSON when env project is unset', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
vi.stubEnv('N8N_INSTANCE_AI_MODEL', 'google-vertex-anthropic/claude-opus-4-8');
Object.assign(globalConfig.instanceAi, {
model: 'google-vertex-anthropic/claude-opus-4-8',
modelUrl: '',
modelApiKey: '',
vertexProjectId: '',
vertexLocation: '',
vertexServiceAccountJson:
'{"project_id":"from-json","client_email":"svc@example.com","private_key":"k"}',
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'from-json',
location: 'global',
googleCredentials:
'{"project_id":"from-json","client_email":"svc@example.com","private_key":"k"}',
});
});
it('falls back to GOOGLE_VERTEX_LOCATION when the n8n Vertex location env is empty', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
vi.stubEnv('N8N_INSTANCE_AI_MODEL', 'google-vertex-anthropic/claude-opus-4-8');
vi.stubEnv('GOOGLE_VERTEX_LOCATION', 'us-east5');
Object.assign(globalConfig.instanceAi, {
model: 'google-vertex-anthropic/claude-opus-4-8',
modelUrl: '',
modelApiKey: '',
vertexProjectId: 'instance-ai-494613',
vertexLocation: '',
vertexServiceAccountJson: '{"client_email":"svc@example.com","private_key":"k"}',
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toMatchObject({
location: 'us-east5',
});
});
});
describe('isSetupCompleted', () => {

View File

@ -22,7 +22,11 @@ import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config';
import { DbLock, DbLockService, SettingsRepository, UserRepository } from '@n8n/db';
import type { CredentialsEntity, ICredentialsDb, OperationContext, User } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import type { ModelConfig } from '@n8n/instance-ai';
import {
resolveCustomModelExperimentDefaultsFromEnv,
type ModelConfig,
type VertexAnthropicModelConfig,
} from '@n8n/instance-ai';
import { hasGlobalScope } from '@n8n/permissions';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { ICredentialDataDecryptedObject, IUserSettings } from 'n8n-workflow';
@ -162,6 +166,25 @@ function modelCredentialHeaders(
return Object.keys(headers).length ? headers : undefined;
}
function isVertexAnthropicModelId(
id: `${string}/${string}`,
): id is `google-vertex-anthropic/${string}` {
return id.startsWith('google-vertex-anthropic/');
}
/** `project_id` from a GCP service-account JSON blob, if present and parseable. */
function projectIdFromServiceAccountJson(json: string | undefined): string {
if (!json?.trim()) return '';
try {
const parsed: unknown = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return '';
const projectId = (parsed as Record<string, unknown>).project_id;
return typeof projectId === 'string' ? projectId.trim() : '';
} catch {
return '';
}
}
function validateSearchCredential({
type,
data,
@ -380,14 +403,20 @@ export class InstanceAiSettingsService {
credentialSelection: AdminCredentialSelection,
): InstanceAiAdminSettingsResponse {
const c = this.config;
const modelProviderApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(c.model.split('/', 1)[0] ?? '');
const modelProvider = c.model.split('/', 1)[0] ?? '';
const modelProviderApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(modelProvider);
const isProxyEnabled = this.aiService.isProxyEnabled();
const isManaged = this.isCloud || isProxyEnabled;
const providerModelApiKeyConfigured = Boolean(
modelProviderApiKeyEnv && process.env[modelProviderApiKeyEnv]?.trim(),
);
const vertexModelEnvConfigured =
modelProvider === 'google-vertex-anthropic' && Boolean(this.resolveVertexProjectId());
const modelConnectionEnvConfigured = Boolean(
c.modelApiKey.trim() || c.modelUrl.trim() || providerModelApiKeyConfigured,
c.modelApiKey.trim() ||
c.modelUrl.trim() ||
providerModelApiKeyConfigured ||
vertexModelEnvConfigured,
);
const sandboxEnvConfigured = this.hasEnvironmentSandboxConnection();
const searchEnvConfigured = this.hasEnvironmentSearchConnection();
@ -1351,11 +1380,12 @@ export class InstanceAiSettingsService {
const provider = config.includes('/') ? config.slice(0, config.indexOf('/')) : 'custom';
return `${provider}/${modelName}`;
}
if ('id' in config && typeof config.id === 'string') {
if ('id' in config && typeof config.id === 'string' && 'url' in config) {
const provider = config.id.includes('/')
? config.id.slice(0, config.id.indexOf('/'))
: 'custom';
return { ...config, id: `${provider}/${modelName}` };
const id: `${string}/${string}` = `${provider}/${modelName}`;
return { ...config, id };
}
return config;
}
@ -1571,6 +1601,9 @@ export class InstanceAiSettingsService {
private hasEnvironmentModelConnection(): boolean {
const provider = this.config.model.split('/', 1)[0] ?? '';
if (provider === 'google-vertex-anthropic') {
return Boolean(this.resolveVertexProjectId());
}
const providerApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(provider);
return Boolean(
this.config.modelApiKey.trim() ||
@ -1604,18 +1637,71 @@ export class InstanceAiSettingsService {
const id: `${string}/${string}` = model.includes('/')
? (model as `${string}/${string}`)
: `custom/${model}`;
const customOptions = this.customModelOptionsFor(id);
const vertexConfig = this.vertexAnthropicModelConfig(id);
if (vertexConfig) return vertexConfig;
if (modelUrl) {
return { id, url: modelUrl, ...(modelApiKey ? { apiKey: modelApiKey } : {}) };
return {
id,
url: modelUrl,
...(modelApiKey ? { apiKey: modelApiKey } : {}),
...customOptions,
};
}
if (modelApiKey) {
return { id, url: '', apiKey: modelApiKey };
return {
id,
url: '',
apiKey: modelApiKey,
...customOptions,
};
}
return model;
}
private resolveVertexProjectId(): string {
return (
this.config.vertexProjectId?.trim() ||
process.env.GOOGLE_VERTEX_PROJECT?.trim() ||
projectIdFromServiceAccountJson(this.config.vertexServiceAccountJson) ||
''
);
}
private resolveVertexLocation(): string {
return (
this.config.vertexLocation?.trim() || process.env.GOOGLE_VERTEX_LOCATION?.trim() || 'global'
);
}
/**
* Build a typed Vertex Anthropic model config from Instance AI env vars.
* Returns null when the model id is not `google-vertex-anthropic/*`.
*/
private vertexAnthropicModelConfig(id: `${string}/${string}`): VertexAnthropicModelConfig | null {
if (!isVertexAnthropicModelId(id)) return null;
const googleCredentials = this.config.vertexServiceAccountJson?.trim() || undefined;
return {
id,
project: this.resolveVertexProjectId(),
location: this.resolveVertexLocation(),
...(googleCredentials ? { googleCredentials } : {}),
};
}
/** Optional custom/* knobs from env override → known-model map → omit. */
private customModelOptionsFor(modelId: string): { supportsStructuredOutputs?: boolean } {
if (!modelId.startsWith('custom/')) return {};
const { supportsStructuredOutputs } = resolveCustomModelExperimentDefaultsFromEnv(modelId);
return supportsStructuredOutputs !== undefined ? { supportsStructuredOutputs } : {};
}
private extractModelName(model: string): string {
const slash = model.indexOf('/');
return slash >= 0 ? model.slice(slash + 1) : model;

View File

@ -13,9 +13,12 @@ describe('provider-capabilities', () => {
expect([...REASONING_EFFORT_OPTIONS]).toEqual(['low', 'medium', 'high']);
});
it('uses budget-tokens for Anthropic and reasoning-effort for OpenAI', () => {
it('uses budget-tokens for Anthropic and reasoning-effort for OpenAI/xAI/custom', () => {
expect(PROVIDER_CAPABILITIES.anthropic.thinking).toBe('budgetTokens');
expect(PROVIDER_CAPABILITIES.openai.thinking).toBe('reasoningEffort');
expect(PROVIDER_CAPABILITIES.openrouter.thinking).toBe(false);
expect(PROVIDER_CAPABILITIES.xai.thinking).toBe('reasoningEffort');
expect(PROVIDER_CAPABILITIES.custom.thinking).toBe('reasoningEffort');
});
it('keeps the canonical Anthropic cache-ttl order', () => {
@ -36,6 +39,7 @@ describe('provider-capabilities', () => {
'deepseek',
'mistral',
'openrouter',
'custom',
'cohere',
'ollama',
];
@ -45,16 +49,7 @@ describe('provider-capabilities', () => {
});
it('marks providers without thinking support as `false`', () => {
const noThinking = [
'google',
'xai',
'groq',
'deepseek',
'mistral',
'openrouter',
'cohere',
'ollama',
];
const noThinking = ['google', 'groq', 'deepseek', 'mistral', 'cohere', 'ollama'];
for (const provider of noThinking) {
expect(PROVIDER_CAPABILITIES[provider]?.thinking).toBe(false);
}

View File

@ -27,6 +27,9 @@ catalogs:
'@ai-sdk/google':
specifier: ^4.0.24
version: 4.0.24
'@ai-sdk/google-vertex':
specifier: ^5.0.47
version: 5.0.48
'@ai-sdk/groq':
specifier: ^4.0.13
version: 4.0.13
@ -966,6 +969,9 @@ importers:
'@ai-sdk/google':
specifier: 'catalog:'
version: 4.0.24(zod@3.25.76)
'@ai-sdk/google-vertex':
specifier: 'catalog:'
version: 5.0.48(zod@3.25.76)
'@ai-sdk/groq':
specifier: 'catalog:'
version: 4.0.13(zod@3.25.76)
@ -1120,7 +1126,7 @@ importers:
version: 1.0.27(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(cheerio@1.1.0)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/community':
specifier: 'catalog:'
version: 1.1.27(b405db3381a109939c4610cb9de005a5)
version: 1.1.27(d4d69bb701b08e92536f507cd2608666)
'@langchain/core':
specifier: 'catalog:'
version: 1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
@ -3198,7 +3204,7 @@ importers:
version: 1.0.1(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(encoding@0.1.13)
'@langchain/community':
specifier: 'catalog:'
version: 1.1.27(6421211f9ce0f081394dd8f3f58c2bce)
version: 1.1.27(ff1df20be70f541448eb6f5cafd5a6b6)
'@langchain/core':
specifier: 'catalog:'
version: 1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
@ -7223,6 +7229,12 @@ packages:
peerDependencies:
zod: 3.25.76
'@ai-sdk/anthropic@4.0.36':
resolution: {integrity: sha512-Wg5jfray0X4+qkrr73GZ7U7K2JNGx+S+rNY9LorVlXhkhTqnvtNLYWRA1WxSxlCDCw3yjRCJdL9kyc+b7naAog==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/azure@4.0.21':
resolution: {integrity: sha512-2DbEPXIdj6dvc30tPE7Rz+t3ELbjOvIDawAHUvLIipc15sY7+uvS203jh1cjiPUyZ+NAIa3B+HWEpHrjlAoyKg==}
engines: {node: '>=22'}
@ -7247,12 +7259,24 @@ packages:
peerDependencies:
zod: 3.25.76
'@ai-sdk/google-vertex@5.0.48':
resolution: {integrity: sha512-04QG2F+q5ZZ3WqOaaZqj0J+pTsKVSUTJs43oIwk7Nx1MyZsHutbWiHAj48BXYi8InnnIDj65WL1pSZJuciAcTw==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/google@4.0.24':
resolution: {integrity: sha512-W+kTGb/RRe2SEYwbKcKYM3N2EQryS2Iqjgou6wPsyP/2BwBU7Q23CF6/YWE8k7xtseDLcSY59jsR2MDpY4azRg==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/google@4.0.39':
resolution: {integrity: sha512-+mRx7UBZn9PkJ4J6YXowaRZKMZYa290cknVqqOw/roZaDg186IUOLn9JHNQkvgaj91/MLW1AZNESy1ZD2yXDCg==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/groq@4.0.13':
resolution: {integrity: sha512-bEUGDm3v3X/LvrpMioPg8NhaFBmwt+MjFa0cFdQVOJOT6LBLlGEYwr70g8B2/ttjZj4fW2qIhM/m8DDoYpXdrg==}
engines: {node: '>=22'}
@ -7271,6 +7295,12 @@ packages:
peerDependencies:
zod: 3.25.76
'@ai-sdk/openai-compatible@3.0.28':
resolution: {integrity: sha512-vF/852mCFiASq2fAPE1mA0E0w78NH68/X2Gukuh4TeJrMIp+CJfoR5QLuY2+pTx7UiLmuYWDpCQhWaupuM7+Xg==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/openai@4.0.20':
resolution: {integrity: sha512-/Hu+btLIO2zuYqCp//vBBAGdM/BeN4gds+NWrlv7Y9WrcTXwnEEwh6P3QZIWh2Tt1I1lswuDIpAJ4t/c/rws3Q==}
engines: {node: '>=22'}
@ -7293,6 +7323,12 @@ packages:
peerDependencies:
zod: 3.25.76
'@ai-sdk/provider-utils@5.0.25':
resolution: {integrity: sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA==}
engines: {node: '>=22'}
peerDependencies:
zod: 3.25.76
'@ai-sdk/provider@4.0.3':
resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==}
engines: {node: '>=22'}
@ -7301,6 +7337,10 @@ packages:
resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==}
engines: {node: '>=22'}
'@ai-sdk/provider@4.0.7':
resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==}
engines: {node: '>=22'}
'@ai-sdk/xai@4.0.18':
resolution: {integrity: sha512-2hrfujGCzNj89+BkREXU3siHlutdWcDMMkKc/+RITRk8T8NPIuAZ4yF8g1/2W+Xessbqz+4PQP7al9HPTfbimg==}
engines: {node: '>=22'}
@ -16744,8 +16784,8 @@ packages:
resolution: {integrity: sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==}
engines: {node: '>=14'}
gcp-metadata@7.0.0:
resolution: {integrity: sha512-3PfRTzvT3Msu0Hy8Gf9ypxJvaClG2IB9pyH0r8QOmRBW5mUcrHgYpF4GYP+XulDbfhxEhBYtJtJJQb5S2wM+LA==}
gcp-metadata@8.1.2:
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
engines: {node: '>=18'}
generate-function@2.3.1:
@ -16922,8 +16962,8 @@ packages:
engines: {node: '>=0.6.0'}
hasBin: true
google-auth-library@10.1.0:
resolution: {integrity: sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==}
google-auth-library@10.9.1:
resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==}
engines: {node: '>=18'}
google-auth-library@9.15.1:
@ -16934,8 +16974,8 @@ packages:
resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==}
engines: {node: '>=14'}
google-logging-utils@1.1.1:
resolution: {integrity: sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==}
google-logging-utils@1.1.3:
resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
engines: {node: '>=14'}
gopd@1.2.0:
@ -16968,10 +17008,6 @@ packages:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
gtoken@8.0.0:
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
engines: {node: '>=18'}
handlebars@4.7.9:
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
engines: {node: '>=0.4.7'}
@ -23270,6 +23306,12 @@ snapshots:
'@ai-sdk/provider-utils': 5.0.18(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/anthropic@4.0.36(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.7
'@ai-sdk/provider-utils': 5.0.25(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/azure@4.0.21(zod@3.25.76)':
dependencies:
'@ai-sdk/deepseek': 3.0.13(zod@3.25.76)
@ -23297,12 +23339,30 @@ snapshots:
'@vercel/oidc': 3.2.0
zod: 3.25.76
'@ai-sdk/google-vertex@5.0.48(zod@3.25.76)':
dependencies:
'@ai-sdk/anthropic': 4.0.36(zod@3.25.76)
'@ai-sdk/google': 4.0.39(zod@3.25.76)
'@ai-sdk/openai-compatible': 3.0.28(zod@3.25.76)
'@ai-sdk/provider': 4.0.7
'@ai-sdk/provider-utils': 5.0.25(zod@3.25.76)
google-auth-library: 10.9.1
zod: 3.25.76
transitivePeerDependencies:
- supports-color
'@ai-sdk/google@4.0.24(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.3
'@ai-sdk/provider-utils': 5.0.12(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/google@4.0.39(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.7
'@ai-sdk/provider-utils': 5.0.25(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/groq@4.0.13(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.3
@ -23321,6 +23381,12 @@ snapshots:
'@ai-sdk/provider-utils': 5.0.12(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai-compatible@3.0.28(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.7
'@ai-sdk/provider-utils': 5.0.25(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai@4.0.20(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.3
@ -23352,6 +23418,15 @@ snapshots:
undici: 7.29.0
zod: 3.25.76
'@ai-sdk/provider-utils@5.0.25(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.7
'@standard-schema/spec': 1.1.0
'@workflow/serde': 4.1.0
eventsource-parser: 3.0.8
undici: 7.29.0
zod: 3.25.76
'@ai-sdk/provider@4.0.3':
dependencies:
json-schema: 0.4.0
@ -23360,6 +23435,10 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@4.0.7':
dependencies:
json-schema: 0.4.0
'@ai-sdk/xai@4.0.18(zod@3.25.76)':
dependencies:
'@ai-sdk/openai-compatible': 3.0.14(zod@3.25.76)
@ -26849,7 +26928,62 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@1.1.27(6421211f9ce0f081394dd8f3f58c2bce)':
'@langchain/community@1.1.27(d4d69bb701b08e92536f507cd2608666)':
dependencies:
'@browserbasehq/stagehand': 1.14.0(@playwright/test@1.60.0)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@17.4.2)(encoding@0.1.13)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(utf-8-validate@5.0.10)(zod@3.25.76)
'@ibm-cloud/watsonx-ai': 1.1.2
'@langchain/classic': 1.0.27(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(cheerio@1.1.0)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/core': 1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/openai': 1.4.1(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
binary-extensions: 2.2.0
flat: 5.0.2
ibm-cloud-sdk-core: 5.5.0
js-yaml: 4.3.1
langsmith: 0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
math-expression-evaluator: 2.0.7
openai: 6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)
uuid: 13.0.1
zod: 3.25.76
optionalDependencies:
'@aws-crypto/sha256-js': 5.2.0
'@azure/storage-blob': 12.32.0
'@browserbasehq/sdk': 2.12.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.6(d33d83a49ccae0e9b43f6f5606890e08)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
'@libsql/client': 0.17.2(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)
'@mozilla/readability': 0.6.0
'@smithy/eventstream-codec': 4.4.14
'@smithy/protocol-http': 5.3.12
'@smithy/util-utf8': 4.4.14
'@supabase/supabase-js': 2.50.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@zilliz/milvus2-sdk-node': 2.5.7
chromadb: 3.2.0
crypto-js: 4.2.0
discord.js: 14.27.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
epub2: 3.0.2(ts-toolbelt@9.6.0)
fast-xml-parser: 5.7.2
google-auth-library: 10.9.1
html-to-text: 9.0.5
ignore: 7.0.5
ioredis: 5.3.2
jsdom: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
jsonwebtoken: 9.0.3
lodash: 4.18.1(patch_hash=81ead1b07fc8efda87bd2724dbd876ab49f4a0aa03f96c3770060b2ea47d9e0b)
mammoth: 1.12.0
mysql2: 3.23.1(@types/node@20.19.41)
pdf-parse: 2.4.5
pg: 8.21.0(pg-native@3.8.0)
playwright: 1.60.0
puppeteer: 24.41.0(bufferutil@4.0.9)(typescript@7.0.2)(utf-8-validate@5.0.10)
ws: 8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- '@smithy/hash-node'
- peggy
'@langchain/community@1.1.27(ff1df20be70f541448eb6f5cafd5a6b6)':
dependencies:
'@browserbasehq/stagehand': 1.14.0(@playwright/test@1.60.0)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@17.4.2)(encoding@0.1.13)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(utf-8-validate@5.0.10)(zod@3.25.76)
'@ibm-cloud/watsonx-ai': 1.1.2
@ -26892,7 +27026,7 @@ snapshots:
discord.js: 14.27.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
epub2: 3.0.2(ts-toolbelt@9.6.0)
fast-xml-parser: 5.7.2
google-auth-library: 10.1.0
google-auth-library: 10.9.1
html-to-text: 9.0.5
ignore: 5.3.2
ioredis: 5.3.2
@ -26916,61 +27050,6 @@ snapshots:
- '@smithy/hash-node'
- peggy
'@langchain/community@1.1.27(b405db3381a109939c4610cb9de005a5)':
dependencies:
'@browserbasehq/stagehand': 1.14.0(@playwright/test@1.60.0)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@17.4.2)(encoding@0.1.13)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(utf-8-validate@5.0.10)(zod@3.25.76)
'@ibm-cloud/watsonx-ai': 1.1.2
'@langchain/classic': 1.0.27(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(cheerio@1.1.0)(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/core': 1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/openai': 1.4.1(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
binary-extensions: 2.2.0
flat: 5.0.2
ibm-cloud-sdk-core: 5.5.0
js-yaml: 4.3.1
langsmith: 0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
math-expression-evaluator: 2.0.7
openai: 6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)
uuid: 13.0.1
zod: 3.25.76
optionalDependencies:
'@aws-crypto/sha256-js': 5.2.0
'@azure/storage-blob': 12.32.0
'@browserbasehq/sdk': 2.12.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.6(d33d83a49ccae0e9b43f6f5606890e08)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
'@libsql/client': 0.17.2(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)
'@mozilla/readability': 0.6.0
'@smithy/eventstream-codec': 4.4.14
'@smithy/protocol-http': 5.3.12
'@smithy/util-utf8': 4.4.14
'@supabase/supabase-js': 2.50.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@zilliz/milvus2-sdk-node': 2.5.7
chromadb: 3.2.0
crypto-js: 4.2.0
discord.js: 14.27.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
epub2: 3.0.2(ts-toolbelt@9.6.0)
fast-xml-parser: 5.7.2
google-auth-library: 10.1.0
html-to-text: 9.0.5
ignore: 7.0.5
ioredis: 5.3.2
jsdom: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
jsonwebtoken: 9.0.3
lodash: 4.18.1(patch_hash=81ead1b07fc8efda87bd2724dbd876ab49f4a0aa03f96c3770060b2ea47d9e0b)
mammoth: 1.12.0
mysql2: 3.23.1(@types/node@20.19.41)
pdf-parse: 2.4.5
pg: 8.21.0(pg-native@3.8.0)
playwright: 1.60.0
puppeteer: 24.41.0(bufferutil@4.0.9)(typescript@7.0.2)(utf-8-validate@5.0.10)
ws: 8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- '@smithy/hash-node'
- peggy
'@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))':
dependencies:
'@cfworker/json-schema': 4.1.0
@ -26995,7 +27074,7 @@ snapshots:
'@langchain/google-gauth@2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))':
dependencies:
'@langchain/google-common': 2.1.24(patch_hash=b50436a6c7b0a82d54472dff331ba1b42ab8b816995f83295eef2ee4b482a87d)(@langchain/core@1.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
google-auth-library: 10.1.0
google-auth-library: 10.9.1
transitivePeerDependencies:
- '@langchain/core'
- supports-color
@ -35325,10 +35404,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
gcp-metadata@7.0.0:
gcp-metadata@8.1.2:
dependencies:
gaxios: 7.1.4
google-logging-utils: 1.1.1
google-logging-utils: 1.1.3
json-bigint: 1.0.0
transitivePeerDependencies:
- supports-color
@ -35539,14 +35618,13 @@ snapshots:
dependencies:
minimist: 1.2.8
google-auth-library@10.1.0:
google-auth-library@10.9.1:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 7.1.4
gcp-metadata: 7.0.0
google-logging-utils: 1.1.1
gtoken: 8.0.0
gcp-metadata: 8.1.2
google-logging-utils: 1.1.3
jws: 4.0.1
transitivePeerDependencies:
- supports-color
@ -35580,7 +35658,7 @@ snapshots:
- encoding
- supports-color
google-logging-utils@1.1.1: {}
google-logging-utils@1.1.3: {}
gopd@1.2.0: {}
@ -35631,13 +35709,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
gtoken@8.0.0:
dependencies:
gaxios: 7.1.4
jws: 4.0.1
transitivePeerDependencies:
- supports-color
handlebars@4.7.9:
dependencies:
minimist: 1.2.8

View File

@ -16,6 +16,7 @@ catalog:
'@ai-sdk/deepseek': ^3.0.13
'@ai-sdk/gateway': ^4.0.37
'@ai-sdk/google': ^4.0.24
'@ai-sdk/google-vertex': ^5.0.47
'@ai-sdk/groq': ^4.0.13
'@ai-sdk/mistral': ^4.0.14
'@ai-sdk/openai': ^4.0.20
@ -291,3 +292,7 @@ minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- '@n8n/*'
- '@n8n_io/*'
# Fresh Claude model IDs (e.g. Opus 5) ship in @ai-sdk/anthropic patches; allow
# installing before the 3-day minimumReleaseAge so agents/evals aren't stuck on
# the unknown-model 4096 max_tokens default.
- '@ai-sdk/anthropic'