@vellumai/assistant 0.11.6 → 0.11.7-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +31 -19
- package/Dockerfile +1 -0
- package/docs/architecture/security.md +3 -3
- package/docs/flux-turn-detection-spike.md +8 -7
- package/docs/runbook-trusted-contacts.md +14 -0
- package/docs/stt-provider-onboarding.md +8 -4
- package/knip.json +1 -0
- package/node_modules/@vellumai/avatar-manifest/package.json +18 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +173 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/read.test.ts +114 -0
- package/node_modules/@vellumai/avatar-manifest/src/index.ts +29 -0
- package/node_modules/@vellumai/avatar-manifest/src/layout.ts +18 -0
- package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +156 -0
- package/node_modules/@vellumai/avatar-manifest/src/read.ts +109 -0
- package/node_modules/@vellumai/avatar-manifest/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
- package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/shell.test.ts +56 -0
- package/node_modules/@vellumai/environments/src/shell.ts +65 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +32 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +82 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +56 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +18 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -11
- package/node_modules/@vellumai/gateway-client/src/channel-permission-contract.ts +3 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +4 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +75 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +17 -0
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +47 -0
- package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/openapi.yaml +419 -55
- package/package.json +4 -1
- package/scripts/sync-llm-catalog.ts +6 -0
- package/src/__tests__/approval-card-edit-inbound-handoff.test.ts +199 -0
- package/src/__tests__/approval-card-edit.test.ts +153 -0
- package/src/__tests__/attachments-store-heic-normalize.test.ts +1 -1
- package/src/__tests__/available-skills.test.ts +54 -0
- package/src/__tests__/avatar-identity-sync.test.ts +5 -1
- package/src/__tests__/catalog-install-normalize.test.ts +9 -1
- package/src/__tests__/channel-policy.test.ts +1 -3
- package/src/__tests__/channel-readiness-discord.test.ts +9 -70
- package/src/__tests__/compactor-call-site-logging.test.ts +61 -0
- package/src/__tests__/config-loader-backfill.test.ts +69 -0
- package/src/__tests__/config-schema.test.ts +2 -3
- package/src/__tests__/conversation-agent-loop.test.ts +238 -0
- package/src/__tests__/conversation-attachments.test.ts +0 -1
- package/src/__tests__/conversation-error.test.ts +44 -0
- package/src/__tests__/conversation-evictor.test.ts +19 -0
- package/src/__tests__/conversation-fork-referential.test.ts +73 -63
- package/src/__tests__/conversation-fork-retrospective.test.ts +99 -74
- package/src/__tests__/conversation-runtime-assembly.test.ts +23 -0
- package/src/__tests__/conversation-slash.test.ts +9 -0
- package/src/__tests__/conversation-title-service.test.ts +97 -0
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +16 -0
- package/src/__tests__/credential-broker-server-use.test.ts +0 -218
- package/src/__tests__/credential-broker.test.ts +43 -420
- package/src/__tests__/credential-record-write-through.test.ts +78 -0
- package/src/__tests__/credential-routes.test.ts +17 -1
- package/src/__tests__/credential-security-invariants.test.ts +8 -9
- package/src/__tests__/default-profile-catalog-fallback.test.ts +289 -0
- package/src/__tests__/delete-propagation.test.ts +92 -2
- package/src/__tests__/discord-callback-round-trip.test.ts +93 -0
- package/src/__tests__/discord-channel-config.test.ts +237 -0
- package/src/__tests__/edit-propagation.test.ts +42 -2
- package/src/__tests__/evict-conversations-for-reload.test.ts +51 -1
- package/src/__tests__/fallback-breaker.test.ts +1335 -0
- package/src/__tests__/gateway-threshold-reader-mock.ts +6 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +30 -0
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -2
- package/src/__tests__/image-conversion.test.ts +38 -4
- package/src/__tests__/install-skill-routing.test.ts +25 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver.test.ts +74 -0
- package/src/__tests__/llm-schema-fallback-profile.test.ts +359 -0
- package/src/__tests__/managed-fallback-dispatch.test.ts +430 -0
- package/src/__tests__/managed-profile-guard.test.ts +28 -0
- package/src/__tests__/managed-speech-defaults.test.ts +196 -1
- package/src/__tests__/mcp-cli.test.ts +23 -1
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +43 -34
- package/src/__tests__/media-stream-stt-session.test.ts +2 -0
- package/src/__tests__/notification-decision-fallback.test.ts +106 -0
- package/src/__tests__/notification-discord-adapter.test.ts +162 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +6 -8
- package/src/__tests__/persist-unsendable-image.test.ts +3 -3
- package/src/__tests__/platform.test.ts +19 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -1
- package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
- package/src/__tests__/provider-usage-tracking.test.ts +56 -0
- package/src/__tests__/require-fresh-approval.test.ts +5 -2
- package/src/__tests__/retry-fallback-escalation.test.ts +1093 -0
- package/src/__tests__/schedule-retry.test.ts +183 -4
- package/src/__tests__/scheduler-reuse-conversation.test.ts +6 -6
- package/src/__tests__/secret-routes-platform-proxy.test.ts +55 -0
- package/src/__tests__/skill-load-tool.test.ts +34 -0
- package/src/__tests__/skill-tool-factory.test.ts +6 -1
- package/src/__tests__/skill-tool-manifest.test.ts +20 -0
- package/src/__tests__/skills.test.ts +25 -0
- package/src/__tests__/subagent-manager-notify.test.ts +38 -0
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +146 -2
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +108 -2
- package/src/__tests__/tool-audit.test.ts +111 -40
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +228 -18
- package/src/__tests__/ui-shape-teaching.test.ts +1 -1
- package/src/__tests__/verification-control-plane-policy.test.ts +6 -2
- package/src/__tests__/voice-config-update.test.ts +43 -0
- package/src/__tests__/workspace-migration-147-rename-colliding-backup-profile-names.test.ts +480 -0
- package/src/__tests__/workspace-migration-148-strip-unsupported-fallback-profiles.test.ts +167 -0
- package/src/__tests__/workspace-migration-149-repoint-backup-profile-selections.test.ts +144 -0
- package/src/agent/loop.ts +31 -2
- package/src/approvals/guardian-card-withdrawal.ts +22 -0
- package/src/avatar/__tests__/ensure-raster.test.ts +291 -0
- package/src/avatar/avatar-manifest.ts +48 -100
- package/src/avatar/avatar-store.ts +7 -7
- package/src/avatar/ensure-raster.ts +146 -0
- package/src/avatar/resvg-lazy.test.ts +0 -1
- package/src/avatar/traits-png-sync.ts +9 -10
- package/src/background-wake/wake-intent-hooks.test.ts +0 -1
- package/src/backup/__tests__/paths.test.ts +6 -2
- package/src/backup/paths.ts +6 -4
- package/src/bundler/app-compiler.ts +2 -2
- package/src/bundler/compiler-tools.test.ts +36 -0
- package/src/bundler/compiler-tools.ts +54 -18
- package/src/calls/__tests__/telephony-synthesis-language.test.ts +81 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +57 -0
- package/src/calls/media-stream-stt-session.ts +2 -1
- package/src/calls/telephony-synthesis-language.ts +13 -5
- package/src/calls/voice-session-bridge.ts +19 -4
- package/src/channels/config.ts +23 -12
- package/src/cli/AGENTS.md +3 -1
- package/src/cli/commands/backup.help.ts +10 -13
- package/src/cli/commands/contacts.help.ts +16 -4
- package/src/cli/commands/contacts.ts +5 -1
- package/src/cli/commands/credentials.help.ts +1 -1
- package/src/cli/commands/inference-providers.ts +18 -53
- package/src/cli/commands/mcp.help.ts +6 -5
- package/src/cli/commands/mcp.ts +1 -1
- package/src/cli/commands/memory/memory-retrospective.ts +1 -0
- package/src/cli/commands/stt.help.ts +2 -2
- package/src/config/__tests__/backup-schema.test.ts +1 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +83 -2
- package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +1 -1
- package/src/config/__tests__/memory-retrospective-schema.test.ts +12 -15
- package/src/config/bundled-skills/computer-use/SKILL.md +6 -6
- package/src/config/bundled-skills/computer-use/TOOLS.json +9 -6
- package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +3 -1
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +16 -15
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +2 -1
- package/src/config/bundled-skills/settings/TOOLS.json +11 -5
- package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +85 -0
- package/src/config/bundled-skills/settings/tools/open-system-settings.ts +33 -8
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +54 -3
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +1 -1
- package/src/config/default-profile-catalog.ts +195 -10
- package/src/config/default-profile-names.ts +41 -0
- package/src/config/feature-flag-registry.json +34 -2
- package/src/config/inference-profile-validation.ts +11 -2
- package/src/config/llm-resolver.ts +25 -0
- package/src/config/loader.ts +3 -0
- package/src/config/managed-speech-defaults.ts +160 -10
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +7 -6
- package/src/config/schemas/__tests__/stt.test.ts +94 -0
- package/src/config/schemas/backup.ts +1 -1
- package/src/config/schemas/channels.ts +32 -0
- package/src/config/schemas/live-voice.ts +13 -4
- package/src/config/schemas/llm.ts +263 -15
- package/src/config/schemas/mcp.ts +10 -2
- package/src/config/schemas/memory-retrospective.ts +1 -19
- package/src/config/schemas/services.ts +24 -0
- package/src/config/schemas/stt.ts +128 -5
- package/src/config/seed-inference-profiles.ts +32 -3
- package/src/config/skill-state.ts +5 -0
- package/src/config/skills.ts +21 -0
- package/src/contacts/notify-contacts-changed.ts +5 -2
- package/src/context/compactor.ts +74 -1
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +138 -1
- package/src/daemon/config-watcher.ts +2 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +27 -0
- package/src/daemon/conversation-agent-loop.ts +86 -10
- package/src/daemon/conversation-error.ts +25 -0
- package/src/daemon/conversation-evictor.ts +2 -4
- package/src/daemon/conversation-runtime-assembly.ts +19 -7
- package/src/daemon/conversation-skill-tools.ts +5 -1
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-store.ts +8 -1
- package/src/daemon/conversation-tool-setup.ts +37 -0
- package/src/daemon/conversation.ts +0 -4
- package/src/daemon/handlers/channel-config-result.ts +30 -0
- package/src/daemon/handlers/config-discord-channel.ts +301 -0
- package/src/daemon/handlers/config-ingress.ts +9 -0
- package/src/daemon/handlers/config-slack-channel.ts +13 -15
- package/src/daemon/handlers/config-telegram.ts +2 -5
- package/src/daemon/handlers/conversations.ts +0 -2
- package/src/daemon/handlers/skills.ts +25 -7
- package/src/daemon/lifecycle.ts +3 -1
- package/src/daemon/message-types/messages.ts +0 -2
- package/src/daemon/tool-setup-types.ts +5 -3
- package/src/daemon/trust-context-types.test.ts +33 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +58 -4
- package/src/live-voice/__tests__/live-voice-managed-speech-fallback.test.ts +8 -3
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +73 -11
- package/src/live-voice/__tests__/live-voice-session-preflight.test.ts +148 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
- package/src/live-voice/__tests__/live-voice-stt.test.ts +3 -0
- package/src/live-voice/__tests__/live-voice-text-turn.test.ts +391 -0
- package/src/live-voice/__tests__/protocol.test.ts +224 -0
- package/src/live-voice/live-voice-credential-preflight.ts +5 -1
- package/src/live-voice/live-voice-session-manager.ts +39 -2
- package/src/live-voice/live-voice-session.ts +224 -9
- package/src/live-voice/protocol.ts +187 -1
- package/src/messaging/providers/discord/api.ts +2 -1
- package/src/messaging/providers/discord/withdraw.ts +62 -0
- package/src/messaging/read-provider-metadata.ts +32 -0
- package/src/monitoring/__tests__/file-descriptors.test.ts +32 -0
- package/src/monitoring/__tests__/process-memory.test.ts +34 -1
- package/src/monitoring/db-integrity-sample.ts +1 -0
- package/src/monitoring/file-descriptors.ts +39 -6
- package/src/monitoring/process-memory.ts +37 -5
- package/src/monitoring/resource-sampler.ts +25 -8
- package/src/notifications/__tests__/activity-failed-dedupe.test.ts +161 -0
- package/src/notifications/__tests__/background-failure-signal.test.ts +107 -0
- package/src/notifications/__tests__/connected-channels.test.ts +12 -0
- package/src/notifications/__tests__/copy-composer.test.ts +83 -0
- package/src/notifications/__tests__/destination-resolver.test.ts +38 -0
- package/src/notifications/activity-failed-dedupe.ts +95 -0
- package/src/notifications/adapters/discord.ts +132 -0
- package/src/notifications/adapters/shared.ts +16 -0
- package/src/notifications/adapters/slack.ts +6 -1
- package/src/notifications/adapters/telegram.ts +5 -8
- package/src/notifications/approval-card-data.ts +94 -1
- package/src/notifications/background-failure-signal.ts +116 -0
- package/src/notifications/broadcaster.ts +3 -9
- package/src/notifications/copy-composer.ts +41 -8
- package/src/notifications/decision-engine.ts +55 -0
- package/src/notifications/destination-resolver.ts +45 -5
- package/src/notifications/emit-signal.ts +19 -5
- package/src/notifications/guardian-question-mode.ts +57 -0
- package/src/notifications/types.ts +2 -8
- package/src/oauth/__tests__/seed-providers-managed.test.ts +18 -0
- package/src/oauth/connect-orchestrator.ts +16 -0
- package/src/oauth/seed-providers.ts +262 -0
- package/src/permissions/checker.test.ts +21 -0
- package/src/permissions/checker.ts +6 -0
- package/src/permissions/gateway-threshold-reader.test.ts +184 -0
- package/src/permissions/gateway-threshold-reader.ts +144 -10
- package/src/permissions/prompter.ts +0 -6
- package/src/permissions/types.ts +5 -4
- package/src/persistence/__tests__/bulk-write-gate.test.ts +0 -38
- package/src/persistence/bulk-write-gate.ts +9 -10
- package/src/persistence/conversation-crud.ts +88 -134
- package/src/persistence/conversation-title-service.ts +43 -29
- package/src/persistence/embeddings/embedding-local.ts +14 -85
- package/src/persistence/embeddings/embedding-runtime-manager.ts +1 -0
- package/src/persistence/lifecycle-events-store.test.ts +50 -0
- package/src/persistence/lifecycle-events-store.ts +75 -5
- package/src/platform/platform-patch-queue.test.ts +368 -0
- package/src/platform/platform-patch-queue.ts +190 -0
- package/src/platform/sync-avatar.test.ts +527 -0
- package/src/platform/sync-avatar.ts +209 -0
- package/src/platform/sync-identity.test.ts +75 -0
- package/src/platform/sync-identity.ts +17 -110
- package/src/plugin-api/model-profiles.test.ts +2 -2
- package/src/plugin-api/model-profiles.ts +7 -7
- package/src/plugin-api/transcription-session.ts +1 -1
- package/src/plugins/defaults/compaction/window-manager.ts +4 -0
- package/src/plugins/defaults/image-recovery/recover.ts +2 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +133 -34
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +21 -31
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +13 -9
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +31 -4
- package/src/plugins/defaults/memory/memory-retrospective-constants.ts +0 -11
- package/src/plugins/defaults/memory/memory-retrospective-fork-boundary.ts +27 -13
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +183 -113
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +13 -13
- package/src/plugins/defaults/memory/src/memory-v2-routes.ts +2 -2
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +1 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +4 -2
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +9 -42
- package/src/plugins/defaults/memory/v2/rerank-local.ts +1 -0
- package/src/plugins/defaults/memory/worker.ts +7 -0
- package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
- package/src/providers/__tests__/retry-callsite.test.ts +55 -0
- package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
- package/src/providers/connection-resolution.ts +2 -17
- package/src/providers/fallback-breaker.ts +389 -0
- package/src/providers/inference/__tests__/adapter-factory-opencode.test.ts +92 -0
- package/src/providers/inference/__tests__/base-url-route-validation.test.ts +16 -0
- package/src/providers/inference/__tests__/base-url-security.test.ts +12 -0
- package/src/providers/inference/adapter-factory.ts +246 -3
- package/src/providers/inference/auth.ts +15 -14
- package/src/providers/inference/chatgpt-subscription-auth.ts +70 -0
- package/src/providers/inference/chatgpt-subscription-credentials.ts +84 -0
- package/src/providers/inference/resolve-auth.ts +1 -1
- package/src/providers/media-resolve.ts +2 -3
- package/src/providers/model-catalog.ts +100 -0
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +22 -0
- package/src/providers/openai/__tests__/chat-template-flatten.test.ts +198 -0
- package/src/providers/openai/api-error-normalization.ts +13 -0
- package/src/providers/openai/chat-completions-provider.ts +84 -0
- package/src/providers/opencode/client.test.ts +150 -0
- package/src/providers/opencode/client.ts +61 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/provider-resolvability.ts +20 -0
- package/src/providers/provider-secret-catalog.ts +3 -2
- package/src/providers/retry.ts +956 -68
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +42 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +136 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +120 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +316 -12
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +83 -3
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +132 -4
- package/src/providers/speech-to-text/provider-catalog.ts +191 -3
- package/src/providers/speech-to-text/resolve.ts +132 -24
- package/src/providers/speech-to-text/vellum-managed-flux-realtime.ts +296 -0
- package/src/providers/types.ts +39 -0
- package/src/providers/usage-tracking.ts +12 -1
- package/src/providers/vellum/client.ts +29 -0
- package/src/providers/vellum-model-routing.test.ts +2 -0
- package/src/providers/vellum-model-routing.ts +3 -3
- package/src/runtime/AGENTS.md +1 -2
- package/src/runtime/__tests__/agent-wake.test.ts +105 -3
- package/src/runtime/__tests__/background-job-runner.test.ts +89 -3
- package/src/runtime/agent-wake.ts +44 -5
- package/src/runtime/background-job-runner.ts +68 -50
- package/src/runtime/channel-readiness-service.ts +0 -39
- package/src/runtime/http-server.ts +12 -4
- package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +18 -18
- package/src/runtime/routes/__tests__/backup-routes.test.ts +9 -5
- package/src/runtime/routes/__tests__/chatgpt-subscription-auth-routes.test.ts +372 -0
- package/src/runtime/routes/__tests__/contact-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +96 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +37 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +2 -1
- package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +87 -0
- package/src/runtime/routes/acp-claude-auth-routes.ts +25 -54
- package/src/runtime/routes/approval-routes.ts +0 -12
- package/src/runtime/routes/avatar-routes.ts +17 -30
- package/src/runtime/routes/backup-routes.ts +2 -2
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +296 -113
- package/src/runtime/routes/contact-prompt-routes.ts +65 -6
- package/src/runtime/routes/contact-routes.ts +20 -15
- package/src/runtime/routes/conversation-query-routes.ts +98 -10
- package/src/runtime/routes/conversation-routes.ts +2 -2
- package/src/runtime/routes/credential-routes.ts +2 -1
- package/src/runtime/routes/guardian-approval-interception.ts +12 -9
- package/src/runtime/routes/inbound-message-handler.ts +47 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +18 -13
- package/src/runtime/routes/inbound-stages/admission-policy.ts +2 -2
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +9 -8
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +1 -1
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profile-session-handler.ts +2 -2
- package/src/runtime/routes/inference-profiles-routes.ts +12 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +2 -2
- package/src/runtime/routes/inference-send-routes.ts +2 -2
- package/src/runtime/routes/ingress-status-routes.ts +2 -2
- package/src/runtime/routes/integrations/discord.ts +119 -0
- package/src/runtime/routes/llm-call-sites-routes.ts +2 -2
- package/src/runtime/routes/mcp-auth-routes.ts +12 -6
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/oauth-pending-flows.ts +136 -0
- package/src/runtime/routes/secret-routes.ts +7 -0
- package/src/runtime/routes/stt-routes.ts +38 -6
- package/src/runtime/routes/wake-conversation-routes.ts +8 -1
- package/src/runtime/routes/watch-routes.ts +9 -5
- package/src/runtime/sync/resource-sync-events.ts +2 -0
- package/src/runtime/trust-verdict-consumer.ts +3 -3
- package/src/schedule/__tests__/worker-feature-flags.test.ts +58 -0
- package/src/schedule/retry-policy.ts +8 -8
- package/src/schedule/run-script.ts +14 -23
- package/src/schedule/schedule-recovery.ts +0 -1
- package/src/schedule/scheduler.ts +89 -51
- package/src/schedule/worker.ts +57 -0
- package/src/security/__tests__/openai-device-auth.test.ts +355 -0
- package/src/security/ces-rpc-record-backend.ts +123 -0
- package/src/security/oauth2.ts +7 -0
- package/src/security/openai-device-auth.ts +364 -0
- package/src/security/secure-keys.ts +15 -0
- package/src/skills/available-skills.ts +10 -1
- package/src/skills/catalog-install.ts +19 -1
- package/src/skills/inline-command-runner.ts +6 -2
- package/src/skills/platform-compatibility.test.ts +49 -0
- package/src/skills/platform-compatibility.ts +65 -0
- package/src/skills/tool-manifest.ts +20 -0
- package/src/stt/__tests__/roles.test.ts +152 -0
- package/src/stt/daemon-batch-transcriber.ts +4 -1
- package/src/stt/roles.ts +180 -0
- package/src/stt/stt-stream-session.ts +3 -2
- package/src/stt/types.ts +33 -3
- package/src/subagent/manager.ts +12 -0
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +34 -0
- package/src/telemetry/live-voice-funnel.ts +12 -2
- package/src/telemetry/telemetry-wire.generated.ts +6 -0
- package/src/telemetry/tool-audit.ts +83 -30
- package/src/telemetry/tool-executed-events-store.test.ts +0 -1
- package/src/telemetry/turn-outcome.ts +75 -4
- package/src/tools/client-os.ts +115 -0
- package/src/tools/computer-use/definitions.ts +11 -7
- package/src/tools/computer-use/skill-proxy-bridge.ts +2 -2
- package/src/tools/credentials/broker-types.ts +0 -71
- package/src/tools/credentials/broker.ts +6 -201
- package/src/tools/credentials/metadata-store.ts +65 -16
- package/src/tools/credentials/store.ts +2 -0
- package/src/tools/executor.ts +2 -8
- package/src/tools/host-shell.test.ts +75 -0
- package/src/tools/host-shell.ts +24 -0
- package/src/tools/host-terminal/host-shell.ts +15 -44
- package/src/tools/mcp/mcp-tool-factory.ts +44 -9
- package/src/tools/permission-checker.ts +73 -21
- package/src/tools/policy-context.ts +1 -0
- package/src/tools/shared/filesystem/image-read.ts +1 -1
- package/src/tools/skills/find-similar-skills.ts +10 -2
- package/src/tools/skills/load.ts +41 -6
- package/src/tools/skills/sandbox-runner.ts +8 -16
- package/src/tools/skills/skill-tool-factory.ts +1 -0
- package/src/tools/terminal/__tests__/safe-env.test.ts +64 -1
- package/src/tools/terminal/safe-env.ts +39 -9
- package/src/tools/terminal/shell.ts +12 -31
- package/src/tools/tool-approval-handler.ts +64 -10
- package/src/tools/tool-types.ts +10 -0
- package/src/tools/types.ts +19 -3
- package/src/tools/ui-surface/surface-shape-docs.ts +4 -3
- package/src/tools/workflows/manage-workflows.ts +2 -2
- package/src/tools/workflows/run-workflow.test.ts +2 -1
- package/src/util/__tests__/file-use.test.ts +61 -0
- package/src/util/__tests__/process-table.test.ts +66 -0
- package/src/util/__tests__/process-tree.test.ts +46 -0
- package/src/util/errors.ts +1 -0
- package/src/util/file-use.ts +91 -0
- package/src/util/host-process.test.ts +17 -0
- package/src/util/host-process.ts +75 -0
- package/src/util/image-conversion.ts +39 -7
- package/src/util/platform.ts +32 -13
- package/src/util/process-table.ts +249 -0
- package/src/util/process-tree.ts +85 -129
- package/src/util/provider-error-patterns.ts +20 -0
- package/src/util/spawn.ts +9 -2
- package/src/watch/__tests__/watch-retro.test.ts +48 -1
- package/src/watch/watch-retro.ts +46 -4
- package/src/workspace/git-service.ts +4 -14
- package/src/workspace/migrations/147-rename-colliding-backup-profile-names.ts +360 -0
- package/src/workspace/migrations/148-strip-unsupported-fallback-profiles.ts +117 -0
- package/src/workspace/migrations/149-repoint-backup-profile-selections.ts +204 -0
- package/src/workspace/migrations/150-stt-flux-provider-to-model-family.ts +139 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +160 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/permission-types.test.ts +0 -17
- package/src/channels/gateway-discord-admission.ts +0 -32
- package/src/persistence/fork-message-copy.ts +0 -239
- package/src/plugins/defaults/memory/__tests__/fork-message-copy.test.ts +0 -221
- package/src/security/__tests__/oauth2-device-code.test.ts +0 -431
- package/src/security/oauth2-device-code.ts +0 -312
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import * as retryUtil from "../util/retry.js";
|
|
4
|
+
|
|
5
|
+
// Instant sleep so exhausted-retry cases don't wait out real backoff delays;
|
|
6
|
+
// everything else is the real module.
|
|
7
|
+
mock.module("../util/retry.js", () => ({
|
|
8
|
+
...retryUtil,
|
|
9
|
+
sleep: async () => {},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
recordFallbackServed,
|
|
14
|
+
resetFallbackBreaker,
|
|
15
|
+
} from "../providers/fallback-breaker.js";
|
|
16
|
+
import type {
|
|
17
|
+
Message,
|
|
18
|
+
Provider,
|
|
19
|
+
ProviderResponse,
|
|
20
|
+
SendMessageOptions,
|
|
21
|
+
} from "../providers/types.js";
|
|
22
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
23
|
+
|
|
24
|
+
const { RetryProvider } = await import("../providers/retry.js");
|
|
25
|
+
const { ContextOverflowError } = await import("../providers/types.js");
|
|
26
|
+
const { ProviderError } = await import("../util/errors.js");
|
|
27
|
+
const { DEFAULT_MAX_RETRIES } = await import("../util/retry.js");
|
|
28
|
+
|
|
29
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const MESSAGES: Message[] = [
|
|
32
|
+
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Primary route resolves through the active profile; the fallback forces the
|
|
36
|
+
// backup profile via `overrideProfile` + `forceOverrideProfile`, so the
|
|
37
|
+
// backup's model/maxTokens must win on the re-normalized options.
|
|
38
|
+
const LLM_FIXTURE = {
|
|
39
|
+
profiles: {
|
|
40
|
+
"primary-profile": {
|
|
41
|
+
source: "user",
|
|
42
|
+
provider: "openai",
|
|
43
|
+
model: "primary-model",
|
|
44
|
+
maxTokens: 1111,
|
|
45
|
+
},
|
|
46
|
+
"backup-profile": {
|
|
47
|
+
source: "user",
|
|
48
|
+
provider: "anthropic",
|
|
49
|
+
model: "backup-model",
|
|
50
|
+
maxTokens: 2222,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
activeProfile: "primary-profile",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
setConfig("llm", LLM_FIXTURE);
|
|
58
|
+
// The circuit breaker remembers a served fallback for the whole process, so
|
|
59
|
+
// a case that escalates would otherwise make the next case skip its primary.
|
|
60
|
+
resetFallbackBreaker();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function okResponse(model: string): ProviderResponse {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: "ok" }],
|
|
66
|
+
model,
|
|
67
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
68
|
+
stopReason: "end_turn",
|
|
69
|
+
} as ProviderResponse;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Primary stub that always throws `makeError()`. */
|
|
73
|
+
function failingProvider(
|
|
74
|
+
name: string,
|
|
75
|
+
makeError: () => unknown,
|
|
76
|
+
): { provider: Provider; calls: () => number } {
|
|
77
|
+
let calls = 0;
|
|
78
|
+
const provider: Provider = {
|
|
79
|
+
name,
|
|
80
|
+
sendMessage: async () => {
|
|
81
|
+
calls += 1;
|
|
82
|
+
throw makeError();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return { provider, calls: () => calls };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Backup stub that records the options it was sent and serves a response. */
|
|
89
|
+
function backupProvider(
|
|
90
|
+
name = "anthropic",
|
|
91
|
+
opts: { supportsNativeWebSearch?: boolean } = {},
|
|
92
|
+
): {
|
|
93
|
+
provider: Provider;
|
|
94
|
+
calls: () => number;
|
|
95
|
+
seenConfig: () => Record<string, unknown>;
|
|
96
|
+
seenToolNames: () => string[];
|
|
97
|
+
} {
|
|
98
|
+
let calls = 0;
|
|
99
|
+
let seen: SendMessageOptions | undefined;
|
|
100
|
+
const provider: Provider = {
|
|
101
|
+
name,
|
|
102
|
+
...(opts.supportsNativeWebSearch === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { supportsNativeWebSearch: opts.supportsNativeWebSearch }),
|
|
105
|
+
sendMessage: async (_messages: Message[], options?: SendMessageOptions) => {
|
|
106
|
+
calls += 1;
|
|
107
|
+
seen = options;
|
|
108
|
+
const config = options?.config as Record<string, unknown> | undefined;
|
|
109
|
+
return okResponse((config?.model as string | undefined) ?? "unresolved");
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
provider,
|
|
114
|
+
calls: () => calls,
|
|
115
|
+
seenConfig: () => (seen?.config ?? {}) as Record<string, unknown>,
|
|
116
|
+
seenToolNames: () => (seen?.tools ?? []).map((tool) => tool.name),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function makeRoute(
|
|
121
|
+
provider: Provider,
|
|
122
|
+
{
|
|
123
|
+
overrideProfile = "backup-profile",
|
|
124
|
+
forwardUsageAttributionHeaders = true,
|
|
125
|
+
}: {
|
|
126
|
+
overrideProfile?: string;
|
|
127
|
+
forwardUsageAttributionHeaders?: boolean;
|
|
128
|
+
} = {},
|
|
129
|
+
): {
|
|
130
|
+
resolveFallbackRoute: (
|
|
131
|
+
failedOptions: SendMessageOptions | undefined,
|
|
132
|
+
) => Promise<{
|
|
133
|
+
provider: Provider;
|
|
134
|
+
overrideProfile: string;
|
|
135
|
+
forwardUsageAttributionHeaders: boolean;
|
|
136
|
+
} | null>;
|
|
137
|
+
calls: () => number;
|
|
138
|
+
} {
|
|
139
|
+
let calls = 0;
|
|
140
|
+
return {
|
|
141
|
+
resolveFallbackRoute: async () => {
|
|
142
|
+
calls += 1;
|
|
143
|
+
return { provider, overrideProfile, forwardUsageAttributionHeaders };
|
|
144
|
+
},
|
|
145
|
+
calls: () => calls,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function captureError(promise: Promise<unknown>): Promise<unknown> {
|
|
150
|
+
try {
|
|
151
|
+
await promise;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
throw new Error("expected the promise to reject");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe("RetryProvider fallback-route escalation", () => {
|
|
161
|
+
test("retries exhaust on 503 → callback invoked once, backup serves, headers restamped", async () => {
|
|
162
|
+
const primary = failingProvider(
|
|
163
|
+
"openai",
|
|
164
|
+
() => new ProviderError("Service Unavailable", "openai", 503),
|
|
165
|
+
);
|
|
166
|
+
const backup = backupProvider();
|
|
167
|
+
const route = makeRoute(backup.provider);
|
|
168
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
169
|
+
forwardUsageAttributionHeaders: true,
|
|
170
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
174
|
+
config: { callSite: "mainAgent" },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Full retry budget burned on the primary before escalating.
|
|
178
|
+
expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
|
|
179
|
+
expect(route.calls()).toBe(1);
|
|
180
|
+
expect(backup.calls()).toBe(1);
|
|
181
|
+
expect(result.model).toBe("backup-model");
|
|
182
|
+
|
|
183
|
+
// The re-normalized options carry the backup profile's resolved values...
|
|
184
|
+
const config = backup.seenConfig();
|
|
185
|
+
expect(config.model).toBe("backup-model");
|
|
186
|
+
expect(config.max_tokens).toBe(2222);
|
|
187
|
+
// ...with routing keys stripped before the fallback provider sees them.
|
|
188
|
+
expect(config.callSite).toBeUndefined();
|
|
189
|
+
expect(config.overrideProfile).toBeUndefined();
|
|
190
|
+
expect(config.forceOverrideProfile).toBeUndefined();
|
|
191
|
+
// Usage-attribution headers come from the backup resolution so platform
|
|
192
|
+
// usage events attribute degraded traffic to the backup profile.
|
|
193
|
+
const headers = config.usageAttributionHeaders as Record<string, string>;
|
|
194
|
+
expect(headers["X-Vellum-Inference-Profile"]).toBe("backup-profile");
|
|
195
|
+
expect(headers["X-Vellum-Resolved-Model"]).toBe("backup-model");
|
|
196
|
+
expect(headers["X-Vellum-Resolved-Provider"]).toBe("anthropic");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("401 invalid_credentials after refresh fails → falls back without burning the retry budget", async () => {
|
|
200
|
+
const primary = failingProvider(
|
|
201
|
+
"openai",
|
|
202
|
+
() =>
|
|
203
|
+
new ProviderError("Unauthorized", "openai", 401, {
|
|
204
|
+
reason: "invalid_credentials",
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
const backup = backupProvider();
|
|
208
|
+
const route = makeRoute(backup.provider);
|
|
209
|
+
let refreshCalls = 0;
|
|
210
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
211
|
+
credentialSource: "vellum-managed",
|
|
212
|
+
refreshCredentialProvider: async () => {
|
|
213
|
+
refreshCalls += 1;
|
|
214
|
+
return null;
|
|
215
|
+
},
|
|
216
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
220
|
+
config: { callSite: "mainAgent" },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The existing credential-refresh path ran first; the 401 is
|
|
224
|
+
// non-retryable, so the primary was attempted exactly once.
|
|
225
|
+
expect(refreshCalls).toBe(1);
|
|
226
|
+
expect(primary.calls()).toBe(1);
|
|
227
|
+
expect(route.calls()).toBe(1);
|
|
228
|
+
expect(result.model).toBe("backup-model");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test.each(["byok", "oauth-subscription", "no-auth", undefined] as const)(
|
|
232
|
+
"401 invalid_credentials on a %s route → callback never invoked, auth error surfaces",
|
|
233
|
+
async (credentialSource) => {
|
|
234
|
+
// The credential eligibility case is restricted to `vellum-managed`
|
|
235
|
+
// routes. A broken personal credential must surface so the user can
|
|
236
|
+
// fix it, not silently reroute to a differently billed backup.
|
|
237
|
+
const authError = new ProviderError("Unauthorized", "openai", 401, {
|
|
238
|
+
reason: "invalid_credentials",
|
|
239
|
+
});
|
|
240
|
+
const primary = failingProvider("openai", () => authError);
|
|
241
|
+
const route = makeRoute(backupProvider().provider);
|
|
242
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
243
|
+
...(credentialSource !== undefined ? { credentialSource } : {}),
|
|
244
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const thrown = await captureError(
|
|
248
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(thrown).toBe(authError);
|
|
252
|
+
expect(primary.calls()).toBe(1);
|
|
253
|
+
expect(route.calls()).toBe(0);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
test("404 model-not-found (non-retryable) → falls back immediately", async () => {
|
|
258
|
+
const primary = failingProvider(
|
|
259
|
+
"openai",
|
|
260
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
261
|
+
);
|
|
262
|
+
const backup = backupProvider();
|
|
263
|
+
const route = makeRoute(backup.provider);
|
|
264
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
265
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
269
|
+
config: { callSite: "mainAgent" },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(primary.calls()).toBe(1);
|
|
273
|
+
expect(route.calls()).toBe(1);
|
|
274
|
+
expect(result.model).toBe("backup-model");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test.each(["unknown", undefined] as const)(
|
|
278
|
+
"404 with reason %s → status fallback applies, backup serves",
|
|
279
|
+
async (reason) => {
|
|
280
|
+
const primary = failingProvider(
|
|
281
|
+
"openai",
|
|
282
|
+
() =>
|
|
283
|
+
new ProviderError("not found", "openai", 404, {
|
|
284
|
+
...(reason !== undefined ? { reason } : {}),
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
const backup = backupProvider();
|
|
288
|
+
const route = makeRoute(backup.provider);
|
|
289
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
290
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
294
|
+
config: { callSite: "mainAgent" },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(route.calls()).toBe(1);
|
|
298
|
+
expect(result.model).toBe("backup-model");
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
test("404 with provider-classified reason model_not_found → falls back immediately", async () => {
|
|
303
|
+
const primary = failingProvider(
|
|
304
|
+
"openai",
|
|
305
|
+
() =>
|
|
306
|
+
new ProviderError("model not found", "openai", 404, {
|
|
307
|
+
reason: "model_not_found",
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const backup = backupProvider();
|
|
311
|
+
const route = makeRoute(backup.provider);
|
|
312
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
313
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
317
|
+
config: { callSite: "mainAgent" },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(primary.calls()).toBe(1);
|
|
321
|
+
expect(route.calls()).toBe(1);
|
|
322
|
+
expect(result.model).toBe("backup-model");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("404 with a definitive non-model reason (bad_request) → callback never invoked", async () => {
|
|
326
|
+
// Anthropic classifies a 404 without a model signal as `bad_request`: a
|
|
327
|
+
// missing gateway resource or other deterministic request failure. The
|
|
328
|
+
// provider-stamped semantic reason takes precedence over the raw status,
|
|
329
|
+
// so the request must surface its real error instead of switching routes.
|
|
330
|
+
const requestError = new ProviderError("not found", "anthropic", 404, {
|
|
331
|
+
reason: "bad_request",
|
|
332
|
+
});
|
|
333
|
+
const primary = failingProvider("anthropic", () => requestError);
|
|
334
|
+
const route = makeRoute(backupProvider().provider);
|
|
335
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
336
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const thrown = await captureError(
|
|
340
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(thrown).toBe(requestError);
|
|
344
|
+
expect(primary.calls()).toBe(1);
|
|
345
|
+
expect(route.calls()).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("managed proxy preflight 400 for a retired model → falls back immediately", async () => {
|
|
349
|
+
const primary = failingProvider(
|
|
350
|
+
"openai",
|
|
351
|
+
() =>
|
|
352
|
+
new ProviderError(
|
|
353
|
+
"Model 'gpt-old' is not yet supported on the Vellum hosted service.",
|
|
354
|
+
"openai",
|
|
355
|
+
400,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
const backup = backupProvider();
|
|
359
|
+
const route = makeRoute(backup.provider);
|
|
360
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
361
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
365
|
+
config: { callSite: "mainAgent" },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(primary.calls()).toBe(1);
|
|
369
|
+
expect(route.calls()).toBe(1);
|
|
370
|
+
expect(result.model).toBe("backup-model");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("plain 400 (request problem) → callback never invoked", async () => {
|
|
374
|
+
const primary = failingProvider(
|
|
375
|
+
"openai",
|
|
376
|
+
() => new ProviderError("invalid request", "openai", 400),
|
|
377
|
+
);
|
|
378
|
+
const route = makeRoute(backupProvider().provider);
|
|
379
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
380
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const thrown = await captureError(
|
|
384
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect((thrown as InstanceType<typeof ProviderError>).statusCode).toBe(400);
|
|
388
|
+
expect(route.calls()).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("context overflow → callback never invoked", async () => {
|
|
392
|
+
const primary = failingProvider(
|
|
393
|
+
"openai",
|
|
394
|
+
() => new ContextOverflowError("prompt too long", "openai"),
|
|
395
|
+
);
|
|
396
|
+
const route = makeRoute(backupProvider().provider);
|
|
397
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
398
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const thrown = await captureError(
|
|
402
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
expect(thrown).toBeInstanceOf(ContextOverflowError);
|
|
406
|
+
expect(primary.calls()).toBe(1);
|
|
407
|
+
expect(route.calls()).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("caller abort → callback never invoked", async () => {
|
|
411
|
+
const primary = failingProvider(
|
|
412
|
+
"openai",
|
|
413
|
+
() =>
|
|
414
|
+
new ProviderError("Request was aborted.", "openai", undefined, {
|
|
415
|
+
abortReason: "user_cancelled",
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
const route = makeRoute(backupProvider().provider);
|
|
419
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
420
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const thrown = await captureError(
|
|
424
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect((thrown as InstanceType<typeof ProviderError>).abortReason).toBe(
|
|
428
|
+
"user_cancelled",
|
|
429
|
+
);
|
|
430
|
+
expect(primary.calls()).toBe(1);
|
|
431
|
+
expect(route.calls()).toBe(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test.each([
|
|
435
|
+
["model", "pinned-model"],
|
|
436
|
+
["provider", "openai"],
|
|
437
|
+
["provider_connection", "vellum"],
|
|
438
|
+
] as const)(
|
|
439
|
+
"explicit config.%s pin + outage-shaped error → callback never invoked, original error rethrown",
|
|
440
|
+
async (field, value) => {
|
|
441
|
+
const finalError = new ProviderError(
|
|
442
|
+
"Service Unavailable",
|
|
443
|
+
"openai",
|
|
444
|
+
503,
|
|
445
|
+
);
|
|
446
|
+
const primary = failingProvider("openai", () => finalError);
|
|
447
|
+
const route = makeRoute(backupProvider().provider);
|
|
448
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
449
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const thrown = await captureError(
|
|
453
|
+
wrapped.sendMessage(MESSAGES, {
|
|
454
|
+
config: { callSite: "mainAgent", [field]: value },
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(thrown).toBe(finalError);
|
|
459
|
+
expect(route.calls()).toBe(0);
|
|
460
|
+
expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
|
|
461
|
+
true,
|
|
462
|
+
);
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
test.each([
|
|
467
|
+
["model", "pinned-model"],
|
|
468
|
+
["provider", "openai"],
|
|
469
|
+
] as const)(
|
|
470
|
+
"persisted call-site %s pin + outage-shaped error → callback never invoked",
|
|
471
|
+
async (field, value) => {
|
|
472
|
+
setConfig("llm", {
|
|
473
|
+
...LLM_FIXTURE,
|
|
474
|
+
callSites: { mainAgent: { [field]: value } },
|
|
475
|
+
});
|
|
476
|
+
const finalError = new ProviderError(
|
|
477
|
+
"Service Unavailable",
|
|
478
|
+
"openai",
|
|
479
|
+
503,
|
|
480
|
+
);
|
|
481
|
+
const primary = failingProvider("openai", () => finalError);
|
|
482
|
+
const route = makeRoute(backupProvider().provider);
|
|
483
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
484
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const thrown = await captureError(
|
|
488
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
expect(thrown).toBe(finalError);
|
|
492
|
+
expect(route.calls()).toBe(0);
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
test("callback returns null (no fallbackProfile) → original error rethrown with retriesExhausted tagging", async () => {
|
|
497
|
+
const finalError = new ProviderError("Service Unavailable", "openai", 503);
|
|
498
|
+
const primary = failingProvider("openai", () => finalError);
|
|
499
|
+
let routeCalls = 0;
|
|
500
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
501
|
+
resolveFallbackRoute: async () => {
|
|
502
|
+
routeCalls += 1;
|
|
503
|
+
return null;
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const thrown = await captureError(
|
|
508
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(routeCalls).toBe(1);
|
|
512
|
+
expect(thrown).toBe(finalError);
|
|
513
|
+
expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
|
|
514
|
+
true,
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("route returned for a disabled backup profile → no send on the backup adapter, original error rethrown", async () => {
|
|
519
|
+
// A disabled override profile is skipped by winner selection, which
|
|
520
|
+
// would resolve back to the primary profile while the request still
|
|
521
|
+
// dispatches on the backup adapter (provider/model mismatch). The guard
|
|
522
|
+
// must refuse the fallback send and surface the original error.
|
|
523
|
+
setConfig("llm", {
|
|
524
|
+
...LLM_FIXTURE,
|
|
525
|
+
profiles: {
|
|
526
|
+
...LLM_FIXTURE.profiles,
|
|
527
|
+
"backup-profile": {
|
|
528
|
+
...LLM_FIXTURE.profiles["backup-profile"],
|
|
529
|
+
status: "disabled",
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
const originalError = new ProviderError("model not found", "openai", 404);
|
|
534
|
+
const primary = failingProvider("openai", () => originalError);
|
|
535
|
+
const backup = backupProvider();
|
|
536
|
+
const route = makeRoute(backup.provider);
|
|
537
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
538
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const thrown = await captureError(
|
|
542
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
expect(thrown).toBe(originalError);
|
|
546
|
+
expect(route.calls()).toBe(1);
|
|
547
|
+
expect(backup.calls()).toBe(0);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("route returned for an incomplete backup profile (no model) → no send on the backup adapter, original error rethrown", async () => {
|
|
551
|
+
setConfig("llm", {
|
|
552
|
+
...LLM_FIXTURE,
|
|
553
|
+
profiles: {
|
|
554
|
+
...LLM_FIXTURE.profiles,
|
|
555
|
+
"backup-profile": {
|
|
556
|
+
source: "user",
|
|
557
|
+
provider: "anthropic",
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
const originalError = new ProviderError("model not found", "openai", 404);
|
|
562
|
+
const primary = failingProvider("openai", () => originalError);
|
|
563
|
+
const backup = backupProvider();
|
|
564
|
+
const route = makeRoute(backup.provider);
|
|
565
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
566
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const thrown = await captureError(
|
|
570
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(thrown).toBe(originalError);
|
|
574
|
+
expect(route.calls()).toBe(1);
|
|
575
|
+
expect(backup.calls()).toBe(0);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("route returned for a mix backup profile → no send on the backup adapter, original error rethrown", async () => {
|
|
579
|
+
// The fallback schema forbids `fallbackProfile` from referencing a mix,
|
|
580
|
+
// and RetryProvider must not trust that: a mix backup without a
|
|
581
|
+
// `selectionSeed` would be re-expanded independently by the apply guard,
|
|
582
|
+
// the route callback, and the normalization, potentially sending one
|
|
583
|
+
// arm's model through another arm's provider adapter.
|
|
584
|
+
setConfig("llm", {
|
|
585
|
+
profiles: {
|
|
586
|
+
...LLM_FIXTURE.profiles,
|
|
587
|
+
"arm-a": {
|
|
588
|
+
source: "user",
|
|
589
|
+
provider: "anthropic",
|
|
590
|
+
model: "arm-a-model",
|
|
591
|
+
},
|
|
592
|
+
"arm-b": {
|
|
593
|
+
source: "user",
|
|
594
|
+
provider: "openai",
|
|
595
|
+
model: "arm-b-model",
|
|
596
|
+
},
|
|
597
|
+
"backup-mix": {
|
|
598
|
+
source: "user",
|
|
599
|
+
mix: [
|
|
600
|
+
{ profile: "arm-a", weight: 1 },
|
|
601
|
+
{ profile: "arm-b", weight: 1 },
|
|
602
|
+
],
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
activeProfile: "primary-profile",
|
|
606
|
+
});
|
|
607
|
+
const originalError = new ProviderError("model not found", "openai", 404);
|
|
608
|
+
const primary = failingProvider("openai", () => originalError);
|
|
609
|
+
const backup = backupProvider();
|
|
610
|
+
const route = makeRoute(backup.provider, { overrideProfile: "backup-mix" });
|
|
611
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
612
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const thrown = await captureError(
|
|
616
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(thrown).toBe(originalError);
|
|
620
|
+
expect(route.calls()).toBe(1);
|
|
621
|
+
expect(backup.calls()).toBe(0);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("backup also fails → original error is preserved, no second fallback attempt", async () => {
|
|
625
|
+
const originalError = new ProviderError("model not found", "openai", 404);
|
|
626
|
+
const primary = failingProvider("openai", () => originalError);
|
|
627
|
+
const fallbackError = new ProviderError("backup down", "anthropic", 500);
|
|
628
|
+
const backup = failingProvider("anthropic", () => fallbackError);
|
|
629
|
+
const route = makeRoute(backup.provider);
|
|
630
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
631
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const thrown = await captureError(
|
|
635
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
expect(thrown).toBe(originalError);
|
|
639
|
+
expect((thrown as Error).cause).toBeUndefined();
|
|
640
|
+
expect(route.calls()).toBe(1);
|
|
641
|
+
expect(backup.calls()).toBe(1);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("failed recovery probe and backup surface the probe error without retrying the primary", async () => {
|
|
645
|
+
const originalError = new ProviderError(
|
|
646
|
+
"Service Unavailable",
|
|
647
|
+
"openai",
|
|
648
|
+
503,
|
|
649
|
+
);
|
|
650
|
+
const primary = failingProvider("openai", () => originalError);
|
|
651
|
+
const backupError = new ProviderError("invalid request", "anthropic", 400);
|
|
652
|
+
const backup = failingProvider("anthropic", () => backupError);
|
|
653
|
+
const route = makeRoute(backup.provider);
|
|
654
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
655
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
656
|
+
});
|
|
657
|
+
recordFallbackServed({ upstream: "openai" }, Date.now() - 11 * 60_000);
|
|
658
|
+
|
|
659
|
+
const thrown = await captureError(
|
|
660
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
expect(thrown).toBe(originalError);
|
|
664
|
+
expect(primary.calls()).toBe(1);
|
|
665
|
+
expect(route.calls()).toBe(1);
|
|
666
|
+
expect(backup.calls()).toBe(1);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("resolveFallbackRoute unset → behavior unchanged (original error rethrown)", async () => {
|
|
670
|
+
const finalError = new ProviderError("Service Unavailable", "openai", 503);
|
|
671
|
+
const primary = failingProvider("openai", () => finalError);
|
|
672
|
+
const wrapped = new RetryProvider(primary.provider);
|
|
673
|
+
|
|
674
|
+
const thrown = await captureError(
|
|
675
|
+
wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
expect(thrown).toBe(finalError);
|
|
679
|
+
expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("explicit max_tokens/effort/thinking are cleared so the backup profile's values win", async () => {
|
|
683
|
+
const primary = failingProvider(
|
|
684
|
+
"openai",
|
|
685
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
686
|
+
);
|
|
687
|
+
const backup = backupProvider();
|
|
688
|
+
const route = makeRoute(backup.provider);
|
|
689
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
690
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
694
|
+
config: {
|
|
695
|
+
callSite: "mainAgent",
|
|
696
|
+
max_tokens: 42,
|
|
697
|
+
effort: "low",
|
|
698
|
+
thinking: { type: "disabled" },
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const config = backup.seenConfig();
|
|
703
|
+
expect(config.model).toBe("backup-model");
|
|
704
|
+
// The explicit per-call cap was cleared; the backup profile's resolved
|
|
705
|
+
// maxTokens applies instead.
|
|
706
|
+
expect(config.max_tokens).toBe(2222);
|
|
707
|
+
expect(config.effort).not.toBe("low");
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("route with forwardUsageAttributionHeaders false → no X-Vellum-* headers reach the backup adapter, even when the primary forwards them", async () => {
|
|
711
|
+
// A managed primary (forwarding enabled) falling back to a BYOK or other
|
|
712
|
+
// third-party adapter must not leak billing metadata: the fallback
|
|
713
|
+
// normalization follows the ROUTE's policy, not the primary's.
|
|
714
|
+
const primary = failingProvider(
|
|
715
|
+
"openai",
|
|
716
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
717
|
+
);
|
|
718
|
+
const backup = backupProvider();
|
|
719
|
+
const route = makeRoute(backup.provider, {
|
|
720
|
+
forwardUsageAttributionHeaders: false,
|
|
721
|
+
});
|
|
722
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
723
|
+
forwardUsageAttributionHeaders: true,
|
|
724
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
728
|
+
config: { callSite: "mainAgent" },
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
expect(result.model).toBe("backup-model");
|
|
732
|
+
expect(backup.seenConfig().usageAttributionHeaders).toBeUndefined();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("route with forwardUsageAttributionHeaders true → headers present and reflect the backup profile, even when the primary does not forward them", async () => {
|
|
736
|
+
// A non-managed primary (forwarding disabled) falling back to the
|
|
737
|
+
// managed proxy must include the required attribution headers.
|
|
738
|
+
const primary = failingProvider(
|
|
739
|
+
"openai",
|
|
740
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
741
|
+
);
|
|
742
|
+
const backup = backupProvider();
|
|
743
|
+
const route = makeRoute(backup.provider, {
|
|
744
|
+
forwardUsageAttributionHeaders: true,
|
|
745
|
+
});
|
|
746
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
747
|
+
forwardUsageAttributionHeaders: false,
|
|
748
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
752
|
+
config: { callSite: "mainAgent" },
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
expect(result.model).toBe("backup-model");
|
|
756
|
+
const headers = backup.seenConfig().usageAttributionHeaders as Record<
|
|
757
|
+
string,
|
|
758
|
+
string
|
|
759
|
+
>;
|
|
760
|
+
expect(headers["X-Vellum-Inference-Profile"]).toBe("backup-profile");
|
|
761
|
+
expect(headers["X-Vellum-Resolved-Model"]).toBe("backup-model");
|
|
762
|
+
expect(headers["X-Vellum-Resolved-Provider"]).toBe("anthropic");
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("fallback response without actualProvider → stamped with the backup provider's name", async () => {
|
|
766
|
+
// The outer call-site router attributes success by `actualProvider`;
|
|
767
|
+
// without the stamp it would record the fallback under the failed
|
|
768
|
+
// primary provider and apply the wrong pricing.
|
|
769
|
+
const primary = failingProvider(
|
|
770
|
+
"openai",
|
|
771
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
772
|
+
);
|
|
773
|
+
const backup = backupProvider();
|
|
774
|
+
const route = makeRoute(backup.provider);
|
|
775
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
776
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
780
|
+
config: { callSite: "mainAgent" },
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
expect(result.actualProvider).toBe("anthropic");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("fallback response with adapter-set actualProvider → preserved, not overwritten", async () => {
|
|
787
|
+
const primary = failingProvider(
|
|
788
|
+
"openai",
|
|
789
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
790
|
+
);
|
|
791
|
+
const backup: Provider = {
|
|
792
|
+
name: "anthropic",
|
|
793
|
+
sendMessage: async () => ({
|
|
794
|
+
...okResponse("backup-model"),
|
|
795
|
+
actualProvider: "openrouter/anthropic",
|
|
796
|
+
}),
|
|
797
|
+
};
|
|
798
|
+
const route = makeRoute(backup);
|
|
799
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
800
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
804
|
+
config: { callSite: "mainAgent" },
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(result.actualProvider).toBe("openrouter/anthropic");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("fallback response → stamped with the backup profile key so usage tracking attributes it correctly", async () => {
|
|
811
|
+
// The outer UsageTrackingProvider resolves `inferenceProfile` from the
|
|
812
|
+
// ORIGINAL request options, which still carry the failed primary's
|
|
813
|
+
// resolution. The stamp is what lets the usage event bill the fallback
|
|
814
|
+
// serve under the backup profile.
|
|
815
|
+
const primary = failingProvider(
|
|
816
|
+
"openai",
|
|
817
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
818
|
+
);
|
|
819
|
+
const backup = backupProvider();
|
|
820
|
+
const route = makeRoute(backup.provider);
|
|
821
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
822
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
826
|
+
config: { callSite: "mainAgent" },
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
expect(result.actualInferenceProfile).toBe("backup-profile");
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("fallback response with a wrapper-set actualInferenceProfile → preserved, not overwritten", async () => {
|
|
833
|
+
const primary = failingProvider(
|
|
834
|
+
"openai",
|
|
835
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
836
|
+
);
|
|
837
|
+
const backup: Provider = {
|
|
838
|
+
name: "anthropic",
|
|
839
|
+
sendMessage: async () => ({
|
|
840
|
+
...okResponse("backup-model"),
|
|
841
|
+
actualInferenceProfile: "inner-specific-profile",
|
|
842
|
+
}),
|
|
843
|
+
};
|
|
844
|
+
const route = makeRoute(backup);
|
|
845
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
846
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
850
|
+
config: { callSite: "mainAgent" },
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
expect(result.actualInferenceProfile).toBe("inner-specific-profile");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("native web search sentinel is dropped when the backup cannot serve it", async () => {
|
|
857
|
+
// The caller appended the sentinel from the PRIMARY route's capability, so
|
|
858
|
+
// a backup without server-side search would answer with a tool call
|
|
859
|
+
// nothing can execute. Degraded mode loses native search, not the turn.
|
|
860
|
+
const primary = failingProvider(
|
|
861
|
+
"openai",
|
|
862
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
863
|
+
);
|
|
864
|
+
const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
|
|
865
|
+
const route = makeRoute(backup.provider);
|
|
866
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
867
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
871
|
+
config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
|
|
872
|
+
tools: [
|
|
873
|
+
{
|
|
874
|
+
name: "read_file",
|
|
875
|
+
description: "d",
|
|
876
|
+
input_schema: { type: "object" },
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
name: "web_search",
|
|
880
|
+
description: "d",
|
|
881
|
+
input_schema: { type: "object" },
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
expect(backup.calls()).toBe(1);
|
|
887
|
+
expect(backup.seenToolNames()).toEqual(["read_file"]);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("native web search sentinel survives when the backup serves it too", async () => {
|
|
891
|
+
const primary = failingProvider(
|
|
892
|
+
"openai",
|
|
893
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
894
|
+
);
|
|
895
|
+
const backup = backupProvider("anthropic", {
|
|
896
|
+
supportsNativeWebSearch: true,
|
|
897
|
+
});
|
|
898
|
+
const route = makeRoute(backup.provider);
|
|
899
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
900
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
904
|
+
config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
|
|
905
|
+
tools: [
|
|
906
|
+
{
|
|
907
|
+
name: "read_file",
|
|
908
|
+
description: "d",
|
|
909
|
+
input_schema: { type: "object" },
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
name: "web_search",
|
|
913
|
+
description: "d",
|
|
914
|
+
input_schema: { type: "object" },
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
expect(backup.seenToolNames()).toEqual(["read_file", "web_search"]);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("an app-executed web_search tool survives the fallback untouched", async () => {
|
|
923
|
+
// With a search backend such as Brave or the platform search proxy, the
|
|
924
|
+
// daemon executes `web_search` itself and the caller sets no sentinel
|
|
925
|
+
// marker. Filtering it by name would strip a capability the backup can
|
|
926
|
+
// still serve, so the tool list has to carry over unchanged.
|
|
927
|
+
const primary = failingProvider(
|
|
928
|
+
"openai",
|
|
929
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
930
|
+
);
|
|
931
|
+
const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
|
|
932
|
+
const route = makeRoute(backup.provider);
|
|
933
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
934
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
938
|
+
config: { callSite: "mainAgent" },
|
|
939
|
+
tools: [
|
|
940
|
+
{
|
|
941
|
+
name: "read_file",
|
|
942
|
+
description: "d",
|
|
943
|
+
input_schema: { type: "object" },
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: "web_search",
|
|
947
|
+
description: "d",
|
|
948
|
+
input_schema: { type: "object" },
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
expect(backup.seenToolNames()).toEqual(["read_file", "web_search"]);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("a tool-less call site loses the tool_choice paired with the sentinel", async () => {
|
|
957
|
+
// `AgentLoop` sets `tool_choice: { type: "auto" }` under the same condition
|
|
958
|
+
// that appends the sentinel, so a call site with no tools of its own sends
|
|
959
|
+
// the sentinel as its ONLY tool. Filtering it and leaving the tool_choice
|
|
960
|
+
// behind puts a choice with nothing to choose from on the wire, which
|
|
961
|
+
// Anthropic rejects outright: a recoverable outage would become a hard 400
|
|
962
|
+
// on the backup.
|
|
963
|
+
const primary = failingProvider(
|
|
964
|
+
"openai",
|
|
965
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
966
|
+
);
|
|
967
|
+
const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
|
|
968
|
+
const route = makeRoute(backup.provider);
|
|
969
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
970
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
974
|
+
config: {
|
|
975
|
+
callSite: "mainAgent",
|
|
976
|
+
nativeWebSearchSentinel: true,
|
|
977
|
+
tool_choice: { type: "auto" },
|
|
978
|
+
},
|
|
979
|
+
tools: [
|
|
980
|
+
{
|
|
981
|
+
name: "web_search",
|
|
982
|
+
description: "d",
|
|
983
|
+
input_schema: { type: "object" },
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
expect(backup.calls()).toBe(1);
|
|
989
|
+
expect(backup.seenToolNames()).toEqual([]);
|
|
990
|
+
expect(backup.seenConfig().tool_choice).toBeUndefined();
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("a tool_choice survives the fallback whenever a tool is left to choose", async () => {
|
|
994
|
+
// The rule is deliberately narrow. A conversation-level `toolChoice` takes
|
|
995
|
+
// precedence over the sentinel's `auto` in `AgentLoop`, so it is caller
|
|
996
|
+
// intent a route change must not quietly discard, and the request it
|
|
997
|
+
// produces is valid on every wire.
|
|
998
|
+
const primary = failingProvider(
|
|
999
|
+
"openai",
|
|
1000
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
1001
|
+
);
|
|
1002
|
+
const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
|
|
1003
|
+
const route = makeRoute(backup.provider);
|
|
1004
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
1005
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
1009
|
+
config: {
|
|
1010
|
+
callSite: "mainAgent",
|
|
1011
|
+
nativeWebSearchSentinel: true,
|
|
1012
|
+
tool_choice: { type: "tool", name: "read_file" },
|
|
1013
|
+
},
|
|
1014
|
+
tools: [
|
|
1015
|
+
{
|
|
1016
|
+
name: "read_file",
|
|
1017
|
+
description: "d",
|
|
1018
|
+
input_schema: { type: "object" },
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
name: "web_search",
|
|
1022
|
+
description: "d",
|
|
1023
|
+
input_schema: { type: "object" },
|
|
1024
|
+
},
|
|
1025
|
+
],
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
expect(backup.seenToolNames()).toEqual(["read_file"]);
|
|
1029
|
+
expect(backup.seenConfig().tool_choice).toEqual({
|
|
1030
|
+
type: "tool",
|
|
1031
|
+
name: "read_file",
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test("a tool_choice survives when the sentinel was never filtered", async () => {
|
|
1036
|
+
// A backup that serves native search keeps the whole list, so nothing about
|
|
1037
|
+
// the caller's config changes either.
|
|
1038
|
+
const primary = failingProvider(
|
|
1039
|
+
"openai",
|
|
1040
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
1041
|
+
);
|
|
1042
|
+
const backup = backupProvider("anthropic", {
|
|
1043
|
+
supportsNativeWebSearch: true,
|
|
1044
|
+
});
|
|
1045
|
+
const route = makeRoute(backup.provider);
|
|
1046
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
1047
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
1051
|
+
config: {
|
|
1052
|
+
callSite: "mainAgent",
|
|
1053
|
+
nativeWebSearchSentinel: true,
|
|
1054
|
+
tool_choice: { type: "auto" },
|
|
1055
|
+
},
|
|
1056
|
+
tools: [
|
|
1057
|
+
{
|
|
1058
|
+
name: "web_search",
|
|
1059
|
+
description: "d",
|
|
1060
|
+
input_schema: { type: "object" },
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
expect(backup.seenToolNames()).toEqual(["web_search"]);
|
|
1066
|
+
expect(backup.seenConfig().tool_choice).toEqual({ type: "auto" });
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test("the sentinel marker never reaches the provider wire config", async () => {
|
|
1070
|
+
const primary = failingProvider(
|
|
1071
|
+
"openai",
|
|
1072
|
+
() => new ProviderError("model not found", "openai", 404),
|
|
1073
|
+
);
|
|
1074
|
+
const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
|
|
1075
|
+
const route = makeRoute(backup.provider);
|
|
1076
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
1077
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
await wrapped.sendMessage(MESSAGES, {
|
|
1081
|
+
config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
|
|
1082
|
+
tools: [
|
|
1083
|
+
{
|
|
1084
|
+
name: "web_search",
|
|
1085
|
+
description: "d",
|
|
1086
|
+
input_schema: { type: "object" },
|
|
1087
|
+
},
|
|
1088
|
+
],
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
expect(backup.seenConfig().nativeWebSearchSentinel).toBeUndefined();
|
|
1092
|
+
});
|
|
1093
|
+
});
|