@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
package/src/providers/retry.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveCallSiteConfig,
|
|
5
|
+
selectWinningProfile,
|
|
6
|
+
} from "../config/llm-resolver.js";
|
|
2
7
|
import { getConfig } from "../config/loader.js";
|
|
3
8
|
import {
|
|
4
9
|
resolveUsageAttribution,
|
|
@@ -22,11 +27,23 @@ import {
|
|
|
22
27
|
isAnthropicDelegatingGateway,
|
|
23
28
|
isAnthropicModel,
|
|
24
29
|
} from "./anthropic-gateway-shared.js";
|
|
30
|
+
import {
|
|
31
|
+
type BreakerObservation,
|
|
32
|
+
type BreakerRoute,
|
|
33
|
+
recordFallbackServed,
|
|
34
|
+
recordPrimaryFailure,
|
|
35
|
+
recordPrimarySuccess,
|
|
36
|
+
releaseRecoveryProbe,
|
|
37
|
+
shouldSkipPrimary,
|
|
38
|
+
tryAcquireRecoveryProbe,
|
|
39
|
+
} from "./fallback-breaker.js";
|
|
25
40
|
import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
|
|
26
41
|
import {
|
|
27
42
|
isAdaptiveThinkingOnlyModel,
|
|
28
43
|
isAdaptiveThinkingUnsupportedModel,
|
|
29
44
|
} from "./model-catalog.js";
|
|
45
|
+
import { buildOpenCodeRequestHeaders } from "./opencode/client.js";
|
|
46
|
+
import { dispatchProviderResolvable } from "./provider-resolvability.js";
|
|
30
47
|
import {
|
|
31
48
|
isThinkingConfigAdaptive,
|
|
32
49
|
isThinkingConfigDisabled,
|
|
@@ -35,9 +52,11 @@ import {
|
|
|
35
52
|
import {
|
|
36
53
|
isContextOverflowError,
|
|
37
54
|
type Message,
|
|
55
|
+
NATIVE_WEB_SEARCH_TOOL_NAME,
|
|
38
56
|
type Provider,
|
|
39
57
|
type ProviderResponse,
|
|
40
58
|
type SendMessageOptions,
|
|
59
|
+
type ToolDefinition,
|
|
41
60
|
} from "./types.js";
|
|
42
61
|
import { UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE } from "./unparseable-tool-args.js";
|
|
43
62
|
|
|
@@ -287,6 +306,16 @@ function isRetryableTransportAbort(error: unknown): boolean {
|
|
|
287
306
|
return RETRYABLE_TRANSPORT_ABORT_PATTERNS.some((p) => p.test(error.message));
|
|
288
307
|
}
|
|
289
308
|
|
|
309
|
+
/**
|
|
310
|
+
* A daemon or user cancellation. The provider catch-sites tag these with
|
|
311
|
+
* `abortReason` exactly when `signal.aborted` was true at the time of failure,
|
|
312
|
+
* which is what separates them from transport-level aborts: both surface as
|
|
313
|
+
* "Request was aborted" from the SDK, and only the tag says who stopped it.
|
|
314
|
+
*/
|
|
315
|
+
function isCallerAbort(error: unknown): boolean {
|
|
316
|
+
return error instanceof ProviderError && error.abortReason !== undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
290
319
|
function isRetryableError(error: unknown): boolean {
|
|
291
320
|
// Context overflow is deterministic — retrying the same oversized prompt
|
|
292
321
|
// will never succeed. Short-circuit before the generic 429/5xx check so
|
|
@@ -295,12 +324,11 @@ function isRetryableError(error: unknown): boolean {
|
|
|
295
324
|
if (isContextOverflowError(error)) {
|
|
296
325
|
return false;
|
|
297
326
|
}
|
|
298
|
-
// Daemon/user-initiated aborts are never retryable.
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
if (error instanceof ProviderError && error.abortReason !== undefined) {
|
|
327
|
+
// Daemon/user-initiated aborts are never retryable. This short-circuits
|
|
328
|
+
// before any message-based pattern matches, which matters because
|
|
329
|
+
// transport-level aborts (retryable) and caller-cancels both surface as
|
|
330
|
+
// "Request was aborted" from the SDK.
|
|
331
|
+
if (isCallerAbort(error)) {
|
|
304
332
|
return false;
|
|
305
333
|
}
|
|
306
334
|
// Prefer the provider-stamped semantic reason: a known reason decides
|
|
@@ -330,6 +358,216 @@ function isRetryableError(error: unknown): boolean {
|
|
|
330
358
|
return isRetryableNetworkError(error);
|
|
331
359
|
}
|
|
332
360
|
|
|
361
|
+
/** Cap server-suggested delays at 60s. */
|
|
362
|
+
const MAX_RETRY_DELAY_MS = 60_000;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* How long to wait before the next attempt, and whether the upstream named the
|
|
366
|
+
* wait itself. A server-provided `Retry-After` wins over exponential backoff,
|
|
367
|
+
* capped so a pathological header cannot stall a turn.
|
|
368
|
+
*/
|
|
369
|
+
function retryPlan(
|
|
370
|
+
error: unknown,
|
|
371
|
+
attempt: number,
|
|
372
|
+
): { delay: number; retryAfterHeader: boolean } {
|
|
373
|
+
const retryAfter =
|
|
374
|
+
error instanceof ProviderError ? error.retryAfterMs : undefined;
|
|
375
|
+
return {
|
|
376
|
+
delay: Math.min(
|
|
377
|
+
retryAfter ?? computeRetryDelay(attempt, DEFAULT_BASE_DELAY_MS),
|
|
378
|
+
MAX_RETRY_DELAY_MS,
|
|
379
|
+
),
|
|
380
|
+
retryAfterHeader: retryAfter !== undefined,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Structured `errorType` for the "Retrying after transient error" logs. */
|
|
385
|
+
function retryErrorType(error: unknown): string {
|
|
386
|
+
if (error instanceof ProviderError && error.statusCode === 429) {
|
|
387
|
+
return "rate_limit";
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
error instanceof ProviderError &&
|
|
391
|
+
error.statusCode !== undefined &&
|
|
392
|
+
error.statusCode >= 500
|
|
393
|
+
) {
|
|
394
|
+
return `server_error_${error.statusCode}`;
|
|
395
|
+
}
|
|
396
|
+
if (isRetryableProviderMessage(error)) {
|
|
397
|
+
return "provider_overloaded";
|
|
398
|
+
}
|
|
399
|
+
if (isRetryableStreamError(error)) {
|
|
400
|
+
return "stream_corruption";
|
|
401
|
+
}
|
|
402
|
+
if (isRetryableTransportAbort(error)) {
|
|
403
|
+
return "transport_abort";
|
|
404
|
+
}
|
|
405
|
+
return "network_error";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The managed proxy's preflight guard rejects a model with no billing rate
|
|
410
|
+
* card using a 400 whose body carries this phrase (django
|
|
411
|
+
* `app/runtime_proxy/views.py`). It marks a model rename/retirement incident
|
|
412
|
+
* (a route problem, not a request problem), so it is fallback-eligible even
|
|
413
|
+
* though 400s are otherwise final.
|
|
414
|
+
*/
|
|
415
|
+
const MANAGED_PROXY_UNSUPPORTED_MODEL_PATTERN =
|
|
416
|
+
/is not yet supported on the Vellum hosted service/;
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Whether the failure indicts one model rather than the upstream serving it: a
|
|
420
|
+
* provider-classified `model_not_found`, a 404 with no definitive
|
|
421
|
+
* classification, or the managed proxy's preflight 400 for a renamed/retired
|
|
422
|
+
* model. As in `isRetryableError`, a provider-stamped semantic reason takes
|
|
423
|
+
* precedence over the status fallback: a 404 whose classifier assigned a
|
|
424
|
+
* definitive non-model reason (Anthropic, for example, stamps `bad_request` on
|
|
425
|
+
* a 404 without a model signal) marks a deterministic request/routing failure
|
|
426
|
+
* that a different model route would not fix. Only an absent or `unknown`
|
|
427
|
+
* reason falls through to the raw 404 check. `model_restricted` is a
|
|
428
|
+
* credential/policy denial, not a missing model, so it is deliberately absent.
|
|
429
|
+
*/
|
|
430
|
+
function isModelSpecificError(error: unknown): boolean {
|
|
431
|
+
if (!(error instanceof ProviderError)) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
if (error.reason === "model_not_found") {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (
|
|
438
|
+
error.statusCode === 404 &&
|
|
439
|
+
(error.reason === undefined || error.reason === "unknown")
|
|
440
|
+
) {
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return (
|
|
444
|
+
error.statusCode === 400 &&
|
|
445
|
+
MANAGED_PROXY_UNSUPPORTED_MODEL_PATTERN.test(error.message)
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Outage-shaped failures that justify switching to a backup profile. */
|
|
450
|
+
function isFallbackEligibleError(
|
|
451
|
+
error: unknown,
|
|
452
|
+
opts: {
|
|
453
|
+
retriesExhausted: boolean;
|
|
454
|
+
credentialSource?: ProviderCredentialSource;
|
|
455
|
+
},
|
|
456
|
+
): boolean {
|
|
457
|
+
// Deterministic request failures and caller-initiated aborts never justify
|
|
458
|
+
// a different route (same short-circuits as `isRetryableError`): the same
|
|
459
|
+
// oversized prompt overflows the backup too, and a cancelled request must
|
|
460
|
+
// stay cancelled.
|
|
461
|
+
if (isContextOverflowError(error)) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
if (isCallerAbort(error)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
// (a) The retry loop burned its whole budget on a transient error
|
|
468
|
+
// (429/5xx/overloaded/network/transport-abort): the primary route is down.
|
|
469
|
+
if (opts.retriesExhausted && isRetryableError(error)) {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
if (!(error instanceof ProviderError)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
// (b) Invalid managed credential (the invalid-key incident). Applies only
|
|
476
|
+
// to `vellum-managed` routes, where the platform owns the credential and a
|
|
477
|
+
// broken key is a platform incident. A BYOK or OAuth-subscription route
|
|
478
|
+
// with a broken personal credential must surface the auth error so the
|
|
479
|
+
// user can fix it, not silently reroute to a differently billed backup.
|
|
480
|
+
// Only reached after `sendMessage`'s credential-refresh path has already
|
|
481
|
+
// been attempted for managed routes: same status/reason gate as
|
|
482
|
+
// `shouldRefreshManagedCredential`.
|
|
483
|
+
if (
|
|
484
|
+
opts.credentialSource === "vellum-managed" &&
|
|
485
|
+
(error.statusCode === 401 || error.statusCode === 403) &&
|
|
486
|
+
(error.reason === undefined ||
|
|
487
|
+
error.reason === "unknown" ||
|
|
488
|
+
error.reason === "invalid_credentials")
|
|
489
|
+
) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
// (c) The model is gone (a rename/retirement incident): a different model's
|
|
493
|
+
// route is exactly what fixes it. See `isModelSpecificError` for which
|
|
494
|
+
// shapes qualify and which deliberately do not.
|
|
495
|
+
return isModelSpecificError(error);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Structured error class for the "Falling back to backup profile" log. */
|
|
499
|
+
function fallbackErrorType(error: unknown, retriesExhausted: boolean): string {
|
|
500
|
+
if (retriesExhausted) {
|
|
501
|
+
return "retries_exhausted";
|
|
502
|
+
}
|
|
503
|
+
if (error instanceof ProviderError) {
|
|
504
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
505
|
+
return "invalid_credentials";
|
|
506
|
+
}
|
|
507
|
+
if (error.statusCode === 404 || error.statusCode === 400) {
|
|
508
|
+
return "model_not_found";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return "unknown";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Whether a failed request can be re-routed to a backup profile. Fallback
|
|
516
|
+
* re-resolves the ORIGINAL caller options with the backup profile forced,
|
|
517
|
+
* which requires a `callSite`-bearing config. An explicit per-call
|
|
518
|
+
* route pin (`model`, `provider`, or `provider_connection`) disqualifies the
|
|
519
|
+
* request, whether it came from this call or the persisted call-site config.
|
|
520
|
+
* Silently serving a different route would violate user intent: a profile
|
|
521
|
+
* user asked for a tier, while a pinning user asked for an exact route. Pinned
|
|
522
|
+
* calls keep retry-then-error behavior.
|
|
523
|
+
*/
|
|
524
|
+
const EXACT_ROUTE_PIN_KEYS = [
|
|
525
|
+
"model",
|
|
526
|
+
"provider",
|
|
527
|
+
"provider_connection",
|
|
528
|
+
] as const;
|
|
529
|
+
|
|
530
|
+
function hasExactRoutePin(
|
|
531
|
+
config: Record<string, unknown> | undefined,
|
|
532
|
+
): boolean {
|
|
533
|
+
return EXACT_ROUTE_PIN_KEYS.some((key) => {
|
|
534
|
+
const value = config?.[key];
|
|
535
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function canReRouteToFallbackProfile(options?: SendMessageOptions): boolean {
|
|
540
|
+
const config = options?.config;
|
|
541
|
+
if (config?.callSite === undefined) {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
if (hasExactRoutePin(config)) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
const callSiteConfig = getConfig().llm.callSites?.[config.callSite] as
|
|
548
|
+
| Record<string, unknown>
|
|
549
|
+
| undefined;
|
|
550
|
+
return !hasExactRoutePin(callSiteConfig);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The route a failure indicts, or null when it must not be remembered at all.
|
|
555
|
+
* An outage marks the whole upstream; a retired or renamed model marks only
|
|
556
|
+
* that model, because diverting every healthy profile on the upstream for one
|
|
557
|
+
* model's 404 does more damage than the incident. A model-specific failure on
|
|
558
|
+
* a request whose model cannot be named is not remembered either: naming the
|
|
559
|
+
* upstream instead would be exactly that over-trip.
|
|
560
|
+
*/
|
|
561
|
+
function failureBreakerRoute(
|
|
562
|
+
route: BreakerRoute,
|
|
563
|
+
error: unknown,
|
|
564
|
+
): BreakerRoute | null {
|
|
565
|
+
if (!isModelSpecificError(error)) {
|
|
566
|
+
return { upstream: route.upstream };
|
|
567
|
+
}
|
|
568
|
+
return route.model === undefined ? null : route;
|
|
569
|
+
}
|
|
570
|
+
|
|
333
571
|
/**
|
|
334
572
|
* Whether the request lands on Anthropic's Messages API wire: direct Anthropic
|
|
335
573
|
* calls, plus OpenRouter / Vercel AI Gateway calls that delegate `anthropic/*`
|
|
@@ -383,6 +621,7 @@ function normalizeSendMessageOptions(
|
|
|
383
621
|
// must never leak into provider JSON request bodies.
|
|
384
622
|
delete nextConfig.usageAttributionHeaders;
|
|
385
623
|
delete nextConfig.usageTracking;
|
|
624
|
+
delete nextConfig.requestHeaders;
|
|
386
625
|
|
|
387
626
|
// Preserve the per-conversation prompt-cache key before `selectionSeed` is
|
|
388
627
|
// stripped below. Gated to providers whose Responses transport consumes it
|
|
@@ -401,17 +640,32 @@ function normalizeSendMessageOptions(
|
|
|
401
640
|
nextConfig.promptCacheKey = config.selectionSeed;
|
|
402
641
|
}
|
|
403
642
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
643
|
+
if (providerName === "opencode") {
|
|
644
|
+
const conversationId =
|
|
645
|
+
typeof config.conversationId === "string"
|
|
646
|
+
? config.conversationId
|
|
647
|
+
: undefined;
|
|
648
|
+
const requestHeaders = buildOpenCodeRequestHeaders({
|
|
649
|
+
conversationId,
|
|
650
|
+
requestId: randomUUID(),
|
|
651
|
+
});
|
|
652
|
+
if (Object.keys(requestHeaders).length > 0) {
|
|
653
|
+
nextConfig.requestHeaders = requestHeaders;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// `overrideProfile`, `forceOverrideProfile`, `selectionSeed`,
|
|
658
|
+
// `conversationId`, and `nativeWebSearchSentinel` are routing/resolution-time
|
|
659
|
+
// concerns (consumed by the resolver below, `CallSiteRoutingProvider`'s
|
|
660
|
+
// provider selection, `UsageTrackingProvider`'s ledger attribution, and the
|
|
661
|
+
// fallback tool filter); none is a wire-format field. Strip unconditionally
|
|
662
|
+
// (after the `openai` promptCacheKey copy above) so they never leak into
|
|
663
|
+
// provider request bodies even when callers set them without a `callSite`.
|
|
411
664
|
delete nextConfig.overrideProfile;
|
|
412
665
|
delete nextConfig.forceOverrideProfile;
|
|
413
666
|
delete nextConfig.selectionSeed;
|
|
414
667
|
delete nextConfig.conversationId;
|
|
668
|
+
delete nextConfig.nativeWebSearchSentinel;
|
|
415
669
|
|
|
416
670
|
if (config.callSite !== undefined) {
|
|
417
671
|
const resolved = resolveCallSiteConfig(config.callSite, getConfig().llm, {
|
|
@@ -892,6 +1146,32 @@ export class RetryProvider implements Provider {
|
|
|
892
1146
|
credentialSource?: ProviderCredentialSource;
|
|
893
1147
|
connectionName?: string;
|
|
894
1148
|
refreshCredentialProvider?: () => Promise<Provider | null>;
|
|
1149
|
+
/**
|
|
1150
|
+
* Escalation hook: resolve a backup route (a ready adapter, the backup
|
|
1151
|
+
* profile key, and the backup route's usage-attribution forwarding
|
|
1152
|
+
* policy) for a request whose primary route failed with an
|
|
1153
|
+
* outage-shaped error (see {@link isFallbackEligibleError}). The
|
|
1154
|
+
* callback owns all profile knowledge: it inspects the failed call's
|
|
1155
|
+
* winning profile and returns null when that profile declares no
|
|
1156
|
+
* `fallbackProfile`, mirroring how `refreshCredentialProvider` keeps
|
|
1157
|
+
* this wrapper ignorant of credential storage.
|
|
1158
|
+
* `forwardUsageAttributionHeaders` must describe the BACKUP route, not
|
|
1159
|
+
* the primary: managed-proxy routes pass true, BYOK and other
|
|
1160
|
+
* third-party routes pass false. The fallback send normalizes with the
|
|
1161
|
+
* returned policy so `X-Vellum-*` billing metadata never leaks to a
|
|
1162
|
+
* third party and is never omitted from the managed proxy. One hop max:
|
|
1163
|
+
* the returned adapter must be RAW, so the backup can never escalate
|
|
1164
|
+
* again whatever happens to it. Whether the backup send gets a retry
|
|
1165
|
+
* budget depends on which entry point escalated; see
|
|
1166
|
+
* `sendOnFallbackRoute`.
|
|
1167
|
+
*/
|
|
1168
|
+
resolveFallbackRoute?: (
|
|
1169
|
+
failedOptions: SendMessageOptions | undefined,
|
|
1170
|
+
) => Promise<{
|
|
1171
|
+
provider: Provider;
|
|
1172
|
+
overrideProfile: string;
|
|
1173
|
+
forwardUsageAttributionHeaders: boolean;
|
|
1174
|
+
} | null>;
|
|
895
1175
|
} = {},
|
|
896
1176
|
) {
|
|
897
1177
|
this.inner = inner;
|
|
@@ -913,6 +1193,40 @@ export class RetryProvider implements Provider {
|
|
|
913
1193
|
);
|
|
914
1194
|
}
|
|
915
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Reload the managed credential after an auth rejection and swap the inner
|
|
1198
|
+
* provider for one built around it, so a key rotated out of band is picked
|
|
1199
|
+
* up without a restart. Returns true when a refreshed adapter took over and
|
|
1200
|
+
* the request is worth attempting again. Never throws: a failed reload
|
|
1201
|
+
* leaves the original error to surface.
|
|
1202
|
+
*/
|
|
1203
|
+
private async refreshManagedCredential(): Promise<boolean> {
|
|
1204
|
+
try {
|
|
1205
|
+
const refreshed = await this.options.refreshCredentialProvider?.();
|
|
1206
|
+
if (refreshed) {
|
|
1207
|
+
this.inner = refreshed;
|
|
1208
|
+
log.info(
|
|
1209
|
+
{
|
|
1210
|
+
provider: this.name,
|
|
1211
|
+
connectionName: this.options.connectionName,
|
|
1212
|
+
},
|
|
1213
|
+
"Retrying managed inference with refreshed assistant credentials",
|
|
1214
|
+
);
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
1217
|
+
} catch (refreshError) {
|
|
1218
|
+
log.warn(
|
|
1219
|
+
{
|
|
1220
|
+
provider: this.name,
|
|
1221
|
+
connectionName: this.options.connectionName,
|
|
1222
|
+
refreshError,
|
|
1223
|
+
},
|
|
1224
|
+
"Failed to reload managed assistant credentials",
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
916
1230
|
private attributeCredential(error: unknown): void {
|
|
917
1231
|
const { credentialSource, connectionName } = this.options;
|
|
918
1232
|
if (
|
|
@@ -933,9 +1247,10 @@ export class RetryProvider implements Provider {
|
|
|
933
1247
|
messages: Message[],
|
|
934
1248
|
options?: SendMessageOptions,
|
|
935
1249
|
): Promise<ProviderResponse> {
|
|
936
|
-
let didRetry = false;
|
|
937
1250
|
let retryAttempt = 0;
|
|
938
1251
|
let credentialRefreshAttempted = false;
|
|
1252
|
+
let correctiveResendAttempted = false;
|
|
1253
|
+
let fallbackAttempted = false;
|
|
939
1254
|
let messagesForAttempt = messages;
|
|
940
1255
|
|
|
941
1256
|
const normalizedOptions = normalizeSendMessageOptions(this.name, options, {
|
|
@@ -943,12 +1258,235 @@ export class RetryProvider implements Provider {
|
|
|
943
1258
|
this.options.forwardUsageAttributionHeaders === true,
|
|
944
1259
|
});
|
|
945
1260
|
|
|
1261
|
+
// Only a request that can actually take the backup consults the circuit
|
|
1262
|
+
// breaker: it needs a wired escalation hook (BYOK, oauth-subscription, and
|
|
1263
|
+
// no-auth routes have none, so they keep today's behavior exactly) and it
|
|
1264
|
+
// must be re-routable. One gate for reads and writes alike, so a request
|
|
1265
|
+
// that bypasses the breaker can neither be skipped by it nor close a trip
|
|
1266
|
+
// that re-routable traffic still needs.
|
|
1267
|
+
//
|
|
1268
|
+
// The model comes from the resolved options, since a retired model is
|
|
1269
|
+
// remembered per model rather than per upstream.
|
|
1270
|
+
const breakerRoute: BreakerRoute | null =
|
|
1271
|
+
this.options.resolveFallbackRoute !== undefined &&
|
|
1272
|
+
canReRouteToFallbackProfile(options)
|
|
1273
|
+
? {
|
|
1274
|
+
upstream: this.name,
|
|
1275
|
+
...(typeof normalizedOptions?.config?.model === "string"
|
|
1276
|
+
? { model: normalizedOptions.config.model }
|
|
1277
|
+
: {}),
|
|
1278
|
+
}
|
|
1279
|
+
: null;
|
|
1280
|
+
|
|
1281
|
+
if (breakerRoute !== null) {
|
|
1282
|
+
if (shouldSkipPrimary(breakerRoute)) {
|
|
1283
|
+
// The primary is known to be down, so its retry budget would only add
|
|
1284
|
+
// latency to an answer the backup was always going to give.
|
|
1285
|
+
log.info(
|
|
1286
|
+
{
|
|
1287
|
+
provider: this.name,
|
|
1288
|
+
connectionName: this.options.connectionName,
|
|
1289
|
+
},
|
|
1290
|
+
"Skipping the primary route while its fallback breaker is open",
|
|
1291
|
+
);
|
|
1292
|
+
fallbackAttempted = true;
|
|
1293
|
+
const served = await this.sendOnFallbackRoute(
|
|
1294
|
+
messages,
|
|
1295
|
+
options,
|
|
1296
|
+
undefined,
|
|
1297
|
+
false,
|
|
1298
|
+
// The rule this argument encodes: a request gets a retry budget on
|
|
1299
|
+
// the backup only when it has not already spent one. This request
|
|
1300
|
+
// skips the primary outright, so it has spent nothing, and on a
|
|
1301
|
+
// single attempt a lone 429 or mid-stream cut would fail the turn.
|
|
1302
|
+
{ backupRetryBudget: true },
|
|
1303
|
+
);
|
|
1304
|
+
if (served !== null) {
|
|
1305
|
+
return served;
|
|
1306
|
+
}
|
|
1307
|
+
// No backup route applies after all (the config changed under the
|
|
1308
|
+
// remembered outage), so the primary is the only route left. The
|
|
1309
|
+
// escalation path below stays disabled: it would resolve the same
|
|
1310
|
+
// options against the same config and get the same nothing.
|
|
1311
|
+
} else if (tryAcquireRecoveryProbe(breakerRoute)) {
|
|
1312
|
+
log.info(
|
|
1313
|
+
{
|
|
1314
|
+
provider: this.name,
|
|
1315
|
+
connectionName: this.options.connectionName,
|
|
1316
|
+
model: breakerRoute.model,
|
|
1317
|
+
},
|
|
1318
|
+
"Probing the primary route for recovery",
|
|
1319
|
+
);
|
|
1320
|
+
// One probe, one attempt: no retry loop. Two one-shot repairs are the
|
|
1321
|
+
// exception, both because the probe would otherwise misread its own
|
|
1322
|
+
// failure as the route still being down. A managed credential that
|
|
1323
|
+
// expired during the outage is refreshed, or the route could never come
|
|
1324
|
+
// back. Malformed tool-argument JSON gets the same corrective note the
|
|
1325
|
+
// retry loop appends, because that failure is conditioned on the
|
|
1326
|
+
// request rather than the route.
|
|
1327
|
+
//
|
|
1328
|
+
// The probe ends the moment it reports a verdict. What happens to the
|
|
1329
|
+
// request that carried it then depends on that verdict: an outage sends
|
|
1330
|
+
// it to the backup, and a recovery hands it back to the ordinary retry
|
|
1331
|
+
// loop below, which is now the right place for it because the route it
|
|
1332
|
+
// just cleared is the one that loop sends to.
|
|
1333
|
+
//
|
|
1334
|
+
// Every send the probe makes is counted, repairs included. A repair is
|
|
1335
|
+
// still a send against the primary, so it is what the seed below has to
|
|
1336
|
+
// be built from: a constant would only be right on the path where no
|
|
1337
|
+
// repair ran.
|
|
1338
|
+
let probeSends = 0;
|
|
1339
|
+
while (true) {
|
|
1340
|
+
try {
|
|
1341
|
+
probeSends += 1;
|
|
1342
|
+
const response = await this.inner.sendMessage(
|
|
1343
|
+
messagesForAttempt,
|
|
1344
|
+
normalizedOptions,
|
|
1345
|
+
);
|
|
1346
|
+
releaseRecoveryProbe(breakerRoute, { verdict: "recovered" });
|
|
1347
|
+
return response;
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
// A cancelled request asked the route nothing, so the probe has no
|
|
1350
|
+
// verdict to report. Hand the claim back and leave the breaker as
|
|
1351
|
+
// it was: reporting recovery here would delete an entry nothing
|
|
1352
|
+
// retested and send the next request through the full retry budget
|
|
1353
|
+
// of a route still known to be down.
|
|
1354
|
+
if (isCallerAbort(error)) {
|
|
1355
|
+
releaseRecoveryProbe(breakerRoute, { verdict: "abandoned" });
|
|
1356
|
+
this.attributeCredential(error);
|
|
1357
|
+
throw error;
|
|
1358
|
+
}
|
|
1359
|
+
if (
|
|
1360
|
+
!credentialRefreshAttempted &&
|
|
1361
|
+
this.shouldRefreshManagedCredential(error)
|
|
1362
|
+
) {
|
|
1363
|
+
credentialRefreshAttempted = true;
|
|
1364
|
+
if (await this.refreshManagedCredential()) {
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
// The same one-shot corrective resend the retry loop performs. A
|
|
1369
|
+
// byte-identical resend can reproduce malformed tool-argument JSON
|
|
1370
|
+
// indefinitely, so without the note the probe would report an
|
|
1371
|
+
// outage the route had nothing to do with. Skipped when the hint
|
|
1372
|
+
// has nowhere to go (an assistant prefill tail), since an
|
|
1373
|
+
// unchanged resend would only cost another round trip.
|
|
1374
|
+
if (
|
|
1375
|
+
!correctiveResendAttempted &&
|
|
1376
|
+
isUnparseableToolArgsError(error)
|
|
1377
|
+
) {
|
|
1378
|
+
correctiveResendAttempted = true;
|
|
1379
|
+
const repaired = withUnparseableToolArgsHint(messages);
|
|
1380
|
+
if (repaired !== messages) {
|
|
1381
|
+
messagesForAttempt = repaired;
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
// The probe stands in for the whole retry budget while the breaker
|
|
1386
|
+
// is open, so a failure the retry loop would have exhausted itself
|
|
1387
|
+
// against means the outage continues. Any other failure means the
|
|
1388
|
+
// route answered the request, which is all the probe asked.
|
|
1389
|
+
//
|
|
1390
|
+
// A mid-stream corruption is such an answer: every pattern in
|
|
1391
|
+
// `RETRYABLE_STREAM_PATTERNS` requires an absent HTTP status, which
|
|
1392
|
+
// means the upstream accepted the request, returned 200, and
|
|
1393
|
+
// streamed content. The failure is in the bytes it produced, not in
|
|
1394
|
+
// its ability to serve, so it is evidence the primary is HEALTHY
|
|
1395
|
+
// and must not extend a remembered outage that can reach ten
|
|
1396
|
+
// minutes. A 429 is deliberately NOT treated the same way: the
|
|
1397
|
+
// route refused to do the work, no resend repairs it (only waiting
|
|
1398
|
+
// does, which is exactly what the cooldown provides), and reading a
|
|
1399
|
+
// rate limit as recovery would send the whole fleet back to a
|
|
1400
|
+
// primary that rejects every request. Provider-declared
|
|
1401
|
+
// `overloaded` and transport aborts stay outages for the same
|
|
1402
|
+
// reason: neither produced a usable answer.
|
|
1403
|
+
const outage =
|
|
1404
|
+
!isRetryableStreamError(error) &&
|
|
1405
|
+
isFallbackEligibleError(error, {
|
|
1406
|
+
retriesExhausted: true,
|
|
1407
|
+
credentialSource: this.options.credentialSource,
|
|
1408
|
+
});
|
|
1409
|
+
// The probe's own error decides what stays remembered, not the
|
|
1410
|
+
// scope of the entry it was acquired under: an upstream that
|
|
1411
|
+
// answers with a retired-model 404 has stopped being an outage,
|
|
1412
|
+
// and a model outage that turns into a 503 has stopped being about
|
|
1413
|
+
// the model.
|
|
1414
|
+
releaseRecoveryProbe(
|
|
1415
|
+
breakerRoute,
|
|
1416
|
+
outage
|
|
1417
|
+
? {
|
|
1418
|
+
verdict: "failing",
|
|
1419
|
+
failedRoute: failureBreakerRoute(breakerRoute, error),
|
|
1420
|
+
}
|
|
1421
|
+
: { verdict: "recovered" },
|
|
1422
|
+
);
|
|
1423
|
+
this.attributeCredential(error);
|
|
1424
|
+
if (!outage) {
|
|
1425
|
+
// The route answered, the breaker is closed, and this request is
|
|
1426
|
+
// an ordinary request again. A deterministic rejection (a plain
|
|
1427
|
+
// 400, a classified 404, a context overflow) is the route's real
|
|
1428
|
+
// answer and no resend changes it, so it surfaces as itself.
|
|
1429
|
+
if (!isRetryableError(error)) {
|
|
1430
|
+
throw error;
|
|
1431
|
+
}
|
|
1432
|
+
// Anything still standing here is the stream-corruption family
|
|
1433
|
+
// the exclusion above lets through: exactly the failure the main
|
|
1434
|
+
// loop repairs by resending, against a route that was just
|
|
1435
|
+
// cleared. Throwing it would sacrifice the request that carried
|
|
1436
|
+
// the probe to establish a verdict every LATER request gets to
|
|
1437
|
+
// use, so it falls through into the ordinary loop instead. That
|
|
1438
|
+
// loop also keeps the backup as its last resort, so a primary
|
|
1439
|
+
// that streams corruption all the way through still finishes the
|
|
1440
|
+
// turn somewhere.
|
|
1441
|
+
//
|
|
1442
|
+
// Every send the probe made WAS this request spending its own
|
|
1443
|
+
// attempts, so the loop starts that many attempts in. The loop
|
|
1444
|
+
// retries while `retryAttempt < DEFAULT_MAX_RETRIES`, so seeding
|
|
1445
|
+
// it with `probeSends` leaves `1 + (DEFAULT_MAX_RETRIES -
|
|
1446
|
+
// probeSends)` sends below and `DEFAULT_MAX_RETRIES + 1` in
|
|
1447
|
+
// total, for any number of probe sends: the same budget a request
|
|
1448
|
+
// that never probes gets. Counting sends rather than entries into
|
|
1449
|
+
// this branch is what holds that equality for a probe whose
|
|
1450
|
+
// repairs (a credential refresh, a corrective resend) each cost a
|
|
1451
|
+
// send of their own.
|
|
1452
|
+
retryAttempt = probeSends;
|
|
1453
|
+
break;
|
|
1454
|
+
}
|
|
1455
|
+
fallbackAttempted = true;
|
|
1456
|
+
const served = await this.sendOnFallbackRoute(
|
|
1457
|
+
messages,
|
|
1458
|
+
options,
|
|
1459
|
+
error,
|
|
1460
|
+
true,
|
|
1461
|
+
// The probe is one attempt on the primary, not a retry loop, so
|
|
1462
|
+
// this request has spent no retry budget either. Same reasoning
|
|
1463
|
+
// as the breaker-open skip above.
|
|
1464
|
+
{ backupRetryBudget: true },
|
|
1465
|
+
);
|
|
1466
|
+
if (served !== null) {
|
|
1467
|
+
// No trip needed: the failed probe already re-tripped the breaker
|
|
1468
|
+
// with the longer cooldown a repeat outage earns.
|
|
1469
|
+
return served;
|
|
1470
|
+
}
|
|
1471
|
+
// The probe confirmed this route is still down. If the backup
|
|
1472
|
+
// cannot serve, surface that primary failure directly instead of
|
|
1473
|
+
// spending a fresh retry budget on the route the probe just
|
|
1474
|
+
// re-tripped.
|
|
1475
|
+
throw error;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
946
1481
|
while (true) {
|
|
947
1482
|
try {
|
|
948
1483
|
const result = await this.inner.sendMessage(
|
|
949
1484
|
messagesForAttempt,
|
|
950
1485
|
normalizedOptions,
|
|
951
1486
|
);
|
|
1487
|
+
if (breakerRoute !== null) {
|
|
1488
|
+
recordPrimarySuccess(breakerRoute);
|
|
1489
|
+
}
|
|
952
1490
|
return result;
|
|
953
1491
|
} catch (error) {
|
|
954
1492
|
if (
|
|
@@ -956,28 +1494,8 @@ export class RetryProvider implements Provider {
|
|
|
956
1494
|
this.shouldRefreshManagedCredential(error)
|
|
957
1495
|
) {
|
|
958
1496
|
credentialRefreshAttempted = true;
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (refreshed) {
|
|
962
|
-
this.inner = refreshed;
|
|
963
|
-
log.info(
|
|
964
|
-
{
|
|
965
|
-
provider: this.name,
|
|
966
|
-
connectionName: this.options.connectionName,
|
|
967
|
-
},
|
|
968
|
-
"Retrying managed inference with refreshed assistant credentials",
|
|
969
|
-
);
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
} catch (refreshError) {
|
|
973
|
-
log.warn(
|
|
974
|
-
{
|
|
975
|
-
provider: this.name,
|
|
976
|
-
connectionName: this.options.connectionName,
|
|
977
|
-
refreshError,
|
|
978
|
-
},
|
|
979
|
-
"Failed to reload managed assistant credentials",
|
|
980
|
-
);
|
|
1497
|
+
if (await this.refreshManagedCredential()) {
|
|
1498
|
+
continue;
|
|
981
1499
|
}
|
|
982
1500
|
}
|
|
983
1501
|
|
|
@@ -990,61 +1508,431 @@ export class RetryProvider implements Provider {
|
|
|
990
1508
|
messagesForAttempt = withUnparseableToolArgsHint(messages);
|
|
991
1509
|
}
|
|
992
1510
|
// Prefer server-provided Retry-After; fall back to exponential backoff.
|
|
993
|
-
const
|
|
994
|
-
error instanceof ProviderError ? error.retryAfterMs : undefined;
|
|
995
|
-
const MAX_RETRY_DELAY_MS = 60_000; // Cap server-suggested delays at 60s
|
|
996
|
-
const delay = Math.min(
|
|
997
|
-
retryAfter ??
|
|
998
|
-
computeRetryDelay(retryAttempt, DEFAULT_BASE_DELAY_MS),
|
|
999
|
-
MAX_RETRY_DELAY_MS,
|
|
1000
|
-
);
|
|
1001
|
-
const errorType =
|
|
1002
|
-
error instanceof ProviderError && error.statusCode === 429
|
|
1003
|
-
? "rate_limit"
|
|
1004
|
-
: error instanceof ProviderError &&
|
|
1005
|
-
error.statusCode !== undefined &&
|
|
1006
|
-
error.statusCode >= 500
|
|
1007
|
-
? `server_error_${error.statusCode}`
|
|
1008
|
-
: isRetryableProviderMessage(error)
|
|
1009
|
-
? "provider_overloaded"
|
|
1010
|
-
: isRetryableStreamError(error)
|
|
1011
|
-
? "stream_corruption"
|
|
1012
|
-
: isRetryableTransportAbort(error)
|
|
1013
|
-
? "transport_abort"
|
|
1014
|
-
: "network_error";
|
|
1511
|
+
const { delay, retryAfterHeader } = retryPlan(error, retryAttempt);
|
|
1015
1512
|
log.warn(
|
|
1016
1513
|
{
|
|
1017
1514
|
attempt: retryAttempt + 1,
|
|
1018
1515
|
maxRetries: DEFAULT_MAX_RETRIES,
|
|
1019
1516
|
delay,
|
|
1020
|
-
retryAfterHeader
|
|
1021
|
-
errorType,
|
|
1517
|
+
retryAfterHeader,
|
|
1518
|
+
errorType: retryErrorType(error),
|
|
1022
1519
|
correctiveHint: messagesForAttempt !== messages,
|
|
1023
1520
|
provider: this.name,
|
|
1024
1521
|
message: error instanceof Error ? error.message : String(error),
|
|
1025
1522
|
},
|
|
1026
1523
|
"Retrying after transient error",
|
|
1027
1524
|
);
|
|
1028
|
-
didRetry = true;
|
|
1029
1525
|
retryAttempt++;
|
|
1030
1526
|
await sleep(delay);
|
|
1031
1527
|
continue;
|
|
1032
1528
|
}
|
|
1033
1529
|
|
|
1034
1530
|
// If we exhausted retries on a retryable error, tag the error so
|
|
1035
|
-
// downstream consumers (Sentry capture,
|
|
1036
|
-
// retry loop already tried its best.
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1531
|
+
// downstream consumers (Sentry capture, escalation eligibility) can
|
|
1532
|
+
// recognize that the retry loop already tried its best. Control
|
|
1533
|
+
// reaches here for two reasons only, and the retryable predicate is
|
|
1534
|
+
// what separates them: either the budget is gone, or the error was
|
|
1535
|
+
// never retryable in the first place.
|
|
1536
|
+
//
|
|
1537
|
+
// Exhaustion is read off the same counter the retry guard above reads,
|
|
1538
|
+
// never off whether this loop happened to perform a retry itself. A
|
|
1539
|
+
// request can arrive here with its budget already consumed elsewhere:
|
|
1540
|
+
// a recovery probe seeds `retryAttempt` with the sends it made, so a
|
|
1541
|
+
// probe that spent the budget on its own repairs leaves the loop below
|
|
1542
|
+
// no retry to perform and would otherwise look like a request that had
|
|
1543
|
+
// never tried at all. Reading the counter keeps the two definitions
|
|
1544
|
+
// from drifting apart however the seed changes.
|
|
1545
|
+
const retriesExhausted =
|
|
1546
|
+
retryAttempt >= DEFAULT_MAX_RETRIES && isRetryableError(error);
|
|
1547
|
+
if (retriesExhausted && error instanceof Error) {
|
|
1041
1548
|
(error as Error & { retriesExhausted?: boolean }).retriesExhausted =
|
|
1042
1549
|
true;
|
|
1043
1550
|
}
|
|
1044
1551
|
|
|
1045
1552
|
this.attributeCredential(error);
|
|
1553
|
+
|
|
1554
|
+
// Last resort before rethrowing: escalate an outage-shaped failure to
|
|
1555
|
+
// the backup profile's route when the construction site wired one in.
|
|
1556
|
+
// One hop max; a request pinned to an explicit model never re-routes.
|
|
1557
|
+
if (
|
|
1558
|
+
!fallbackAttempted &&
|
|
1559
|
+
this.options.resolveFallbackRoute !== undefined &&
|
|
1560
|
+
canReRouteToFallbackProfile(options) &&
|
|
1561
|
+
isFallbackEligibleError(error, {
|
|
1562
|
+
retriesExhausted,
|
|
1563
|
+
credentialSource: this.options.credentialSource,
|
|
1564
|
+
})
|
|
1565
|
+
) {
|
|
1566
|
+
fallbackAttempted = true;
|
|
1567
|
+
// What the failure indicts: the whole upstream for an outage, only
|
|
1568
|
+
// this model for a retirement or rename.
|
|
1569
|
+
const failedRoute =
|
|
1570
|
+
breakerRoute === null
|
|
1571
|
+
? null
|
|
1572
|
+
: failureBreakerRoute(breakerRoute, error);
|
|
1573
|
+
let failureObservation: BreakerObservation | undefined;
|
|
1574
|
+
if (failedRoute !== null) {
|
|
1575
|
+
failureObservation = recordPrimaryFailure(failedRoute);
|
|
1576
|
+
}
|
|
1577
|
+
const fallbackResult = await this.sendOnFallbackRoute(
|
|
1578
|
+
messages,
|
|
1579
|
+
options,
|
|
1580
|
+
error,
|
|
1581
|
+
retriesExhausted,
|
|
1582
|
+
// No budget here: the primary loop above runs to a definitive
|
|
1583
|
+
// verdict before reaching this point, either exhausting its whole
|
|
1584
|
+
// budget against a transient failure or receiving an error no
|
|
1585
|
+
// resend changes. The user has waited through all of that, so the
|
|
1586
|
+
// backup answers once or the turn fails.
|
|
1587
|
+
{ backupRetryBudget: false },
|
|
1588
|
+
);
|
|
1589
|
+
if (fallbackResult !== null) {
|
|
1590
|
+
// A completed backup serve is proof the primary is down and the
|
|
1591
|
+
// backup can carry the traffic, so later requests skip the retry
|
|
1592
|
+
// budget until a probe says the primary is back.
|
|
1593
|
+
if (failedRoute !== null) {
|
|
1594
|
+
recordFallbackServed(failedRoute, Date.now(), failureObservation);
|
|
1595
|
+
}
|
|
1596
|
+
return fallbackResult;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1046
1600
|
throw error;
|
|
1047
1601
|
}
|
|
1048
1602
|
}
|
|
1049
1603
|
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* The `tools` override for the fallback send, or nothing when the original
|
|
1607
|
+
* list carries over unchanged.
|
|
1608
|
+
*
|
|
1609
|
+
* The caller decided whether to append the native web search sentinel from
|
|
1610
|
+
* the PRIMARY route's capability (see `AgentLoop`), so a backup that runs
|
|
1611
|
+
* no server-side search would receive a tool it cannot execute and answer
|
|
1612
|
+
* with a tool call nothing can service. The sentinel is dropped for those
|
|
1613
|
+
* routes: degraded mode loses native search rather than the whole turn.
|
|
1614
|
+
*
|
|
1615
|
+
* Gated on `config.nativeWebSearchSentinel`, never on the name alone: with a
|
|
1616
|
+
* search backend like Brave or the platform search proxy configured, a tool
|
|
1617
|
+
* of the same name is app-executed and works on every route, so filtering it
|
|
1618
|
+
* would take away a capability the backup can still serve.
|
|
1619
|
+
*
|
|
1620
|
+
* `dropToolChoice` reports that filtering emptied the list. `AgentLoop` sets
|
|
1621
|
+
* `tool_choice: { type: "auto" }` under the same condition that appends the
|
|
1622
|
+
* sentinel, so a tool-less call site with native search enabled carries the
|
|
1623
|
+
* sentinel as its ONLY tool. Filtering it and leaving the paired
|
|
1624
|
+
* `tool_choice` behind would put a choice with nothing to choose from on the
|
|
1625
|
+
* wire. The Anthropic Messages API rejects that (its client spreads
|
|
1626
|
+
* `tool_choice` out of the request config whether or not any `tools`
|
|
1627
|
+
* survived), and the OpenAI Responses API would too if its client did not
|
|
1628
|
+
* happen to gate the field on a non-empty tool list. A recoverable outage
|
|
1629
|
+
* would become a hard 400 on the backup.
|
|
1630
|
+
*
|
|
1631
|
+
* Deliberately narrow: the flag is raised only for an EMPTY filtered list,
|
|
1632
|
+
* the one case that is invalid on the wire. A non-empty list keeps whatever
|
|
1633
|
+
* `tool_choice` the caller set. A conversation-level `toolChoice` takes
|
|
1634
|
+
* precedence over the sentinel's `auto` in `AgentLoop`, so it is caller
|
|
1635
|
+
* intent that a route change must not quietly discard, and the request it
|
|
1636
|
+
* produces is still valid.
|
|
1637
|
+
*/
|
|
1638
|
+
private fallbackTools(
|
|
1639
|
+
options: SendMessageOptions | undefined,
|
|
1640
|
+
route: { provider: Provider },
|
|
1641
|
+
): { tools?: ToolDefinition[]; dropToolChoice: boolean } {
|
|
1642
|
+
const tools = options?.tools;
|
|
1643
|
+
if (
|
|
1644
|
+
options?.config?.nativeWebSearchSentinel !== true ||
|
|
1645
|
+
tools === undefined ||
|
|
1646
|
+
!tools.some((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME)
|
|
1647
|
+
) {
|
|
1648
|
+
return { dropToolChoice: false };
|
|
1649
|
+
}
|
|
1650
|
+
const backupServesNativeSearch = route.provider.supportsNativeWebSearchFor
|
|
1651
|
+
? route.provider.supportsNativeWebSearchFor(options)
|
|
1652
|
+
: route.provider.supportsNativeWebSearch === true;
|
|
1653
|
+
if (backupServesNativeSearch) {
|
|
1654
|
+
return { dropToolChoice: false };
|
|
1655
|
+
}
|
|
1656
|
+
const filtered = tools.filter(
|
|
1657
|
+
(tool) => tool.name !== NATIVE_WEB_SEARCH_TOOL_NAME,
|
|
1658
|
+
);
|
|
1659
|
+
return { tools: filtered, dropToolChoice: filtered.length === 0 };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Send on the backup adapter, optionally with a retry budget of its own.
|
|
1664
|
+
*
|
|
1665
|
+
* The one-hop rule is structural rather than conditional here: `route
|
|
1666
|
+
* .provider` is the RAW adapter the route callback built, with no
|
|
1667
|
+
* `RetryProvider` of its own and therefore no `resolveFallbackRoute`, so
|
|
1668
|
+
* nothing this loop calls can escalate to a second backup no matter how many
|
|
1669
|
+
* times it retries.
|
|
1670
|
+
*
|
|
1671
|
+
* `fallbackOptions` is sent verbatim on every attempt, never re-normalized.
|
|
1672
|
+
* `normalizeSendMessageOptions` has already consumed the `callSite` and
|
|
1673
|
+
* stamped the backup route's `usageAttributionHeaders`; running it again over
|
|
1674
|
+
* that now callSite-less config would delete the headers and have no way to
|
|
1675
|
+
* rebuild them, leaving degraded traffic unattributed on the platform's
|
|
1676
|
+
* billing events. This is the same reason the route callback hands back a raw
|
|
1677
|
+
* adapter instead of a wrapped one.
|
|
1678
|
+
*/
|
|
1679
|
+
private async sendOnBackupAdapter(
|
|
1680
|
+
route: { provider: Provider },
|
|
1681
|
+
messages: Message[],
|
|
1682
|
+
fallbackOptions: SendMessageOptions | undefined,
|
|
1683
|
+
retryBudget: boolean,
|
|
1684
|
+
): Promise<ProviderResponse> {
|
|
1685
|
+
let attempt = 0;
|
|
1686
|
+
let didRetry = false;
|
|
1687
|
+
let messagesForAttempt = messages;
|
|
1688
|
+
while (true) {
|
|
1689
|
+
try {
|
|
1690
|
+
return await route.provider.sendMessage(
|
|
1691
|
+
messagesForAttempt,
|
|
1692
|
+
fallbackOptions,
|
|
1693
|
+
);
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
if (
|
|
1696
|
+
!retryBudget ||
|
|
1697
|
+
attempt >= DEFAULT_MAX_RETRIES ||
|
|
1698
|
+
!isRetryableError(error)
|
|
1699
|
+
) {
|
|
1700
|
+
// Same tagging contract as the primary loop, so a backup that flapped
|
|
1701
|
+
// its way through the whole budget is recognizable to Sentry capture
|
|
1702
|
+
// as noise no engineering action would change.
|
|
1703
|
+
if (didRetry && isRetryableError(error) && error instanceof Error) {
|
|
1704
|
+
(error as Error & { retriesExhausted?: boolean }).retriesExhausted =
|
|
1705
|
+
true;
|
|
1706
|
+
}
|
|
1707
|
+
throw error;
|
|
1708
|
+
}
|
|
1709
|
+
// Built from the original `messages` each time, so the corrective note
|
|
1710
|
+
// appears exactly once however many attempts fail this way.
|
|
1711
|
+
if (isUnparseableToolArgsError(error)) {
|
|
1712
|
+
messagesForAttempt = withUnparseableToolArgsHint(messages);
|
|
1713
|
+
}
|
|
1714
|
+
const { delay, retryAfterHeader } = retryPlan(error, attempt);
|
|
1715
|
+
log.warn(
|
|
1716
|
+
{
|
|
1717
|
+
attempt: attempt + 1,
|
|
1718
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
1719
|
+
delay,
|
|
1720
|
+
retryAfterHeader,
|
|
1721
|
+
errorType: retryErrorType(error),
|
|
1722
|
+
correctiveHint: messagesForAttempt !== messages,
|
|
1723
|
+
provider: this.name,
|
|
1724
|
+
backupProvider: route.provider.name,
|
|
1725
|
+
connectionName: this.options.connectionName,
|
|
1726
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1727
|
+
},
|
|
1728
|
+
"Retrying the backup route after a transient error",
|
|
1729
|
+
);
|
|
1730
|
+
didRetry = true;
|
|
1731
|
+
attempt++;
|
|
1732
|
+
await sleep(delay);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Attempt the failed request on the backup route resolved by
|
|
1739
|
+
* `resolveFallbackRoute`. Returns null when no backup route applies (the
|
|
1740
|
+
* caller rethrows the original error unchanged); throws the fallback
|
|
1741
|
+
* error (with the original error attached as `cause`) when the backup
|
|
1742
|
+
* attempt itself fails.
|
|
1743
|
+
*
|
|
1744
|
+
* `originalError` is undefined when the circuit breaker skipped the primary
|
|
1745
|
+
* outright: there is no failure of this request to report or to attach, only
|
|
1746
|
+
* the remembered outage of an earlier one.
|
|
1747
|
+
*
|
|
1748
|
+
* `backupRetryBudget` says whether the backup send gets a retry loop of its
|
|
1749
|
+
* own. It is the caller's answer to one question: has this request already
|
|
1750
|
+
* spent a retry budget somewhere? See the three call sites for the reasoning
|
|
1751
|
+
* behind each answer.
|
|
1752
|
+
*/
|
|
1753
|
+
private async sendOnFallbackRoute(
|
|
1754
|
+
messages: Message[],
|
|
1755
|
+
options: SendMessageOptions | undefined,
|
|
1756
|
+
originalError: unknown,
|
|
1757
|
+
retriesExhausted: boolean,
|
|
1758
|
+
{ backupRetryBudget }: { backupRetryBudget: boolean },
|
|
1759
|
+
): Promise<ProviderResponse | null> {
|
|
1760
|
+
let route: {
|
|
1761
|
+
provider: Provider;
|
|
1762
|
+
overrideProfile: string;
|
|
1763
|
+
forwardUsageAttributionHeaders: boolean;
|
|
1764
|
+
} | null;
|
|
1765
|
+
try {
|
|
1766
|
+
route = (await this.options.resolveFallbackRoute?.(options)) ?? null;
|
|
1767
|
+
} catch (resolveError) {
|
|
1768
|
+
log.warn(
|
|
1769
|
+
{ provider: this.name, resolveError },
|
|
1770
|
+
"Failed to resolve fallback route; rethrowing the original error",
|
|
1771
|
+
);
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1774
|
+
if (route === null) {
|
|
1775
|
+
return null;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Reject a mix backup profile outright. The fallback schema already
|
|
1779
|
+
// forbids `fallbackProfile` from referencing a mix, but this wrapper must
|
|
1780
|
+
// not trust that: honoring one would require the route callback, the
|
|
1781
|
+
// winner-selection guard below, and the re-normalization to agree on the
|
|
1782
|
+
// same seeded arm, and a request without a `selectionSeed` expands the
|
|
1783
|
+
// mix independently at each of those points, so one arm's model could be
|
|
1784
|
+
// sent through another arm's provider adapter. Treat it as non-applying
|
|
1785
|
+
// and surface the original error.
|
|
1786
|
+
if (getConfig().llm.profiles?.[route.overrideProfile]?.mix != null) {
|
|
1787
|
+
log.warn(
|
|
1788
|
+
{
|
|
1789
|
+
provider: this.name,
|
|
1790
|
+
connectionName: this.options.connectionName,
|
|
1791
|
+
overrideProfile: route.overrideProfile,
|
|
1792
|
+
},
|
|
1793
|
+
"Backup profile is a mix, which fallback routing does not support; rethrowing the original error",
|
|
1794
|
+
);
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Guard against a backup profile that does not actually apply: the
|
|
1799
|
+
// resolver skips a disabled, incomplete, or missing override profile and
|
|
1800
|
+
// falls through to the next rung (often the failed primary), while the
|
|
1801
|
+
// request would still dispatch on the backup adapter, producing a
|
|
1802
|
+
// provider/model mismatch. Verify the backup profile wins the winner
|
|
1803
|
+
// selection the re-normalization below will perform; if it does not,
|
|
1804
|
+
// surface the original error instead of sending a mismatched request.
|
|
1805
|
+
const failedConfig = options?.config;
|
|
1806
|
+
const callSite = failedConfig?.callSite;
|
|
1807
|
+
const selectionSeed = failedConfig?.selectionSeed;
|
|
1808
|
+
const selection =
|
|
1809
|
+
callSite === undefined
|
|
1810
|
+
? null
|
|
1811
|
+
: selectWinningProfile(callSite, getConfig().llm, {
|
|
1812
|
+
overrideProfile: route.overrideProfile,
|
|
1813
|
+
...(selectionSeed !== undefined ? { selectionSeed } : {}),
|
|
1814
|
+
isResolvableProvider: dispatchProviderResolvable,
|
|
1815
|
+
});
|
|
1816
|
+
if (
|
|
1817
|
+
selection === null ||
|
|
1818
|
+
selection.source !== "override" ||
|
|
1819
|
+
selection.profileName !== route.overrideProfile
|
|
1820
|
+
) {
|
|
1821
|
+
log.warn(
|
|
1822
|
+
{
|
|
1823
|
+
provider: this.name,
|
|
1824
|
+
connectionName: this.options.connectionName,
|
|
1825
|
+
overrideProfile: route.overrideProfile,
|
|
1826
|
+
selectionSource: selection?.source,
|
|
1827
|
+
selectedProfile: selection?.profileName,
|
|
1828
|
+
},
|
|
1829
|
+
"Backup profile did not apply on re-resolution; rethrowing the original error",
|
|
1830
|
+
);
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Re-resolve the ORIGINAL caller options with the backup profile forced
|
|
1835
|
+
// (`resolveCallSiteConfig` floats a forced override to the top of the
|
|
1836
|
+
// selection chain). Explicit per-call `max_tokens`/`effort`/`thinking`
|
|
1837
|
+
// are cleared so the backup profile's resolved values win; the pin gate
|
|
1838
|
+
// in `canReRouteToFallbackProfile` already excluded explicit
|
|
1839
|
+
// `config.model`. Re-normalizing on a callSite-bearing config also
|
|
1840
|
+
// restamps the usage-attribution headers from the backup resolution, so
|
|
1841
|
+
// platform usage events attribute degraded traffic to the backup
|
|
1842
|
+
// profile. Whether those headers are forwarded at all follows the
|
|
1843
|
+
// FALLBACK route's policy, not the primary's: a managed primary falling
|
|
1844
|
+
// back to a third-party adapter must not leak billing metadata, and a
|
|
1845
|
+
// non-managed primary falling back to the managed proxy must include it.
|
|
1846
|
+
const fallbackConfig: Record<string, unknown> = { ...options?.config };
|
|
1847
|
+
delete fallbackConfig.max_tokens;
|
|
1848
|
+
delete fallbackConfig.effort;
|
|
1849
|
+
delete fallbackConfig.thinking;
|
|
1850
|
+
fallbackConfig.overrideProfile = route.overrideProfile;
|
|
1851
|
+
fallbackConfig.forceOverrideProfile = true;
|
|
1852
|
+
const { dropToolChoice, ...toolsOverride } = this.fallbackTools(
|
|
1853
|
+
options,
|
|
1854
|
+
route,
|
|
1855
|
+
);
|
|
1856
|
+
// Filtering the sentinel emptied the tool list, so the `tool_choice` the
|
|
1857
|
+
// call site paired with it now names a choice among no tools. See
|
|
1858
|
+
// `fallbackTools` for why that is a hard 400 rather than a no-op.
|
|
1859
|
+
if (dropToolChoice) {
|
|
1860
|
+
delete fallbackConfig.tool_choice;
|
|
1861
|
+
}
|
|
1862
|
+
const fallbackOptions = normalizeSendMessageOptions(
|
|
1863
|
+
route.provider.name,
|
|
1864
|
+
{
|
|
1865
|
+
...options,
|
|
1866
|
+
config: fallbackConfig,
|
|
1867
|
+
...toolsOverride,
|
|
1868
|
+
},
|
|
1869
|
+
{
|
|
1870
|
+
forwardUsageAttributionHeaders:
|
|
1871
|
+
route.forwardUsageAttributionHeaders === true,
|
|
1872
|
+
},
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
const escalationCause =
|
|
1876
|
+
originalError === undefined
|
|
1877
|
+
? { errorType: "breaker_open" }
|
|
1878
|
+
: {
|
|
1879
|
+
errorType: fallbackErrorType(originalError, retriesExhausted),
|
|
1880
|
+
message:
|
|
1881
|
+
originalError instanceof Error
|
|
1882
|
+
? originalError.message
|
|
1883
|
+
: String(originalError),
|
|
1884
|
+
};
|
|
1885
|
+
log.warn(
|
|
1886
|
+
{
|
|
1887
|
+
provider: this.name,
|
|
1888
|
+
connectionName: this.options.connectionName,
|
|
1889
|
+
fallbackProvider: route.provider.name,
|
|
1890
|
+
overrideProfile: route.overrideProfile,
|
|
1891
|
+
...escalationCause,
|
|
1892
|
+
},
|
|
1893
|
+
"Falling back to backup profile",
|
|
1894
|
+
);
|
|
1895
|
+
|
|
1896
|
+
try {
|
|
1897
|
+
const response = await this.sendOnBackupAdapter(
|
|
1898
|
+
route,
|
|
1899
|
+
messages,
|
|
1900
|
+
fallbackOptions,
|
|
1901
|
+
backupRetryBudget,
|
|
1902
|
+
);
|
|
1903
|
+
// Stamp the provider that actually served the response. Without this,
|
|
1904
|
+
// a backup adapter that does not set `actualProvider` leaves the outer
|
|
1905
|
+
// call-site router recording the success under the failed primary
|
|
1906
|
+
// provider (wrong provider, wrong pricing attribution). Never
|
|
1907
|
+
// overwrite a more specific value the adapter already set.
|
|
1908
|
+
if (response.actualProvider === undefined) {
|
|
1909
|
+
response.actualProvider = route.provider.name;
|
|
1910
|
+
}
|
|
1911
|
+
// Stamp the profile that actually governed the response for the same
|
|
1912
|
+
// reason: the outer `UsageTrackingProvider` resolves attribution from
|
|
1913
|
+
// the ORIGINAL request options, which still carry the failed primary's
|
|
1914
|
+
// resolution, so without this the usage event would bill the fallback
|
|
1915
|
+
// serve under the wrong profile. Never overwrite a more specific value
|
|
1916
|
+
// an inner wrapper already set.
|
|
1917
|
+
if (response.actualInferenceProfile === undefined) {
|
|
1918
|
+
response.actualInferenceProfile = route.overrideProfile;
|
|
1919
|
+
}
|
|
1920
|
+
return response;
|
|
1921
|
+
} catch (fallbackError) {
|
|
1922
|
+
if (originalError === undefined) {
|
|
1923
|
+
throw fallbackError;
|
|
1924
|
+
}
|
|
1925
|
+
log.warn(
|
|
1926
|
+
{
|
|
1927
|
+
provider: this.name,
|
|
1928
|
+
connectionName: this.options.connectionName,
|
|
1929
|
+
fallbackProvider: route.provider.name,
|
|
1930
|
+
overrideProfile: route.overrideProfile,
|
|
1931
|
+
fallbackError,
|
|
1932
|
+
},
|
|
1933
|
+
"Backup profile failed; rethrowing the original provider error",
|
|
1934
|
+
);
|
|
1935
|
+
return null;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1050
1938
|
}
|