@vellumai/assistant 0.5.7 → 0.5.8
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/Dockerfile +2 -1
- package/docker-entrypoint.sh +9 -0
- package/docs/architecture/memory.md +13 -11
- package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/approval-cascade.test.ts +0 -1
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/call-controller.test.ts +0 -1
- package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
- package/src/__tests__/ces-startup-timeout.test.ts +40 -0
- package/src/__tests__/config-schema-cmd.test.ts +0 -1
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop.test.ts +2 -4
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
- package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
- package/src/__tests__/conversation-queue.test.ts +0 -1
- package/src/__tests__/conversation-slash-queue.test.ts +0 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
- package/src/__tests__/credential-execution-client.test.ts +5 -2
- package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
- package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
- package/src/__tests__/credential-security-e2e.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -5
- package/src/__tests__/credentials-cli.test.ts +4 -3
- package/src/__tests__/daemon-credential-client.test.ts +123 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
- package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
- package/src/__tests__/journal-context.test.ts +335 -0
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
- package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
- package/src/__tests__/memory-recall-quality.test.ts +48 -17
- package/src/__tests__/memory-regressions.test.ts +408 -363
- package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
- package/src/__tests__/non-member-access-request.test.ts +2 -2
- package/src/__tests__/notification-decision-strategy.test.ts +71 -0
- package/src/__tests__/oauth-cli.test.ts +5 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
- package/src/__tests__/provider-error-scenarios.test.ts +0 -267
- package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
- package/src/__tests__/relay-server.test.ts +1 -2
- package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +1 -1
- package/src/__tests__/secure-keys.test.ts +18 -15
- package/src/__tests__/skill-memory.test.ts +17 -3
- package/src/__tests__/stale-approval-dedup.test.ts +171 -0
- package/src/__tests__/stt-hints.test.ts +437 -0
- package/src/__tests__/task-memory-cleanup.test.ts +14 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
- package/src/__tests__/voice-quality.test.ts +58 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
- package/src/acp/agent-process.ts +9 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-request-resolvers.ts +164 -38
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
- package/src/calls/call-controller.ts +9 -5
- package/src/calls/fish-audio-client.ts +26 -14
- package/src/calls/stt-hints.ts +189 -0
- package/src/calls/tts-text-sanitizer.ts +61 -0
- package/src/calls/twilio-routes.ts +32 -4
- package/src/calls/voice-quality.ts +15 -3
- package/src/calls/voice-session-bridge.ts +1 -0
- package/src/cli/commands/avatar.ts +2 -2
- package/src/cli/commands/credentials.ts +110 -94
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/keys.ts +7 -7
- package/src/cli/commands/memory.ts +1 -1
- package/src/cli/commands/oauth/connections.ts +11 -29
- package/src/cli/commands/oauth/platform.ts +389 -43
- package/src/cli/lib/daemon-credential-client.ts +284 -0
- package/src/cli.ts +1 -1
- package/src/config/bundled-skills/AGENTS.md +34 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
- package/src/config/bundled-skills/settings/SKILL.md +15 -2
- package/src/config/bundled-skills/settings/TOOLS.json +46 -1
- package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
- package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
- package/src/config/bundled-skills/slack/SKILL.md +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/defaults.ts +0 -2
- package/src/config/env-registry.ts +4 -4
- package/src/config/env.ts +14 -1
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +8 -11
- package/src/config/schema.ts +5 -16
- package/src/config/schemas/calls.ts +17 -0
- package/src/config/schemas/inference.ts +2 -2
- package/src/config/schemas/journal.ts +16 -0
- package/src/config/schemas/memory-processing.ts +2 -2
- package/src/config/types.ts +1 -0
- package/src/contacts/contact-store.ts +2 -2
- package/src/credential-execution/executable-discovery.ts +1 -1
- package/src/credential-execution/startup-timeout.ts +36 -0
- package/src/daemon/approval-generators.ts +3 -9
- package/src/daemon/conversation-error.ts +13 -1
- package/src/daemon/conversation-memory.ts +1 -2
- package/src/daemon/conversation-process.ts +18 -1
- package/src/daemon/conversation-surfaces.ts +30 -1
- package/src/daemon/conversation.ts +20 -9
- package/src/daemon/guardian-action-generators.ts +3 -9
- package/src/daemon/lifecycle.ts +18 -11
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/server.ts +2 -3
- package/src/memory/app-store.ts +31 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/indexer.ts +19 -10
- package/src/memory/items-extractor.ts +315 -322
- package/src/memory/job-handlers/summarization.ts +26 -16
- package/src/memory/jobs-store.ts +33 -1
- package/src/memory/journal-memory.ts +214 -0
- package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/retriever.test.ts +37 -25
- package/src/memory/retriever.ts +24 -49
- package/src/memory/schema/memory-core.ts +2 -0
- package/src/memory/search/formatting.ts +7 -44
- package/src/memory/search/staleness.ts +4 -0
- package/src/memory/search/tier-classifier.ts +10 -2
- package/src/memory/search/types.ts +2 -5
- package/src/memory/task-memory-cleanup.ts +4 -3
- package/src/notifications/adapters/slack.ts +168 -6
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +59 -2
- package/src/notifications/signal.ts +2 -0
- package/src/notifications/types.ts +2 -0
- package/src/prompts/journal-context.ts +133 -0
- package/src/prompts/persona-resolver.ts +80 -24
- package/src/prompts/system-prompt.ts +8 -0
- package/src/prompts/templates/SOUL.md +10 -0
- package/src/providers/provider-send-message.ts +3 -32
- package/src/providers/registry.ts +2 -139
- package/src/providers/types.ts +1 -1
- package/src/runtime/access-request-helper.ts +4 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
- package/src/runtime/auth/route-policy.ts +2 -0
- package/src/runtime/gateway-client.ts +47 -4
- package/src/runtime/guardian-decision-types.ts +45 -4
- package/src/runtime/http-server.ts +5 -2
- package/src/runtime/routes/access-request-decision.ts +2 -2
- package/src/runtime/routes/app-management-routes.ts +2 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
- package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
- package/src/runtime/routes/channel-readiness-routes.ts +9 -4
- package/src/runtime/routes/debug-routes.ts +12 -9
- package/src/runtime/routes/guardian-approval-interception.ts +168 -11
- package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
- package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
- package/src/runtime/routes/identity-routes.ts +1 -1
- package/src/runtime/routes/inbound-message-handler.ts +31 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
- package/src/runtime/routes/integrations/twilio.ts +52 -10
- package/src/runtime/routes/memory-item-routes.test.ts +3 -3
- package/src/runtime/routes/memory-item-routes.ts +25 -11
- package/src/runtime/routes/secret-routes.ts +141 -10
- package/src/runtime/routes/tts-routes.ts +11 -1
- package/src/security/ces-credential-client.ts +18 -9
- package/src/security/ces-rpc-credential-backend.ts +4 -3
- package/src/security/credential-backend.ts +10 -4
- package/src/security/secure-keys.ts +21 -4
- package/src/skills/catalog-install.ts +4 -36
- package/src/skills/skill-memory.ts +1 -0
- package/src/subagent/manager.ts +2 -5
- package/src/tools/acp/spawn.ts +78 -1
- package/src/tools/credentials/vault.ts +5 -3
- package/src/tools/memory/definitions.ts +3 -2
- package/src/tools/memory/handlers.ts +10 -7
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/util/browser.ts +15 -0
- package/src/util/platform.ts +1 -1
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
- package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
- package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
- package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/workspace/provider-commit-message-generator.ts +12 -21
- package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
- package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
- package/src/memory/search/lexical.ts +0 -48
- package/src/providers/failover.ts +0 -186
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { credentialKey } from "../security/credential-key.js";
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Mock the underlying dependencies of managed-proxy/context.js rather than
|
|
7
|
-
// the context module itself. This avoids global mock bleed: other test files
|
|
8
|
-
// that import context.js will still get the real implementation with their
|
|
9
|
-
// own dependency mocks.
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
let mockPlatformBaseUrl = "";
|
|
12
|
-
let mockAssistantApiKey = "";
|
|
13
|
-
let mockProviderKeys: Record<string, string> = {};
|
|
14
|
-
|
|
15
|
-
const actualEnv = await import("../config/env.js");
|
|
16
|
-
mock.module("../config/env.js", () => ({
|
|
17
|
-
...actualEnv,
|
|
18
|
-
getPlatformBaseUrl: () => mockPlatformBaseUrl,
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const actualSecureKeys = await import("../security/secure-keys.js");
|
|
22
|
-
mock.module("../security/secure-keys.js", () => ({
|
|
23
|
-
...actualSecureKeys,
|
|
24
|
-
getSecureKeyAsync: async (key: string) => {
|
|
25
|
-
if (key === credentialKey("vellum", "assistant_api_key")) {
|
|
26
|
-
return mockAssistantApiKey || null;
|
|
27
|
-
}
|
|
28
|
-
return mockProviderKeys[key] ?? null;
|
|
29
|
-
},
|
|
30
|
-
}));
|
|
31
|
-
|
|
32
|
-
import type { ProvidersConfig } from "../providers/registry.js";
|
|
33
|
-
import {
|
|
34
|
-
getFailoverProvider,
|
|
35
|
-
getProviderRoutingSource,
|
|
36
|
-
initializeProviders,
|
|
37
|
-
listProviders,
|
|
38
|
-
resolveProviderSelection,
|
|
39
|
-
} from "../providers/registry.js";
|
|
40
|
-
import { ProviderNotConfiguredError } from "../util/errors.js";
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Tests for fail-open provider selection: when the configured primary provider
|
|
44
|
-
* is unavailable, the system should automatically fall back to the first
|
|
45
|
-
* available provider in the provider order.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
function makeProvidersConfig(provider: string, model: string): ProvidersConfig {
|
|
49
|
-
return {
|
|
50
|
-
services: {
|
|
51
|
-
inference: { mode: "your-own", provider, model },
|
|
52
|
-
"image-generation": {
|
|
53
|
-
mode: "your-own",
|
|
54
|
-
provider: "gemini",
|
|
55
|
-
model: "gemini-3.1-flash-image-preview",
|
|
56
|
-
},
|
|
57
|
-
"web-search": { mode: "your-own", provider: "inference-provider-native" },
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Initialize registry with anthropic + openai for most tests. */
|
|
63
|
-
async function setupTwoProviders() {
|
|
64
|
-
mockProviderKeys = { anthropic: "test-key", openai: "test-key" };
|
|
65
|
-
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Initialize registry with no providers (empty keys, non-registerable primary). */
|
|
69
|
-
async function setupNoProviders() {
|
|
70
|
-
mockProviderKeys = {};
|
|
71
|
-
await initializeProviders(makeProvidersConfig("gemini", "test-model"));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
describe("resolveProviderSelection", () => {
|
|
75
|
-
test("configured primary available → selected as primary", async () => {
|
|
76
|
-
await setupTwoProviders();
|
|
77
|
-
const result = resolveProviderSelection("anthropic", ["openai"]);
|
|
78
|
-
expect(result.selectedPrimary).toBe("anthropic");
|
|
79
|
-
expect(result.usedFallbackPrimary).toBe(false);
|
|
80
|
-
expect(result.availableProviders).toEqual(["anthropic", "openai"]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("configured primary unavailable + alternate available → alternate selected", async () => {
|
|
84
|
-
await setupTwoProviders();
|
|
85
|
-
const result = resolveProviderSelection("gemini", ["anthropic", "openai"]);
|
|
86
|
-
expect(result.selectedPrimary).toBe("anthropic");
|
|
87
|
-
expect(result.usedFallbackPrimary).toBe(true);
|
|
88
|
-
expect(result.availableProviders).toEqual(["anthropic", "openai"]);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("configured primary unavailable + first alternate also unavailable → second alternate selected", async () => {
|
|
92
|
-
await setupTwoProviders();
|
|
93
|
-
const result = resolveProviderSelection("gemini", ["fireworks", "openai"]);
|
|
94
|
-
expect(result.selectedPrimary).toBe("openai");
|
|
95
|
-
expect(result.usedFallbackPrimary).toBe(true);
|
|
96
|
-
expect(result.availableProviders).toEqual(["openai"]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("deduplicates entries in providerOrder", async () => {
|
|
100
|
-
await setupTwoProviders();
|
|
101
|
-
const result = resolveProviderSelection("anthropic", [
|
|
102
|
-
"anthropic",
|
|
103
|
-
"openai",
|
|
104
|
-
"openai",
|
|
105
|
-
]);
|
|
106
|
-
expect(result.availableProviders).toEqual(["anthropic", "openai"]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("unknown entries in providerOrder are filtered out", async () => {
|
|
110
|
-
await setupTwoProviders();
|
|
111
|
-
const result = resolveProviderSelection("anthropic", [
|
|
112
|
-
"nonexistent",
|
|
113
|
-
"openai",
|
|
114
|
-
]);
|
|
115
|
-
expect(result.availableProviders).toEqual(["anthropic", "openai"]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("no available providers → null selectedPrimary", async () => {
|
|
119
|
-
await setupTwoProviders();
|
|
120
|
-
const result = resolveProviderSelection("gemini", ["fireworks", "ollama"]);
|
|
121
|
-
expect(result.selectedPrimary).toBeNull();
|
|
122
|
-
expect(result.usedFallbackPrimary).toBe(false);
|
|
123
|
-
expect(result.availableProviders).toEqual([]);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("empty providerOrder with available primary → primary only", async () => {
|
|
127
|
-
await setupTwoProviders();
|
|
128
|
-
const result = resolveProviderSelection("anthropic", []);
|
|
129
|
-
expect(result.selectedPrimary).toBe("anthropic");
|
|
130
|
-
expect(result.usedFallbackPrimary).toBe(false);
|
|
131
|
-
expect(result.availableProviders).toEqual(["anthropic"]);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("empty providerOrder with unavailable primary → null", async () => {
|
|
135
|
-
await setupTwoProviders();
|
|
136
|
-
const result = resolveProviderSelection("gemini", []);
|
|
137
|
-
expect(result.selectedPrimary).toBeNull();
|
|
138
|
-
expect(result.availableProviders).toEqual([]);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("getFailoverProvider (fail-open)", () => {
|
|
143
|
-
test("returns provider when primary is available", async () => {
|
|
144
|
-
await setupTwoProviders();
|
|
145
|
-
const provider = getFailoverProvider("anthropic", ["openai"]);
|
|
146
|
-
expect(provider).toBeDefined();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("returns provider when primary is unavailable but alternate exists", async () => {
|
|
150
|
-
await setupTwoProviders();
|
|
151
|
-
const provider = getFailoverProvider("gemini", ["anthropic", "openai"]);
|
|
152
|
-
expect(provider).toBeDefined();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("throws ProviderNotConfiguredError when no providers are available", async () => {
|
|
156
|
-
await setupNoProviders();
|
|
157
|
-
expect(() => getFailoverProvider("gemini", ["fireworks"])).toThrow(
|
|
158
|
-
ProviderNotConfiguredError,
|
|
159
|
-
);
|
|
160
|
-
try {
|
|
161
|
-
getFailoverProvider("gemini", ["fireworks"]);
|
|
162
|
-
} catch (err) {
|
|
163
|
-
expect(err).toBeInstanceOf(ProviderNotConfiguredError);
|
|
164
|
-
const typed = err as ProviderNotConfiguredError;
|
|
165
|
-
expect(typed.requestedProvider).toBe("gemini");
|
|
166
|
-
expect(typed.registeredProviders).toEqual([]);
|
|
167
|
-
expect(typed.message).toMatch(/No providers available/);
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("single available provider returns it directly (no failover wrapper)", async () => {
|
|
172
|
-
await setupTwoProviders();
|
|
173
|
-
const provider = getFailoverProvider("gemini", ["anthropic"]);
|
|
174
|
-
// Should be a RetryProvider wrapping AnthropicProvider, not a FailoverProvider
|
|
175
|
-
expect(provider.name).not.toBe("failover");
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// -------------------------------------------------------------------------
|
|
180
|
-
// Managed proxy fallback
|
|
181
|
-
// -------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
describe("managed proxy fallback", () => {
|
|
184
|
-
function enableManagedProxy() {
|
|
185
|
-
mockPlatformBaseUrl = "https://platform.example.com";
|
|
186
|
-
mockAssistantApiKey = "ast-key-123";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function disableManagedProxy() {
|
|
190
|
-
mockPlatformBaseUrl = "";
|
|
191
|
-
mockAssistantApiKey = "";
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
test("anthropic and gemini are registered via managed fallback when no user key but proxy context is valid", async () => {
|
|
195
|
-
enableManagedProxy();
|
|
196
|
-
try {
|
|
197
|
-
mockProviderKeys = { anthropic: "test-key" };
|
|
198
|
-
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
199
|
-
const registered = listProviders();
|
|
200
|
-
expect(registered).toEqual(["anthropic", "gemini"]);
|
|
201
|
-
} finally {
|
|
202
|
-
disableManagedProxy();
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("user key takes precedence and managed fallback only fills anthropic and gemini", async () => {
|
|
207
|
-
enableManagedProxy();
|
|
208
|
-
try {
|
|
209
|
-
mockProviderKeys = { anthropic: "test-key", openai: "user-openai-key" };
|
|
210
|
-
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
211
|
-
const registered = listProviders();
|
|
212
|
-
expect(registered).toContain("openai");
|
|
213
|
-
expect(registered).toContain("anthropic");
|
|
214
|
-
expect(registered).toContain("gemini");
|
|
215
|
-
expect(registered).not.toContain("fireworks");
|
|
216
|
-
expect(registered).not.toContain("openrouter");
|
|
217
|
-
} finally {
|
|
218
|
-
disableManagedProxy();
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("managed fallback not activated when proxy context is disabled", async () => {
|
|
223
|
-
disableManagedProxy();
|
|
224
|
-
mockProviderKeys = { anthropic: "test-key" };
|
|
225
|
-
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
226
|
-
const registered = listProviders();
|
|
227
|
-
// Anthropic is registered via user-key, not managed proxy.
|
|
228
|
-
expect(registered).toContain("anthropic");
|
|
229
|
-
expect(getProviderRoutingSource("anthropic")).toBe("user-key");
|
|
230
|
-
// No other providers are registered — managed fallback is off.
|
|
231
|
-
expect(registered).not.toContain("gemini");
|
|
232
|
-
expect(registered).not.toContain("openai");
|
|
233
|
-
expect(registered).not.toContain("fireworks");
|
|
234
|
-
expect(registered).not.toContain("openrouter");
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("managed anthropic and gemini participate in failover selection", async () => {
|
|
238
|
-
enableManagedProxy();
|
|
239
|
-
try {
|
|
240
|
-
mockProviderKeys = { anthropic: "test-key" };
|
|
241
|
-
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
242
|
-
const selection = resolveProviderSelection("anthropic", [
|
|
243
|
-
"openai",
|
|
244
|
-
"gemini",
|
|
245
|
-
"fireworks",
|
|
246
|
-
]);
|
|
247
|
-
expect(selection.availableProviders).toEqual(["anthropic", "gemini"]);
|
|
248
|
-
expect(selection.selectedPrimary).toBe("anthropic");
|
|
249
|
-
expect(selection.usedFallbackPrimary).toBe(false);
|
|
250
|
-
} finally {
|
|
251
|
-
disableManagedProxy();
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("managed gemini can be selected as fallback primary when configured primary is unavailable", async () => {
|
|
256
|
-
enableManagedProxy();
|
|
257
|
-
try {
|
|
258
|
-
// No anthropic key, no gemini key — only managed providers available
|
|
259
|
-
mockProviderKeys = {};
|
|
260
|
-
await initializeProviders(makeProvidersConfig("openai", "test-model"));
|
|
261
|
-
const selection = resolveProviderSelection("openai", [
|
|
262
|
-
"gemini",
|
|
263
|
-
"anthropic",
|
|
264
|
-
]);
|
|
265
|
-
expect(selection.selectedPrimary).toBe("gemini");
|
|
266
|
-
expect(selection.usedFallbackPrimary).toBe(true);
|
|
267
|
-
} finally {
|
|
268
|
-
disableManagedProxy();
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
mock.module("../util/logger.js", () => ({
|
|
4
|
-
getLogger: () =>
|
|
5
|
-
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
import { FailoverProvider } from "../providers/failover.js";
|
|
9
|
-
import type {
|
|
10
|
-
Message,
|
|
11
|
-
Provider,
|
|
12
|
-
ProviderResponse,
|
|
13
|
-
} from "../providers/types.js";
|
|
14
|
-
import { ProviderError } from "../util/errors.js";
|
|
15
|
-
|
|
16
|
-
const MESSAGES: Message[] = [
|
|
17
|
-
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
function successResponse(
|
|
21
|
-
overrides?: Partial<ProviderResponse>,
|
|
22
|
-
): ProviderResponse {
|
|
23
|
-
return {
|
|
24
|
-
content: [{ type: "text", text: "ok" }],
|
|
25
|
-
model: "test-model",
|
|
26
|
-
usage: { inputTokens: 10, outputTokens: 5 },
|
|
27
|
-
stopReason: "end_turn",
|
|
28
|
-
...overrides,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("FailoverProvider actual provider propagation", () => {
|
|
33
|
-
test("stamps the winning provider when failover uses a fallback", async () => {
|
|
34
|
-
const primary: Provider = {
|
|
35
|
-
name: "openrouter",
|
|
36
|
-
async sendMessage() {
|
|
37
|
-
throw new ProviderError("down", "openrouter", 500);
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
const secondary: Provider = {
|
|
41
|
-
name: "fireworks",
|
|
42
|
-
async sendMessage() {
|
|
43
|
-
return successResponse();
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const provider = new FailoverProvider([primary, secondary]);
|
|
48
|
-
const response = await provider.sendMessage(MESSAGES);
|
|
49
|
-
|
|
50
|
-
expect(response.actualProvider).toBe("fireworks");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("preserves an inner provider's actual provider when already set", async () => {
|
|
54
|
-
const inner: Provider = {
|
|
55
|
-
name: "retry-wrapper",
|
|
56
|
-
async sendMessage() {
|
|
57
|
-
return successResponse({ actualProvider: "anthropic" });
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const provider = new FailoverProvider([inner]);
|
|
62
|
-
const response = await provider.sendMessage(MESSAGES);
|
|
63
|
-
|
|
64
|
-
expect(response.actualProvider).toBe("anthropic");
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
|
|
2
|
-
|
|
3
|
-
import { getDb } from "../db.js";
|
|
4
|
-
import { memorySegments } from "../schema.js";
|
|
5
|
-
import { computeRecencyScore } from "./ranking.js";
|
|
6
|
-
import type { Candidate, CandidateType } from "./types.js";
|
|
7
|
-
|
|
8
|
-
export function recencySearch(
|
|
9
|
-
conversationId: string,
|
|
10
|
-
limit: number,
|
|
11
|
-
excludedMessageIds: string[] = [],
|
|
12
|
-
scopeIds?: string[],
|
|
13
|
-
): Candidate[] {
|
|
14
|
-
if (!conversationId || limit <= 0) return [];
|
|
15
|
-
const db = getDb();
|
|
16
|
-
const conditions = [eq(memorySegments.conversationId, conversationId)];
|
|
17
|
-
if (excludedMessageIds.length > 0) {
|
|
18
|
-
conditions.push(notInArray(memorySegments.messageId, excludedMessageIds));
|
|
19
|
-
}
|
|
20
|
-
if (scopeIds && scopeIds.length > 0) {
|
|
21
|
-
conditions.push(inArray(memorySegments.scopeId, scopeIds));
|
|
22
|
-
}
|
|
23
|
-
const whereClause =
|
|
24
|
-
conditions.length > 1 ? and(...conditions) : conditions[0];
|
|
25
|
-
const rows = db
|
|
26
|
-
.select()
|
|
27
|
-
.from(memorySegments)
|
|
28
|
-
.where(whereClause)
|
|
29
|
-
.orderBy(desc(memorySegments.createdAt))
|
|
30
|
-
.limit(limit)
|
|
31
|
-
.all();
|
|
32
|
-
return rows.map((row) => ({
|
|
33
|
-
key: `segment:${row.id}`,
|
|
34
|
-
type: "segment" as CandidateType,
|
|
35
|
-
id: row.id,
|
|
36
|
-
source: "recency",
|
|
37
|
-
text: row.text,
|
|
38
|
-
kind: "segment",
|
|
39
|
-
conversationId: row.conversationId,
|
|
40
|
-
messageId: row.messageId,
|
|
41
|
-
confidence: 0.55,
|
|
42
|
-
importance: 0.5,
|
|
43
|
-
createdAt: row.createdAt,
|
|
44
|
-
semantic: 0,
|
|
45
|
-
recency: computeRecencyScore(row.createdAt),
|
|
46
|
-
finalScore: 0,
|
|
47
|
-
}));
|
|
48
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { ProviderError } from "../util/errors.js";
|
|
2
|
-
import { getLogger } from "../util/logger.js";
|
|
3
|
-
import type {
|
|
4
|
-
Message,
|
|
5
|
-
Provider,
|
|
6
|
-
ProviderResponse,
|
|
7
|
-
SendMessageOptions,
|
|
8
|
-
ToolDefinition,
|
|
9
|
-
} from "./types.js";
|
|
10
|
-
|
|
11
|
-
const log = getLogger("failover");
|
|
12
|
-
|
|
13
|
-
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
14
|
-
|
|
15
|
-
interface ProviderHealth {
|
|
16
|
-
unhealthySince: number | null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Determine whether an error should trigger failover to the next provider.
|
|
21
|
-
* Connection errors, auth errors, and 5xx server errors trigger failover.
|
|
22
|
-
* 4xx client errors do NOT trigger failover (except 429 rate limit).
|
|
23
|
-
*/
|
|
24
|
-
function isFailoverError(error: unknown): boolean {
|
|
25
|
-
if (error instanceof ProviderError && error.statusCode !== undefined) {
|
|
26
|
-
// 429 rate limit — try next provider
|
|
27
|
-
if (error.statusCode === 429) return true;
|
|
28
|
-
// 5xx server errors — try next provider
|
|
29
|
-
if (error.statusCode >= 500) return true;
|
|
30
|
-
// Other 4xx — don't failover (bad request, auth with wrong format, etc.)
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Network errors — try next provider
|
|
35
|
-
if (error instanceof Error) {
|
|
36
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
37
|
-
if (
|
|
38
|
-
code === "ECONNRESET" ||
|
|
39
|
-
code === "ECONNREFUSED" ||
|
|
40
|
-
code === "ETIMEDOUT" ||
|
|
41
|
-
code === "EPIPE"
|
|
42
|
-
) {
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
if (error.cause instanceof Error) {
|
|
46
|
-
const causeCode = (error.cause as NodeJS.ErrnoException).code;
|
|
47
|
-
if (
|
|
48
|
-
causeCode === "ECONNRESET" ||
|
|
49
|
-
causeCode === "ECONNREFUSED" ||
|
|
50
|
-
causeCode === "ETIMEDOUT" ||
|
|
51
|
-
causeCode === "EPIPE"
|
|
52
|
-
) {
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ProviderError without a status code = connection/unknown failure
|
|
59
|
-
if (error instanceof ProviderError && error.statusCode === undefined) {
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface ProviderHealthStatus {
|
|
67
|
-
name: string;
|
|
68
|
-
healthy: boolean;
|
|
69
|
-
unhealthySince: string | null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class FailoverProvider implements Provider {
|
|
73
|
-
public readonly name: string;
|
|
74
|
-
private readonly healthMap = new Map<string, ProviderHealth>();
|
|
75
|
-
|
|
76
|
-
constructor(
|
|
77
|
-
private readonly providers: Provider[],
|
|
78
|
-
private readonly cooldownMs: number = DEFAULT_COOLDOWN_MS,
|
|
79
|
-
) {
|
|
80
|
-
if (providers.length === 0) {
|
|
81
|
-
throw new Error("FailoverProvider requires at least one provider");
|
|
82
|
-
}
|
|
83
|
-
this.name = providers[0].name;
|
|
84
|
-
for (const p of providers) {
|
|
85
|
-
this.healthMap.set(p.name, { unhealthySince: null });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async sendMessage(
|
|
90
|
-
messages: Message[],
|
|
91
|
-
tools?: ToolDefinition[],
|
|
92
|
-
systemPrompt?: string,
|
|
93
|
-
options?: SendMessageOptions,
|
|
94
|
-
): Promise<ProviderResponse> {
|
|
95
|
-
let lastError: unknown;
|
|
96
|
-
|
|
97
|
-
for (const provider of this.providers) {
|
|
98
|
-
const health = this.healthMap.get(provider.name)!;
|
|
99
|
-
const now = Date.now();
|
|
100
|
-
|
|
101
|
-
// Skip providers that are still in cooldown
|
|
102
|
-
if (health.unhealthySince != null) {
|
|
103
|
-
const elapsed = now - health.unhealthySince;
|
|
104
|
-
if (elapsed < this.cooldownMs) {
|
|
105
|
-
log.debug(
|
|
106
|
-
{
|
|
107
|
-
provider: provider.name,
|
|
108
|
-
cooldownRemainingMs: this.cooldownMs - elapsed,
|
|
109
|
-
},
|
|
110
|
-
"Skipping unhealthy provider (in cooldown)",
|
|
111
|
-
);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
// Cooldown expired — give it another chance
|
|
115
|
-
log.info(
|
|
116
|
-
{ provider: provider.name },
|
|
117
|
-
"Provider cooldown expired, retrying",
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const response = await provider.sendMessage(
|
|
123
|
-
messages,
|
|
124
|
-
tools,
|
|
125
|
-
systemPrompt,
|
|
126
|
-
options,
|
|
127
|
-
);
|
|
128
|
-
// Success — mark healthy
|
|
129
|
-
if (health.unhealthySince != null) {
|
|
130
|
-
log.info(
|
|
131
|
-
{ provider: provider.name },
|
|
132
|
-
"Provider recovered, marking healthy",
|
|
133
|
-
);
|
|
134
|
-
health.unhealthySince = null;
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
...response,
|
|
138
|
-
actualProvider: response.actualProvider ?? provider.name,
|
|
139
|
-
};
|
|
140
|
-
} catch (error) {
|
|
141
|
-
lastError = error;
|
|
142
|
-
|
|
143
|
-
if (isFailoverError(error)) {
|
|
144
|
-
health.unhealthySince = Date.now();
|
|
145
|
-
log.warn(
|
|
146
|
-
{
|
|
147
|
-
provider: provider.name,
|
|
148
|
-
error: error instanceof Error ? error.message : String(error),
|
|
149
|
-
statusCode:
|
|
150
|
-
error instanceof ProviderError ? error.statusCode : undefined,
|
|
151
|
-
},
|
|
152
|
-
"Provider failed, marked unhealthy",
|
|
153
|
-
);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Non-failover error (e.g. 400 bad request) — don't try other providers
|
|
158
|
-
throw error;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// All providers exhausted
|
|
163
|
-
throw (
|
|
164
|
-
lastError ??
|
|
165
|
-
new ProviderError(
|
|
166
|
-
"All configured providers are unavailable",
|
|
167
|
-
this.name,
|
|
168
|
-
undefined,
|
|
169
|
-
)
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
getHealthStatus(): ProviderHealthStatus[] {
|
|
174
|
-
return this.providers.map((p) => {
|
|
175
|
-
const health = this.healthMap.get(p.name)!;
|
|
176
|
-
return {
|
|
177
|
-
name: p.name,
|
|
178
|
-
healthy: health.unhealthySince == null,
|
|
179
|
-
unhealthySince:
|
|
180
|
-
health.unhealthySince != null
|
|
181
|
-
? new Date(health.unhealthySince).toISOString()
|
|
182
|
-
: null,
|
|
183
|
-
};
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|