Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions assistant/src/__tests__/config-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ mock.module("../providers/registry.js", () => ({
listProviders: () => [],
getProviderRoutingSource: () => undefined,
initializeProviders: () => {},
// Required by `providers/inference/connections.ts` and
// `providers/connection-resolution.ts`, both loaded transitively when
// ConfigWatcher's deps resolve. Without these, the import chain throws
// "Export named '...' not found in module 'registry.ts'".
clearConnectionProviderCache: () => {},
resolveProviderFromConnection: async () => null,
}));

mock.module("../daemon/mcp-reload-service.js", () => ({
Expand Down
10 changes: 10 additions & 0 deletions assistant/src/config/schemas/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ const PricingOverrideSchema = z.object({
*/
export const LLMConfigBase = z.object({
provider: LLMProvider.default("anthropic"),
/**
* Name of a `provider_connections` row to use for this resolved config.
* Optional and additive: when set, the dispatcher resolves auth from the
* connection (mix-and-match managed/your-own per profile). When unset,
* the dispatcher falls back to the legacy `provider` lookup.
*
* Lives on the merged base type so it flows through `resolveCallSiteConfig`
* naturally — the underlying profile-level field is on `ProfileEntry`.
*/
provider_connection: z.string().min(1).optional(),
model: ModelSchema.default("claude-opus-4-7"),
maxTokens: MaxTokensSchema.default(64000),
effort: EffortEnum.default("max"),
Expand Down
49 changes: 20 additions & 29 deletions assistant/src/daemon/approval-generators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { loadConfig } from "../config/loader.js";
import { CallSiteRoutingProvider } from "../providers/call-site-routing.js";
import { getProvider, listProviders } from "../providers/registry.js";
import { wrapWithCallSiteRouting } from "../providers/call-site-routing.js";
import { resolveDefaultProvider } from "../providers/connection-resolution.js";
import type { Provider } from "../providers/types.js";
import {
APPROVAL_COPY_MAX_TOKENS,
Expand Down Expand Up @@ -79,15 +79,16 @@ const VALID_DISPOSITIONS: ReadonlySet<string> = new Set([
export function createApprovalCopyGenerator(): ApprovalCopyGenerator {
return async (context, options = {}) => {
const config = loadConfig();
let baseProvider: Provider;
try {
baseProvider = getProvider(config.llm.default.provider);
} catch {
return null;
}
// Connection-aware default-provider resolution. If the default profile
// names a `provider_connection`, route through that connection's auth;
// otherwise fall through to the legacy registry lookup.
const baseProvider: Provider | null = await resolveDefaultProvider(config);
if (!baseProvider) return null;
// Wrap so per-call `callSite` can route to an alternative provider
// transport when `llm.callSites.<id>.provider` overrides the default.
const provider = wrapWithCallSiteRouting(baseProvider);
// The `wrapWithCallSiteRouting` helper threads `config` through so the
// wrapper's per-call resolution is also connection-aware.
const provider = wrapWithCallSiteRouting(baseProvider, config);

const fallbackText =
options.fallbackText?.trim() || getFallbackMessage(context);
Expand Down Expand Up @@ -136,12 +137,18 @@ export function createApprovalCopyGenerator(): ApprovalCopyGenerator {
export function createApprovalConversationGenerator(): ApprovalConversationGenerator {
return async (context) => {
const config = loadConfig();
if (!listProviders().includes(config.llm.default.provider)) {
// Connection-aware default + per-call routing. `resolveDefaultProvider`
// returns null when neither the `provider_connection` path nor the
// legacy registry can produce a Provider, which is the right "no
// provider available" signal here. (We do not pre-gate on
// `listProviders()` because in `your-own` configurations the default
// provider may live entirely behind a `provider_connection` and never
// appear in the legacy registry list.)
const baseProvider = await resolveDefaultProvider(config);
if (!baseProvider) {
throw new Error("No provider available for approval conversation");
}
const provider = wrapWithCallSiteRouting(
getProvider(config.llm.default.provider),
);
const provider = wrapWithCallSiteRouting(baseProvider, config);

const pendingDescription = context.pendingApprovals
.map((p) => `- Request ${p.requestId}: tool "${p.toolName}"`)
Expand Down Expand Up @@ -212,19 +219,3 @@ export function createApprovalConversationGenerator(): ApprovalConversationGener
return result;
};
}

/**
* Wrap a base Provider so per-call `callSite` metadata can route the actual
* transport to a different provider when `llm.callSites.<id>.provider`
* differs from the default. Without this wrapper, only request metadata
* reflects the callSite — the HTTP transport stays bound to the default.
*/
function wrapWithCallSiteRouting(base: Provider): Provider {
return new CallSiteRoutingProvider(base, (name) => {
try {
return getProvider(name);
} catch {
return undefined;
}
});
}
24 changes: 14 additions & 10 deletions assistant/src/daemon/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
import { getConfig } from "../config/loader.js";
import type { CesClient } from "../credential-execution/client.js";
import { buildSystemPrompt } from "../prompts/system-prompt.js";
import { CallSiteRoutingProvider } from "../providers/call-site-routing.js";
import { wrapWithCallSiteRouting } from "../providers/call-site-routing.js";
import { resolveDefaultProvider } from "../providers/connection-resolution.js";
import { RateLimitProvider } from "../providers/ratelimit.js";
import { getProvider } from "../providers/registry.js";
import { getSubagentManager } from "../subagent/index.js";
import { getSandboxWorkingDir } from "../util/platform.js";
import { Conversation } from "./conversation.js";
Expand Down Expand Up @@ -222,14 +222,18 @@ export async function getOrCreateConversation(

const createPromise = (async () => {
const config = getConfig();
let provider = getProvider(config.llm.default.provider);
provider = new CallSiteRoutingProvider(provider, (name) => {
try {
return getProvider(name);
} catch {
return undefined;
}
});
// Connection-aware default-provider resolution. When the default
// profile names a `provider_connection`, route through that
// connection's auth; otherwise fall through to the legacy registry.
const baseProvider = await resolveDefaultProvider(config);
if (!baseProvider) {
throw new Error(
`Conversation: default provider '${config.llm.default.provider}' is not registered`,
);
}
// Per-call `callSite` routing layered on top, with connection-awareness
// for alternate profiles (matches the canonical dispatch path).
let provider = wrapWithCallSiteRouting(baseProvider, config);
const { rateLimit } = config;
if (rateLimit.maxRequestsPerMinute > 0) {
provider = new RateLimitProvider(
Expand Down
29 changes: 7 additions & 22 deletions assistant/src/daemon/guardian-action-generators.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { CallSiteRoutingProvider } from "../providers/call-site-routing.js";
import { loadConfig } from "../config/loader.js";
import { wrapWithCallSiteRouting } from "../providers/call-site-routing.js";
import { getConfiguredProvider } from "../providers/provider-send-message.js";
import { getProvider } from "../providers/registry.js";
import type { Provider } from "../providers/types.js";
import {
buildGuardianActionGenerationPrompt,
getGuardianActionFallbackMessage,
Expand Down Expand Up @@ -32,8 +31,10 @@ export function createGuardianActionCopyGenerator(): GuardianActionCopyGenerator
if (!baseProvider) return null;
// Wrap so the per-call `callSite` can route to a different provider
// transport when `llm.callSites.guardianQuestionCopy.provider` overrides
// the default. Without this, callSite only affects request metadata.
const provider = wrapWithCallSiteRouting(baseProvider);
// the default. Connection-aware: when the resolved profile names a
// `provider_connection`, that connection's auth wins over the legacy
// registry lookup. See `wrapWithCallSiteRouting`.
const provider = wrapWithCallSiteRouting(baseProvider, loadConfig());

const fallbackText =
options.fallbackText?.trim() || getGuardianActionFallbackMessage(context);
Expand Down Expand Up @@ -135,7 +136,7 @@ export function createGuardianFollowUpConversationGenerator(): GuardianFollowUpC
if (!baseProvider) {
throw new Error("No configured provider available for follow-up conversation");
}
const provider = wrapWithCallSiteRouting(baseProvider);
const provider = wrapWithCallSiteRouting(baseProvider, loadConfig());

const userPrompt = [
`Original question from the voice call: "${context.questionText}"`,
Expand Down Expand Up @@ -192,19 +193,3 @@ export function createGuardianFollowUpConversationGenerator(): GuardianFollowUpC
return result;
};
}

/**
* Wrap a base Provider so per-call `callSite` metadata can route the actual
* transport to a different provider when `llm.callSites.<id>.provider`
* differs from the default. Without this wrapper, only request metadata
* reflects the callSite — the HTTP transport stays bound to the default.
*/
function wrapWithCallSiteRouting(base: Provider): Provider {
return new CallSiteRoutingProvider(base, (name) => {
try {
return getProvider(name);
} catch {
return undefined;
}
});
}
21 changes: 9 additions & 12 deletions assistant/src/home/rollup-producer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
*/

import { loadConfig } from "../config/loader.js";
import { getProvider, listProviders } from "../providers/registry.js";
import { resolveDefaultProvider } from "../providers/connection-resolution.js";
import type { Provider } from "../providers/types.js";
import { getLogger } from "../util/logger.js";
import {
Expand Down Expand Up @@ -172,7 +172,12 @@ export interface RollupProducerDeps {
Awaited<ReturnType<typeof computeRelationshipState>>
>;
loadRecentActions?: () => FeedItem[];
resolveProvider?: () => Provider | null;
/**
* Test injection point for the default provider. May be sync or async to
* support both legacy stubs and the connection-aware path that loads
* `provider_connection` rows from the DB.
*/
resolveProvider?: () => Provider | null | Promise<Provider | null>;
}

/**
Expand Down Expand Up @@ -216,8 +221,8 @@ async function runRollupProducerInner(
const loadRecentActions = deps.loadRecentActions ?? defaultLoadRecentActions;

const provider = deps.resolveProvider
? deps.resolveProvider()
: resolveDefaultProvider();
? await deps.resolveProvider()
: await resolveDefaultProvider(loadConfig());
if (!provider) {
return { wroteCount: 0, skippedReason: "no_provider" };
}
Expand Down Expand Up @@ -292,14 +297,6 @@ async function runRollupProducerInner(
return { wroteCount, skippedReason: null };
}

function resolveDefaultProvider(): ReturnType<typeof getProvider> | null {
const config = loadConfig();
if (!listProviders().includes(config.llm.default.provider)) {
return null;
}
return getProvider(config.llm.default.provider);
}

/**
* Default recent-actions loader. Reads the TTL-filtered home feed,
* keeps only `action` items, and returns them sorted by `createdAt`
Expand Down
Loading
Loading