FN-8661: resolve selected credential instances for sessions
Resolve requested provider credential instances before creating agent sessions. - Thread lane credential instance selections through planning, validation, execution, review, and merge sessions. - Resolve selected instances into runtime credential stores while retaining provider-default fallback behavior. - Preserve selected credentials for mission validation, executor retries, and spawned child agents. Files changed: .../fn-8661-credential-instance-resolution.md | 7 ++ AGENTS.md | 1 + docs/architecture.md | 2 +- docs/secrets.md | 2 + docs/settings-reference.md | 1 + .../dashboard/src/__tests__/routes-auth.test.ts | 80 +++++++++++++++++++ .../dashboard/src/routes/register-model-routes.ts | 78 +++++++++++++++++++ .../src/__tests__/agent-session-helpers.test.ts | 16 ++++ .../credential-instance-resolution.test.ts | 49 ++++++++++++ packages/engine/src/agent-heartbeat.ts | 1 + packages/engine/src/agent-runtime.ts | 9 ++- packages/engine/src/agent-session-helpers.ts | 67 +++++++++++----- packages/engine/src/auth-storage.ts | 90 ++++++++++++++++++---- packages/engine/src/executor.ts | 29 ++++++- packages/engine/src/merger-ai.ts | 2 + packages/engine/src/merger.ts | 5 ++ packages/engine/src/mission-execution-loop.ts | 4 +- packages/engine/src/pi.ts | 7 +- packages/engine/src/pr-response-run-ops.ts | 1 + packages/engine/src/reviewer.ts | 7 ++ packages/engine/src/triage.ts | 2 + 21 files changed, 420 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-8661 Fusion-Task-Lineage: 1e34a3ce-0857-4619-9746-ce0dc12dc2ba Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8661-credential-instance-resolution.md
Normal file
7
.changeset/fn-8661-credential-instance-resolution.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Honor selected credential instances when starting AI sessions.
|
||||
category: feature
|
||||
dev: Adds AgentRuntimeOptions.resolvedCredentialInstance and credentialInstanceId, optional ResolvedSessionOptions.authStorage, and /api/models providerInstances.
|
||||
@@ -300,6 +300,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
- FN-7158: agent performance reflections emit `reflection:generated`, `reflection:skipped`, and `reflection:failed` with ids/counts/outcomes-only metadata; never persist reflection prose or prompt text in run-audit.
|
||||
- FN-7528: a deterministic, non-LLM post-task performance capture (`AgentReflectionService.captureTaskPerformance`) runs once per completed task and emits `reflection:captured` with ids/counts/outcomes-only metadata (`retryReworkCount?`, `filesTouchedCount?`, `packagesTouchedCount?`, `verificationFileScoped?`, `durationMs?`); never persists `verificationScopeReason` free-text or summary prose in run-audit.
|
||||
- FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict.
|
||||
- FN-8661: `session:runtime-resolved` records `credentialInstanceId` for a resolved explicit credential, plus `credentialInstanceMissing`, requested, and resolved instance ids when a dangling selection falls back to the provider default. Metadata is ids/outcomes-only and never includes credential material.
|
||||
- FN-7835/FN-7844/FN-7859/FN-7878: durable-agent error-state recovery emits `agent:auto-recover-error-state` when either the heartbeat timer or the self-healing sweep clears a recoverable, non-operator-actionable `error` and retries; metadata stays ids/counts/outcomes-only (`agentId`, attempt, limit, source), where `source` is `timer`/`automation`/`self-healing`. Generic/unknown heartbeat failures are recoverable by default because manual Retry often proves they were transient; both entry paths share the `heartbeatErrorRecovery` budget (self-healing keeps `durableErrorRecovery` only for cooldown/stale-path bookkeeping) and emit `agent:error-retry-exhausted` when the shared budget is exhausted and the agent is parked `paused` with `pauseReason:"error-retry-exhausted"`. Only operator-actionable durable heartbeat errors (credentials/OAuth scope, model access, billing/quota, excluding transient auth rotation), plus stale worktree/module-resolution errors handled by their dedicated suppression path, skip the retry budget and emit `agent:error-parked-unrecoverable` with ids/counts/outcomes-only metadata (`agentId`, `source`, optional `attempts`, `limit`) before parking `paused` with `pauseReason:"error-unrecoverable"` for human repair.
|
||||
- FN-7884: self-healing startup recovery emits `agent:reset-error-state-on-startup` when an engine restart clears an eligible durable-agent `error` or `pauseReason:"error-retry-exhausted"` park, resets shared `heartbeatErrorRecovery` plus legacy `durableErrorRecovery` budget/cooldown metadata, clears `lastError`/exhaustion pause state, and re-arms the heartbeat. Metadata stays ids/counts/outcomes-only (`agentId`, `priorState`, optional `priorPauseReason`, `source`). This startup-only path bypasses steady-state staleness/cooldown/exhaustion gates while preserving operator-actionable, stale-module, user-paused, `error-unrecoverable`, ephemeral, disabled-runtime, and active-execution suppression.
|
||||
- FN-7802: self-healing emits `task:reconcile-missing-worktree-merge-active` when it proves an `in-review` merge-active task (`merging`/`merging-pr`/`merging-fix`) is stranded by an unusable-worktree session-start failure, clears stale `worktree`/`branch`/`sessionFile`, resets the worktree-session retry budget, increments `recoveryRetryCount` as the bounded stale-metadata clear counter, and requeues to `todo`; it emits `task:reconcile-missing-worktree-merge-active-no-action` when `autoMerge:false`, workspace-task ownership, or triple-proof blocks the backward move.
|
||||
|
||||
@@ -1129,7 +1129,7 @@ The run-audit system records every mutation performed by the engine across four
|
||||
- **Database / `task:reconcile-stale-agent-assignment`** — emitted when self-healing or heartbeat reconciliation clears stale durable `Agent.taskId`/`state` for a task parked in `todo`/`triage` without live execution proof. Metadata includes `{ agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }`; task queue/lease fields are preserved.
|
||||
- **Database / `task:reconcile-stale-duplicate-decision`** — emitted when self-healing clears a triage-marker duplicate-decision pause whose canonical is missing, deleted, done, or archived. Metadata is ids/outcomes-only: `{ taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason }`; active canonicals and user pauses are excluded.
|
||||
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
|
||||
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution.
|
||||
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint?, credentialInstanceId?, credentialInstanceMissing?, requestedCredentialInstanceId?, resolvedCredentialInstanceId? }` for per-lane runtime/provider attribution. Credential fields are ids/outcomes-only; a missing selected instance is visibly recorded when the canonical provider default is used.
|
||||
- **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list.
|
||||
- **Database / `task:reconcile-in-review-unmet-dependencies`** — emitted by `reconcileInReviewUnmetDependencies()` (FN-6793/FN-6797) when self-healing rebounds an `in-review` task to blocked `todo` because one or more declared dependencies are still unmet. Metadata includes `unmetDeps`, `blockedBy`, and prior review status; the `-no-action` companion is emitted when task pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation prevents the backward move.
|
||||
- **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no PostgreSQL task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path.
|
||||
|
||||
@@ -212,6 +212,8 @@ Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare
|
||||
|
||||
Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys.
|
||||
|
||||
At session creation, an explicitly selected credential instance wins over the provider default only for that provider. A missing named instance falls back to the canonical provider default and is recorded in `session:runtime-resolved`; Fusion never substitutes an arbitrary instance. If there is no default, session resolution fails without exposing credential material. Omitted instance ids retain the legacy default-resolving behavior.
|
||||
|
||||
Dashboard auth routes accept an optional instance id; omitted or blank ids retain default-instance behavior. `GET /api/auth/status?provider=<id>&instance=<id>` keeps the full provider envelope but describes the requested instance at that provider entry. A valid missing id is an unauthenticated `200` result, never a fallback to another account. Credential-establishing login and API-key writes may create a supplied id; rename, default, logout, and delete require an existing id. OAuth binds the id and optional opaque label to its server-side flow state, so a callback cannot fall back to the default account. Instance listings expose only ids, labels, auth status, and masked key hints; raw key and token material never leaves storage. `removeInstance` deletes the credential row and its default participation, while credential clear only removes its usable credential state.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
@@ -73,6 +73,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
|
||||
| `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. |
|
||||
| `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. |
|
||||
| `credentialInstanceId` | `string` | `undefined` | Optional named credential instance for a resolved model selection. A named instance takes precedence over the provider default for that session and is scoped to that provider only; fallback providers retain their own default credentials. A deleted/malformed named instance uses the provider default and records `credentialInstanceMissing` with requested/resolved ids on `session:runtime-resolved`; no provider default is a visible resolution error. |
|
||||
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth by default (see [`anthropicAuthPreference`](#anthropicauthpreference) to invert this). (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. |
|
||||
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
|
||||
| `modelPricingOverrides` | `Record<string, ModelPricing>` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. |
|
||||
|
||||
@@ -394,6 +394,86 @@ describe("GET /models", () => {
|
||||
expect(res.body.models).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns instance availability for all configured providers without duplicating catalog models", async () => {
|
||||
const modelRegistry = createMockModelRegistry();
|
||||
const authStorage = createMockAuthStorage({
|
||||
getOAuthProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]),
|
||||
hasAuth: vi.fn((provider: string) => provider === "openai"),
|
||||
listInstances: vi.fn((provider: string) => provider === "openai"
|
||||
? [{ providerId: "openai", instanceId: "work" }, { providerId: "openai", instanceId: "personal" }]
|
||||
: []),
|
||||
getDefaultInstance: vi.fn((provider: string) => provider === "openai"
|
||||
? { providerId: "openai", instanceId: "work" }
|
||||
: undefined),
|
||||
getInstance: vi.fn(() => ({ type: "api_key" })),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(modelRegistry, authStorage), "/api/models");
|
||||
|
||||
expect(res.body.providerInstances).toEqual({
|
||||
openai: { instances: [{ id: "work", isDefault: true }, { id: "personal", isDefault: false }] },
|
||||
});
|
||||
expect(res.body.models.filter((model: { provider: string }) => model.provider === "openai")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("derives per-instance unavailable models from the existing credential-kind provider gate", async () => {
|
||||
const modelRegistry = createMockModelRegistry();
|
||||
const authStorage = createMockAuthStorage({
|
||||
getOAuthProviders: vi.fn().mockReturnValue([]),
|
||||
getApiKeyProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]),
|
||||
hasApiKey: vi.fn((provider: string) => provider === "openai"),
|
||||
listInstances: vi.fn((provider: string) => provider === "openai"
|
||||
? [{ providerId: "openai", instanceId: "work" }, { providerId: "openai", instanceId: "oauth" }]
|
||||
: []),
|
||||
getDefaultInstance: vi.fn(() => ({ providerId: "openai", instanceId: "work" })),
|
||||
getInstance: vi.fn((ref: { instanceId: string }) => ({ type: ref.instanceId === "work" ? "api_key" : "oauth" })),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(modelRegistry, authStorage), "/api/models");
|
||||
|
||||
expect(res.body.providerInstances.openai.instances).toEqual([
|
||||
{ id: "work", isDefault: true },
|
||||
{ id: "oauth", isDefault: false, unavailableModelIds: ["gpt-4o"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits instance availability when auth storage is unavailable or lacks the optional capability", async () => {
|
||||
const withoutStorage = await GET(buildApp(createMockModelRegistry()), "/api/models");
|
||||
expect(withoutStorage.body).not.toHaveProperty("providerInstances");
|
||||
|
||||
const withoutCapability = await GET(buildApp(createMockModelRegistry(), createMockAuthStorage()), "/api/models");
|
||||
expect(withoutCapability.body).not.toHaveProperty("providerInstances");
|
||||
});
|
||||
|
||||
it("skips a provider whose instance enumeration throws without failing the catalog", async () => {
|
||||
const authStorage = createMockAuthStorage({
|
||||
getOAuthProviders: vi.fn(() => [{ id: "openai", name: "OpenAI" }]),
|
||||
hasAuth: vi.fn(() => true),
|
||||
listInstances: vi.fn(() => { throw new Error("corrupt instance metadata"); }),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(createMockModelRegistry(), authStorage), "/api/models");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.models).toEqual(expect.any(Array));
|
||||
expect(res.body).not.toHaveProperty("providerInstances");
|
||||
});
|
||||
|
||||
it("omits unavailable model ids when instance credentials have no existing distinguishable signal", async () => {
|
||||
const modelRegistry = createMockModelRegistry();
|
||||
const authStorage = createMockAuthStorage({
|
||||
listInstances: vi.fn(() => [{ providerId: "custom", instanceId: "one" }]),
|
||||
getDefaultInstance: vi.fn(() => ({ providerId: "custom", instanceId: "one" })),
|
||||
getInstance: vi.fn(() => ({ type: "api_key" })),
|
||||
getOAuthProviders: vi.fn(() => [{ id: "custom", name: "Custom" }]),
|
||||
hasAuth: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(modelRegistry, authStorage), "/api/models");
|
||||
|
||||
expect(res.body.providerInstances.custom.instances).toEqual([{ id: "one", isDefault: true }]);
|
||||
});
|
||||
|
||||
it("returns empty array when registry has no available models", async () => {
|
||||
const modelRegistry = createMockModelRegistry({
|
||||
getAvailable: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -152,6 +152,82 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis
|
||||
return providers;
|
||||
}
|
||||
|
||||
type ProviderCredential = { type?: unknown } | null | undefined;
|
||||
|
||||
/**
|
||||
* Return the models which today's configured-provider gate would advertise for a
|
||||
* concrete credential kind. `undefined` means that the existing gate has no
|
||||
* per-instance distinction for this provider, so callers must not invent one.
|
||||
*/
|
||||
function getAdvertisedModelIdsForCredential(
|
||||
providerId: string,
|
||||
credential: ProviderCredential,
|
||||
models: Array<{ provider: string; id: string }>,
|
||||
apiKeyProviderIds: Set<string>,
|
||||
oauthProviderIds: Set<string>,
|
||||
): Set<string> | undefined {
|
||||
const modelProviderId = toModelProviderId(providerId);
|
||||
const providerModels = new Set(models.filter(model => model.provider === modelProviderId).map(model => model.id));
|
||||
if (providerModels.size === 0) return new Set();
|
||||
|
||||
// Direct Anthropic intentionally accepts both of its existing auth kinds.
|
||||
if (modelProviderId === ANTHROPIC_PROVIDER_ID) {
|
||||
return credential?.type === "api_key" || credential?.type === "oauth" ? providerModels : new Set();
|
||||
}
|
||||
if (apiKeyProviderIds.has(providerId)) {
|
||||
return credential?.type === "api_key" ? providerModels : new Set();
|
||||
}
|
||||
if (oauthProviderIds.has(providerId)) {
|
||||
return credential?.type === "oauth" ? providerModels : new Set();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:39:
|
||||
Expose instance availability beside the catalog rather than copying every model per credential. Reuse the existing API-key/OAuth configured-provider gate to derive only real default-versus-instance deltas; an arbitrary stored field or a network probe would fabricate availability data.
|
||||
*/
|
||||
function getProviderInstances(
|
||||
authStorage: AuthStorageLike | undefined,
|
||||
advertisedProviders: Iterable<string>,
|
||||
models: Array<{ provider: string; id: string }>,
|
||||
): Record<string, { instances: Array<{ id: string; isDefault: boolean; unavailableModelIds?: string[] }> }> | undefined {
|
||||
if (!authStorage?.listInstances) return undefined;
|
||||
const result: Record<string, { instances: Array<{ id: string; isDefault: boolean; unavailableModelIds?: string[] }> }> = {};
|
||||
const apiKeyProviderIds = new Set((authStorage.getApiKeyProviders?.() ?? []).map(provider => provider.id));
|
||||
const oauthProviderIds = new Set((authStorage.getOAuthProviders?.() ?? []).map(provider => provider.id));
|
||||
const providerIds = new Set([...advertisedProviders, ...models.map(model => model.provider)]);
|
||||
for (const modelProviderId of providerIds) {
|
||||
const providerId = modelProviderId === ANTHROPIC_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : modelProviderId;
|
||||
try {
|
||||
const defaultRef = authStorage.getDefaultInstance?.(providerId);
|
||||
const refs = authStorage.listInstances(providerId);
|
||||
if (refs.length === 0) continue;
|
||||
const defaultCredential = defaultRef && authStorage.getInstance?.(defaultRef);
|
||||
const defaultModelIds = getAdvertisedModelIdsForCredential(
|
||||
providerId, defaultCredential, models, apiKeyProviderIds, oauthProviderIds,
|
||||
);
|
||||
const instances = refs.map(ref => {
|
||||
const instanceModelIds = getAdvertisedModelIdsForCredential(
|
||||
providerId, authStorage.getInstance?.(ref), models, apiKeyProviderIds, oauthProviderIds,
|
||||
);
|
||||
const unavailableModelIds = defaultModelIds && instanceModelIds
|
||||
? [...defaultModelIds].filter(modelId => !instanceModelIds.has(modelId))
|
||||
: [];
|
||||
return {
|
||||
id: ref.instanceId,
|
||||
isDefault: defaultRef?.instanceId === ref.instanceId,
|
||||
...(unavailableModelIds.length > 0 ? { unavailableModelIds } : {}),
|
||||
};
|
||||
});
|
||||
result[modelProviderId] = { instances };
|
||||
} catch {
|
||||
// A corrupt provider entry must not make the shared model catalog unavailable.
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, runtimeLogger } = ctx;
|
||||
|
||||
@@ -490,6 +566,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
configuredProviders.add(customProviderRegistryKey(provider, customProviders));
|
||||
}
|
||||
models = models.filter((m) => configuredProviders.has(m.provider));
|
||||
const providerInstances = getProviderInstances(options?.authStorage, configuredProviders, models);
|
||||
|
||||
res.json({
|
||||
models,
|
||||
@@ -497,6 +574,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
favoriteModels,
|
||||
...defaultModelResponse,
|
||||
...resolvedPlanningModelResponse,
|
||||
...(providerInstances ? { providerInstances } : {}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -244,6 +244,22 @@ describe("resolve session model parity", () => {
|
||||
defaultModelId: "glm-5.1",
|
||||
};
|
||||
|
||||
it("carries the winning selection credential instance alongside its provider and model", () => {
|
||||
expect(resolveExecutorSessionModel("task-provider", "task-model", settings, undefined, "personal")).toEqual({
|
||||
provider: "task-provider",
|
||||
modelId: "task-model",
|
||||
credentialInstanceId: "personal",
|
||||
});
|
||||
expect(resolvePlanningSessionModel(undefined, undefined, {
|
||||
...settings,
|
||||
planningCredentialInstanceId: "work",
|
||||
})).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
credentialInstanceId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the same fresh settings model for executor and heartbeat when runtimeConfig is absent", () => {
|
||||
const executor = resolveExecutorSessionModel(undefined, undefined, settings);
|
||||
const heartbeat = resolveHeartbeatSessionModels(settings);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FusionAuthStorage } from "../auth-storage.js";
|
||||
import { CredentialInstanceResolutionError, createFusionCredentialStore, resolveCredentialInstanceRef } from "../auth-storage.js";
|
||||
|
||||
function storage(): FusionAuthStorage {
|
||||
const values = new Map([
|
||||
["openai[work]", { type: "api_key", key: "work-key" }],
|
||||
["openai[personal]", { type: "api_key", key: "personal-key" }],
|
||||
["fallback", { type: "api_key", key: "fallback-key" }],
|
||||
]);
|
||||
const ref = (providerId: string, instanceId: string) => ({ providerId, instanceId });
|
||||
return {
|
||||
reload() {}, get: provider => values.get(provider), getAll: () => ({}), list: () => ["openai", "fallback"], has: () => true, hasAuth: () => true,
|
||||
listInstances: provider => provider === "openai" ? [ref("openai", "work"), ref("openai", "personal")] : [],
|
||||
getInstance: item => values.get(`${item.providerId}[${item.instanceId}]`), setInstance: async (item, credential) => { values.set(`${item.providerId}[${item.instanceId}]`, credential); }, removeInstance: async () => {},
|
||||
getDefaultInstance: provider => provider === "openai" ? ref("openai", "work") : undefined, setDefaultInstance: async () => {},
|
||||
set: async () => {}, remove: async () => {}, logout: async () => {}, getApiKey: async () => undefined, getOAuthProviders: () => [], login: async () => {}, modify: async () => undefined, setModelRuntime: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("credential instance resolution", () => {
|
||||
it("uses the explicit instance while fallback providers keep their default behavior", async () => {
|
||||
const auth = storage();
|
||||
const resolution = resolveCredentialInstanceRef(auth, "openai", "personal");
|
||||
expect(resolution).toMatchObject({ ref: { providerId: "openai", instanceId: "personal" }, missing: false });
|
||||
const credentials = createFusionCredentialStore(auth, resolution.ref);
|
||||
expect(await credentials.read("openai")).toMatchObject({ key: "personal-key" });
|
||||
expect(await credentials.read("fallback")).toMatchObject({ key: "fallback-key" });
|
||||
});
|
||||
|
||||
it("keeps Anthropic on the instance-aware getApiKey indirection", async () => {
|
||||
const auth = storage();
|
||||
const getApiKey = async (provider: string, instance?: { providerId: string; instanceId: string }) => {
|
||||
expect(provider).toBe("anthropic");
|
||||
expect(instance).toEqual({ providerId: "anthropic", instanceId: "personal" });
|
||||
return "instance-token";
|
||||
};
|
||||
const credentials = createFusionCredentialStore({ ...auth, getApiKey }, { providerId: "anthropic", instanceId: "personal" });
|
||||
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "instance-token" });
|
||||
});
|
||||
|
||||
it("audits a missing or malformed name by resolving only the provider default", () => {
|
||||
const auth = storage();
|
||||
expect(resolveCredentialInstanceRef(auth, "openai", "deleted")).toMatchObject({ ref: { instanceId: "work" }, missing: true });
|
||||
expect(resolveCredentialInstanceRef(auth, "openai", "bad name")).toMatchObject({ ref: { instanceId: "work" }, missing: true });
|
||||
const noDefault = { ...auth, getDefaultInstance: () => undefined };
|
||||
expect(() => resolveCredentialInstanceRef(noDefault, "openai", "deleted")).toThrow(CredentialInstanceResolutionError);
|
||||
});
|
||||
});
|
||||
@@ -3032,6 +3032,7 @@ export class HeartbeatMonitor {
|
||||
customTools: heartbeatTools,
|
||||
defaultProvider: heartbeatSessionModels.defaultProvider,
|
||||
defaultModelId: heartbeatSessionModels.defaultModelId,
|
||||
...(heartbeatSessionModels.credentialInstanceId ? { credentialInstanceId: heartbeatSessionModels.credentialInstanceId } : {}),
|
||||
fallbackProvider: heartbeatSessionModels.fallbackProvider,
|
||||
fallbackModelId: heartbeatSessionModels.fallbackModelId,
|
||||
fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentSession, SessionManager, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import type { PermanentAgentGatingContext, ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
import type { PermanentAgentGatingContext, ProviderInstanceRef, ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import type { FallbackModelUsedPayload } from "./pi.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
@@ -103,6 +103,13 @@ export interface AgentRuntimeOptions {
|
||||
defaultProvider?: string;
|
||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5") */
|
||||
defaultModelId?: string;
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
Session creation carries the already-resolved credential ref rather than a raw id so audit and the credential store cannot drift if a provider default changes mid-startup.
|
||||
*/
|
||||
resolvedCredentialInstance?: ProviderInstanceRef;
|
||||
/** Informational operator-requested instance id; the concrete ref above is authoritative. */
|
||||
credentialInstanceId?: string;
|
||||
/** Optional fallback model provider for retryable errors */
|
||||
fallbackProvider?: string;
|
||||
/** Optional fallback model ID */
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type FallbackModelUsedPayload,
|
||||
} from "./pi.js";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "./auth-storage.js";
|
||||
import { MockAgentRuntime } from "./providers/mock-provider.js";
|
||||
|
||||
/** Logger for agent session helpers */
|
||||
@@ -140,6 +141,8 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions {
|
||||
* `session:runtime-resolved` metadata.
|
||||
*/
|
||||
settings?: Settings;
|
||||
/** Optional injected storage; only consulted when an instance was explicitly requested. */
|
||||
authStorage?: FusionAuthStorage;
|
||||
/**
|
||||
* `beforeSpawnSession` and `taskEnv` are inherited from
|
||||
* {@link AgentRuntimeOptions}. Both are forwarded verbatim to
|
||||
@@ -644,25 +647,19 @@ function armDeferredGrokCliFallback(args: {
|
||||
function pickSettingsThenRuntimeModel(
|
||||
settingsModel: ResolvedModelSelection,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
): ResolvedModelSelection {
|
||||
// Project/task/global settings are the authoritative model hierarchy. The
|
||||
// assigned durable agent runtime model is only a final compatibility fallback
|
||||
// when the hierarchy produced no complete pair; partial runtime pairs must
|
||||
// never be mixed with settings fields or mask saved project overrides.
|
||||
if (settingsModel.provider && settingsModel.modelId) {
|
||||
return {
|
||||
provider: settingsModel.provider,
|
||||
modelId: settingsModel.modelId,
|
||||
};
|
||||
return settingsModel;
|
||||
}
|
||||
|
||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
||||
return hasCompleteRuntimeModel(assignedRuntimeModel)
|
||||
? assignedRuntimeModel
|
||||
: {
|
||||
provider: settingsModel.provider,
|
||||
modelId: settingsModel.modelId,
|
||||
};
|
||||
: settingsModel;
|
||||
}
|
||||
|
||||
export function resolveExecutorSessionModel(
|
||||
@@ -670,7 +667,8 @@ export function resolveExecutorSessionModel(
|
||||
taskModelId: string | undefined,
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
taskCredentialInstanceId?: string | null,
|
||||
): ResolvedModelSelection {
|
||||
if (isTestModeActive(settings)) {
|
||||
return {
|
||||
provider: TEST_MODE_RESOLVED.provider,
|
||||
@@ -682,6 +680,7 @@ export function resolveExecutorSessionModel(
|
||||
{
|
||||
modelProvider: taskModelProvider,
|
||||
modelId: taskModelId,
|
||||
credentialInstanceId: taskCredentialInstanceId,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
@@ -694,7 +693,8 @@ export function resolvePlanningSessionModel(
|
||||
taskPlanningModelId: string | undefined,
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
taskCredentialInstanceId?: string | null,
|
||||
): ResolvedModelSelection {
|
||||
if (isTestModeActive(settings)) {
|
||||
return {
|
||||
provider: TEST_MODE_RESOLVED.provider,
|
||||
@@ -706,6 +706,7 @@ export function resolvePlanningSessionModel(
|
||||
{
|
||||
planningModelProvider: taskPlanningModelProvider,
|
||||
planningModelId: taskPlanningModelId,
|
||||
planningCredentialInstanceId: taskCredentialInstanceId,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
@@ -730,7 +731,7 @@ export function resolveImplicitPlanningFallbackModel(
|
||||
primaryProvider: string | undefined,
|
||||
primaryModelId: string | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
): ResolvedModelSelection {
|
||||
if (isTestModeActive(settings)) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
@@ -772,7 +773,8 @@ export function resolveValidatorSessionModel(
|
||||
taskValidatorModelId: string | undefined,
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
taskCredentialInstanceId?: string | null,
|
||||
): ResolvedModelSelection {
|
||||
if (isTestModeActive(settings)) {
|
||||
return {
|
||||
provider: TEST_MODE_RESOLVED.provider,
|
||||
@@ -784,6 +786,7 @@ export function resolveValidatorSessionModel(
|
||||
{
|
||||
validatorModelProvider: taskValidatorModelProvider,
|
||||
validatorModelId: taskValidatorModelId,
|
||||
validatorCredentialInstanceId: taskCredentialInstanceId,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
@@ -797,6 +800,7 @@ export function resolveHeartbeatSessionModels(
|
||||
): {
|
||||
defaultProvider: string | undefined;
|
||||
defaultModelId: string | undefined;
|
||||
credentialInstanceId?: string;
|
||||
fallbackProvider: string | undefined;
|
||||
fallbackModelId: string | undefined;
|
||||
} {
|
||||
@@ -816,13 +820,14 @@ export function resolveHeartbeatSessionModels(
|
||||
FNXC:AgentHeartbeat 2026-07-14-16:13:
|
||||
Durable-agent heartbeats must use the complete model assigned to that agent. Shared project execution defaults are only a fallback for an absent or incomplete assignment; otherwise one broken project override can park every heterogeneous agent under the same unrelated provider.
|
||||
*/
|
||||
const resolvedModel = hasCompleteRuntimeModel(assignedRuntimeModel)
|
||||
const resolvedModel: ResolvedModelSelection = hasCompleteRuntimeModel(assignedRuntimeModel)
|
||||
? assignedRuntimeModel
|
||||
: pickSettingsThenRuntimeModel(executionSettingsModel, assignedAgentRuntimeConfig);
|
||||
|
||||
return {
|
||||
defaultProvider: resolvedModel.provider,
|
||||
defaultModelId: resolvedModel.modelId,
|
||||
...(resolvedModel.credentialInstanceId ? { credentialInstanceId: resolvedModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: executorFallbackModel.provider,
|
||||
fallbackModelId: executorFallbackModel.modelId,
|
||||
};
|
||||
@@ -831,8 +836,8 @@ export function resolveHeartbeatSessionModels(
|
||||
export function resolveMergerSessionModel(
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
task?: { mergerModelProvider?: string | null; mergerModelId?: string | null },
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
task?: { mergerModelProvider?: string | null; mergerModelId?: string | null; mergerCredentialInstanceId?: string | null },
|
||||
): ResolvedModelSelection {
|
||||
if (isTestModeActive(settings)) {
|
||||
return {
|
||||
provider: TEST_MODE_RESOLVED.provider,
|
||||
@@ -848,7 +853,7 @@ export function resolveMergerSessionModel(
|
||||
*/
|
||||
/* FNXC:Settings-MergerModel 2026-07-16-12:00: task pair → settings → global/default; partial task pairs inherit settings. */
|
||||
const mergerModel = task?.mergerModelProvider && task?.mergerModelId
|
||||
? { provider: task.mergerModelProvider, modelId: task.mergerModelId }
|
||||
? { provider: task.mergerModelProvider, modelId: task.mergerModelId, credentialInstanceId: task.mergerCredentialInstanceId ?? undefined }
|
||||
: resolveMergerSettingsModel(settings);
|
||||
return pickSettingsThenRuntimeModel(mergerModel, assignedAgentRuntimeConfig);
|
||||
}
|
||||
@@ -867,7 +872,23 @@ export function resolveMergerSessionModel(
|
||||
export async function createResolvedAgentSession(
|
||||
options: ResolvedSessionOptions,
|
||||
): Promise<ResolvedSessionResult> {
|
||||
const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, ...runtimeOptionsRaw } = options;
|
||||
const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options;
|
||||
let credentialResolution: ReturnType<typeof resolveCredentialInstanceRef> | undefined;
|
||||
if (requestedCredentialInstanceId) {
|
||||
try {
|
||||
const storage = injectedAuthStorage ?? createFusionAuthStorage();
|
||||
credentialResolution = resolveCredentialInstanceRef(storage, runtimeOptionsRaw.defaultProvider ?? "", requestedCredentialInstanceId);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "CredentialInstanceResolutionError") throw error;
|
||||
sessionLog.warn(`[${sessionPurpose}] credential instance resolution unavailable; using provider default`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
A dangling named instance must be auditable: silent substitution spends the wrong account quota under an identity the operator did not select.
|
||||
*/
|
||||
if (credentialResolution?.missing) sessionLog.warn(`[${sessionPurpose}] requested credential instance is missing; using provider default`);
|
||||
|
||||
const skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection);
|
||||
const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0
|
||||
@@ -891,6 +912,10 @@ export async function createResolvedAgentSession(
|
||||
? runtimeOptionsRaw.toolOutputMaxChars
|
||||
: resolveAgentToolOutputMaxChars(settings ?? {}),
|
||||
...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}),
|
||||
...(credentialResolution ? {
|
||||
resolvedCredentialInstance: credentialResolution.ref,
|
||||
credentialInstanceId: credentialResolution.requestedInstanceId,
|
||||
} : {}),
|
||||
};
|
||||
// FNXC:McpConfig 2026-06-25-22:06:
|
||||
// createResolvedAgentSession is the common lane helper for executor, reviewer, validator, workflow model-node, summarization, and merger-adjacent paths that pass MCP through this seam. Preserve `mcpServers` verbatim here; runtime-resolution/pi own support-gated forwarding and content-free skip logging.
|
||||
@@ -1044,6 +1069,12 @@ export async function createResolvedAgentSession(
|
||||
...(grokFallbackDeferral.dropped ? { grokCliFallbackDropped: true } : {}),
|
||||
...(deferredGrokFallback ? { grokCliFallbackDeferred: true } : {}),
|
||||
...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}),
|
||||
...(credentialResolution?.ref.instanceId ? { credentialInstanceId: credentialResolution.ref.instanceId } : {}),
|
||||
...(credentialResolution?.missing ? {
|
||||
credentialInstanceMissing: true,
|
||||
requestedCredentialInstanceId: credentialResolution.requestedInstanceId,
|
||||
resolvedCredentialInstanceId: credentialResolution.ref.instanceId,
|
||||
} : {}),
|
||||
/*
|
||||
FNXC:FusionToolBridgeDiagnostics 2026-07-20-08:00:
|
||||
Plugin bridge failures are session-visible, but they also need one durable
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isReservedAuthStorageKey,
|
||||
isStoredAuthCredential,
|
||||
isValidProviderId,
|
||||
isValidProviderInstanceId,
|
||||
parseProviderInstanceKey,
|
||||
} from "@fusion/core";
|
||||
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
@@ -40,7 +41,7 @@ export interface FusionAuthStorage {
|
||||
set(provider: string, credential: StoredCredential): Promise<void>;
|
||||
remove(provider: string): Promise<void>;
|
||||
logout(provider: string): Promise<void>;
|
||||
getApiKey(provider: string): Promise<string | undefined>;
|
||||
getApiKey(provider: string, instance?: ProviderInstanceRef): Promise<string | undefined>;
|
||||
getOAuthProviders(): Array<{ id: string; name: string }>;
|
||||
login(provider: string, callbacks: unknown): Promise<void>;
|
||||
modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined>;
|
||||
@@ -213,7 +214,9 @@ class FusionFileAuthStorage implements FusionAuthStorage {
|
||||
async removeInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.removeRef(ref); }
|
||||
async logout(provider: string): Promise<void> { await this.remove(provider); }
|
||||
async setDefaultInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.withLock(async current => { if (!this.credential(ref, current)) throw new Error("Cannot set default for a missing credential instance"); const defaults = { ...readDefaultInstanceMap(current), [ref.providerId]: ref.instanceId }; current.__fusionDefaultInstances = defaults; return { result: undefined, changed: true }; }); }
|
||||
async getApiKey(provider: string): Promise<string | undefined> { return resolveStoredCredentialApiKey(provider, this.get(provider)); }
|
||||
async getApiKey(provider: string, instance?: ProviderInstanceRef): Promise<string | undefined> {
|
||||
return resolveStoredCredentialApiKey(provider, instance ? this.getInstance(instance) : this.get(provider));
|
||||
}
|
||||
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
|
||||
this.assertRefFromKey(provider);
|
||||
return this.withLock(async current => {
|
||||
@@ -237,27 +240,79 @@ class FusionFileAuthStorage implements FusionAuthStorage {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore {
|
||||
export class CredentialInstanceResolutionError extends Error {
|
||||
constructor(providerId: string, instanceId: string) {
|
||||
super(`Credential instance "${instanceId}" for provider "${providerId}" was not found and the provider has no default instance`);
|
||||
this.name = "CredentialInstanceResolutionError";
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialInstanceResolution = {
|
||||
ref: ProviderInstanceRef;
|
||||
requestedInstanceId: string;
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
A selected instance is resolved once before runtime dispatch and its concrete ref is passed forward. Re-resolving downstream could select a different default after audit, silently running work on an account the operator did not choose.
|
||||
*/
|
||||
export function resolveCredentialInstanceRef(
|
||||
authStorage: FusionAuthStorage,
|
||||
providerId: string,
|
||||
requestedInstanceId: string,
|
||||
): CredentialInstanceResolution {
|
||||
const requested = { providerId, instanceId: requestedInstanceId };
|
||||
if (isValidProviderInstanceId(requestedInstanceId) && authStorage.getInstance(requested)) {
|
||||
return { ref: requested, requestedInstanceId, missing: false };
|
||||
}
|
||||
const ref = authStorage.getDefaultInstance(providerId);
|
||||
if (!ref) throw new CredentialInstanceResolutionError(providerId, requestedInstanceId);
|
||||
return { ref, requestedInstanceId, missing: true };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
The resolved ref applies only to its provider. Applying provider A's selection to a fallback provider B would silently spend B credentials under the wrong operator intent; missing named instances instead retain the audited provider default.
|
||||
*/
|
||||
export function createFusionCredentialStore(authStorage: FusionAuthStorage, resolvedCredentialInstance?: ProviderInstanceRef): CredentialStore {
|
||||
const scopedRefFor = (providerId: string): ProviderInstanceRef | undefined => {
|
||||
const normalizedProviderId = providerId === ANTHROPIC_PROVIDER_ID
|
||||
? ANTHROPIC_PROVIDER_ID
|
||||
: providerId;
|
||||
return resolvedCredentialInstance?.providerId === normalizedProviderId
|
||||
? resolvedCredentialInstance
|
||||
: undefined;
|
||||
};
|
||||
return {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-17-06:30:
|
||||
pi >=0.80.8 moved session request auth from `ModelRegistry.getApiKeyAndHeaders` (which called fusion's `getApiKey(provider)`) to `ModelRuntime.getAuth` -> pi-ai `resolveProviderAuth`, which reads the credential store directly (`credentials.read(provider.id)`) and, for an OAuth credential, refreshes it ITSELF via `credentials.modify(provider.id, ...)`. That refresh path is broken for Anthropic: fusion persists the subscription login under `anthropic-subscription` (there is NO raw `anthropic` row), so `modify("anthropic")` reads `current === undefined`, the refresh callback bails, and `resolveStoredOAuth` returns undefined -> the task fails with `Provider is not configured: anthropic` (then falls back). The status card still shows "connected" because status uses a different path (hasVisibleAnthropicCredential). Fix: resolve Anthropic auth through fusion's `getApiKey("anthropic")`, the battle-tested path that already handles token refresh + the raw-key/legacy-oauth/subscription/fallback precedence (see resolveAnthropicRuntimeApiKey), and hand pi-ai a ready-to-use api_key credential. pi-ai's anthropic-messages layer routes by token prefix — `sk-ant-oat*` -> OAuth Bearer + Claude Code identity headers, otherwise x-api-key — so a subscription OAuth token still runs as OAuth, and returning it as `api_key` deliberately bypasses pi-ai's own (broken-for-us) OAuth refresh-via-modify. Other OAuth providers (openai-codex, github-copilot) are stored under their own provider id, so read/modify share an id and pi-ai's refresh works — only Anthropic needs this indirection.
|
||||
*/
|
||||
read: async (providerId) => {
|
||||
const scopedRef = scopedRefFor(providerId);
|
||||
if (providerId === ANTHROPIC_PROVIDER_ID) {
|
||||
const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID);
|
||||
// Preserve Anthropic's refresh-aware getApiKey indirection for scoped instances too.
|
||||
const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID, scopedRef);
|
||||
return token ? ({ type: "api_key", key: token } as Credential) : undefined;
|
||||
}
|
||||
return authStorage.get(providerId) as Credential | undefined;
|
||||
return (scopedRef ? authStorage.getInstance(scopedRef) : authStorage.get(providerId)) as Credential | undefined;
|
||||
},
|
||||
list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => {
|
||||
const credential = authStorage.get(providerId);
|
||||
return credential?.type === "api_key" || credential?.type === "oauth"
|
||||
? [{ providerId, type: credential.type }]
|
||||
: [];
|
||||
const credential = scopedRefFor(providerId) ? authStorage.getInstance(scopedRefFor(providerId)!) : authStorage.get(providerId);
|
||||
return credential?.type === "api_key" || credential?.type === "oauth" ? [{ providerId, type: credential.type }] : [];
|
||||
}),
|
||||
modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise<StoredCredential | undefined>) as Promise<Credential | undefined>,
|
||||
delete: async (providerId) => { await authStorage.remove(providerId); },
|
||||
modify: async (providerId, fn) => {
|
||||
const scopedRef = scopedRefFor(providerId);
|
||||
if (!scopedRef) return authStorage.modify(providerId, async current => fn(current as Credential | undefined) as Promise<StoredCredential | undefined>) as Promise<Credential | undefined>;
|
||||
const next = await fn(authStorage.getInstance(scopedRef) as Credential | undefined);
|
||||
if (next) await authStorage.setInstance(scopedRef, next as StoredCredential);
|
||||
return next;
|
||||
},
|
||||
delete: async (providerId) => {
|
||||
const scopedRef = scopedRefFor(providerId);
|
||||
if (scopedRef) await authStorage.removeInstance(scopedRef); else await authStorage.remove(providerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -557,9 +612,9 @@ export type FusionModelRegistry = ModelRegistry & { readonly modelRuntime: Model
|
||||
* ModelRuntime. Keep Fusion's file-backed, locked credential adapter as the runtime
|
||||
* CredentialStore so FN-7646's per-provider read-modify-merge guarantee survives.
|
||||
*/
|
||||
export async function createFusionModelRegistry(authStorage: FusionAuthStorage, home?: string): Promise<FusionModelRegistry> {
|
||||
export async function createFusionModelRegistry(authStorage: FusionAuthStorage, home?: string, resolvedCredentialInstance?: ProviderInstanceRef): Promise<FusionModelRegistry> {
|
||||
const modelRuntime = await ModelRuntime.create({
|
||||
credentials: createFusionCredentialStore(authStorage),
|
||||
credentials: createFusionCredentialStore(authStorage, resolvedCredentialInstance),
|
||||
modelsPath: getModelRegistryModelsPath(home),
|
||||
});
|
||||
authStorage.setModelRuntime(modelRuntime);
|
||||
@@ -1110,9 +1165,16 @@ export function createFusionAuthStorage(): FusionAuthStorage {
|
||||
}
|
||||
|
||||
if (prop === "getApiKey") {
|
||||
return async (provider: string) => {
|
||||
return async (provider: string, instance?: ProviderInstanceRef) => {
|
||||
if (!parseProviderInstanceKey(provider)) return undefined;
|
||||
await supplementalHydration;
|
||||
if (instance) {
|
||||
const credential = target.getInstance(instance);
|
||||
if (!credential) return undefined;
|
||||
const refreshed = await refreshOAuthCredential(instance.providerId, credential);
|
||||
if (refreshed && refreshed !== credential) await target.setInstance(instance, refreshed);
|
||||
return resolveStoredCredentialApiKey(provider, refreshed ?? credential);
|
||||
}
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return resolveAnthropicRuntimeApiKey();
|
||||
}
|
||||
|
||||
@@ -8735,6 +8735,7 @@ export class TaskExecutor {
|
||||
),
|
||||
taskValidatorProvider: detail.validatorModelProvider,
|
||||
taskValidatorModelId: detail.validatorModelId,
|
||||
taskValidatorCredentialInstanceId: detail.validatorCredentialInstanceId,
|
||||
projectValidatorProvider: settings.validatorProvider,
|
||||
projectValidatorModelId: settings.validatorModelId,
|
||||
projectValidatorFallbackProvider: settings.validatorFallbackProvider,
|
||||
@@ -14187,12 +14188,14 @@ export class TaskExecutor {
|
||||
Override column agents own initial session model selection as well as mid-flight re-resolution. Ignore task-level modelProvider/modelId before resolveExecutorSessionModel so pre-existing task model pairs cannot run the column-agent identity on the task model.
|
||||
*/
|
||||
const overrideColumnGovernsInitialSession = columnAgentSeam?.mode === "override";
|
||||
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
|
||||
const executorSessionModel = resolveExecutorSessionModel(
|
||||
overrideColumnGovernsInitialSession ? undefined : detail.modelProvider,
|
||||
overrideColumnGovernsInitialSession ? undefined : detail.modelId,
|
||||
settings,
|
||||
(identityAgent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
|
||||
overrideColumnGovernsInitialSession ? undefined : detail.credentialInstanceId,
|
||||
);
|
||||
const { provider: executorProvider, modelId: executorModelId } = executorSessionModel;
|
||||
const { provider: executorFallbackProvider, modelId: executorFallbackModelId } = resolveExecutorFallbackModel(settings);
|
||||
const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel;
|
||||
const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings);
|
||||
@@ -14293,6 +14296,7 @@ export class TaskExecutor {
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: executorProvider,
|
||||
defaultModelId: executorModelId,
|
||||
...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: executorFallbackProvider,
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
fallbackThinkingLevel: executorFallbackThinkingLevel,
|
||||
@@ -14737,6 +14741,7 @@ export class TaskExecutor {
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: executorProvider,
|
||||
defaultModelId: executorModelId,
|
||||
...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: executorFallbackProvider,
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
fallbackThinkingLevel: executorFallbackThinkingLevel,
|
||||
@@ -17713,12 +17718,14 @@ export class TaskExecutor {
|
||||
|
||||
// Resolve model using the executor's model hierarchy
|
||||
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
|
||||
const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel(
|
||||
const executorSessionModel = resolveExecutorSessionModel(
|
||||
task.modelProvider,
|
||||
task.modelId,
|
||||
settings,
|
||||
assignedRuntimeConfig,
|
||||
task.credentialInstanceId,
|
||||
);
|
||||
const { provider: executorProvider, modelId: executorModelId } = executorSessionModel;
|
||||
|
||||
const executorFallback = resolveExecutorFallbackModel(settings);
|
||||
|
||||
@@ -17750,6 +17757,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
|
||||
onToolEnd: logger.onToolEnd,
|
||||
defaultProvider: executorProvider,
|
||||
defaultModelId: executorModelId,
|
||||
...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: executorFallback.provider,
|
||||
fallbackModelId: executorFallback.modelId,
|
||||
fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(task.thinkingLevel, settings),
|
||||
@@ -18743,16 +18751,20 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
task.validatorModelId,
|
||||
settings,
|
||||
assignedRuntimeConfig,
|
||||
task.validatorCredentialInstanceId,
|
||||
)
|
||||
: resolveExecutorSessionModel(
|
||||
task.modelProvider,
|
||||
task.modelId,
|
||||
settings,
|
||||
assignedRuntimeConfig,
|
||||
task.credentialInstanceId,
|
||||
);
|
||||
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
|
||||
const primaryProvider = useOverride ? workflowStep.modelProvider : laneModel.provider;
|
||||
const primaryModelId = useOverride ? workflowStep.modelId : laneModel.modelId;
|
||||
// FNXC:ProviderAuth 2026-08-01-08:39: A workflow-step model override has no paired instance selection, so only the resolved primary task lane may carry its requested credential instance. Fallback attempts must retain their provider-default behavior rather than inheriting a primary-provider identity.
|
||||
const primaryCredentialInstanceId = useOverride ? undefined : laneModel.credentialInstanceId;
|
||||
|
||||
const workflowFallback = isReviewTypeWorkflowStep
|
||||
? resolveValidatorFallbackModel(settings)
|
||||
@@ -18932,6 +18944,9 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
tools: toolMode,
|
||||
defaultProvider: provider,
|
||||
defaultModelId: modelId,
|
||||
...(attemptLabel !== "fallback" && primaryCredentialInstanceId
|
||||
? { credentialInstanceId: primaryCredentialInstanceId }
|
||||
: {}),
|
||||
fallbackProvider: workflowFallback.provider,
|
||||
fallbackModelId: workflowFallback.modelId,
|
||||
fallbackThinkingLevel: workflowStepFallbackThinkingLevel,
|
||||
@@ -22044,8 +22059,13 @@ Child agent: ${agent.id} (${name})`;
|
||||
// Resolve executor model via canonical lane hierarchy so child agents
|
||||
// honor project executionProvider/executionModelId overrides (parity
|
||||
// with main executor at the top of agentWork()).
|
||||
const { provider: childExecutorProvider, modelId: childExecutorModelId } =
|
||||
resolveExecutorSessionModel(undefined, undefined, settings, agent.runtimeConfig as Record<string, unknown> | undefined);
|
||||
const childExecutorSessionModel = resolveExecutorSessionModel(
|
||||
undefined,
|
||||
undefined,
|
||||
settings,
|
||||
agent.runtimeConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
const { provider: childExecutorProvider, modelId: childExecutorModelId } = childExecutorSessionModel;
|
||||
|
||||
const childExecutorFallback = resolveExecutorFallbackModel(settings);
|
||||
|
||||
@@ -22059,6 +22079,7 @@ Child agent: ${agent.id} (${name})`;
|
||||
tools: "coding",
|
||||
defaultProvider: childExecutorProvider,
|
||||
defaultModelId: childExecutorModelId,
|
||||
...(childExecutorSessionModel.credentialInstanceId ? { credentialInstanceId: childExecutorSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: childExecutorFallback.provider,
|
||||
fallbackModelId: childExecutorFallback.modelId,
|
||||
fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, settings),
|
||||
|
||||
@@ -439,6 +439,7 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string,
|
||||
onToolEnd: logger.onToolEnd,
|
||||
defaultProvider: model.provider,
|
||||
defaultModelId: model.modelId,
|
||||
...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel),
|
||||
@@ -508,6 +509,7 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
|
||||
onToolEnd: logger.onToolEnd,
|
||||
defaultProvider: model.provider,
|
||||
defaultModelId: model.modelId,
|
||||
...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel),
|
||||
|
||||
@@ -1182,6 +1182,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
|
||||
onToolEnd: logger.onToolEnd,
|
||||
defaultProvider: mergerSessionModel.provider,
|
||||
defaultModelId: mergerSessionModel.modelId,
|
||||
...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel),
|
||||
@@ -2449,6 +2450,7 @@ ${fileList}
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: mergerSessionModel.provider,
|
||||
defaultModelId: mergerSessionModel.modelId,
|
||||
...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel),
|
||||
@@ -2873,6 +2875,7 @@ ${fileList}
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: mergerSessionModel.provider,
|
||||
defaultModelId: mergerSessionModel.modelId,
|
||||
...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel),
|
||||
@@ -5925,6 +5928,7 @@ You are assisting with a paused \`git pull --rebase\`.
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: mergerSessionModel.provider,
|
||||
defaultModelId: mergerSessionModel.modelId,
|
||||
...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel),
|
||||
@@ -10923,6 +10927,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: mergerSessionModel.provider,
|
||||
defaultModelId: mergerSessionModel.modelId,
|
||||
...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel),
|
||||
|
||||
@@ -802,6 +802,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
tools: "readonly",
|
||||
defaultProvider: validationSessionModel.provider,
|
||||
defaultModelId: validationSessionModel.modelId,
|
||||
...(validationSessionModel.credentialInstanceId ? { credentialInstanceId: validationSessionModel.credentialInstanceId } : {}),
|
||||
fallbackProvider: settings?.fallbackProvider,
|
||||
fallbackModelId: settings?.fallbackModelId,
|
||||
defaultThinkingLevel: "medium",
|
||||
@@ -1077,12 +1078,13 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
task: Awaited<ReturnType<TaskStore["getTask"]>> | null,
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
): { provider: string | undefined; modelId: string | undefined } {
|
||||
): { provider?: string; modelId?: string; credentialInstanceId?: string } {
|
||||
return resolveValidatorSessionModel(
|
||||
task?.validatorModelProvider,
|
||||
task?.validatorModelId,
|
||||
settings,
|
||||
assignedAgentRuntimeConfig,
|
||||
task?.validatorCredentialInstanceId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import type {
|
||||
AgentPermissionPolicyActionCategory,
|
||||
PermanentAgentActionCategory,
|
||||
PermanentAgentGatingContext,
|
||||
ProviderInstanceRef,
|
||||
ResolvedMcpServerDefinition,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -1064,6 +1065,10 @@ export interface AgentOptions {
|
||||
defaultProvider?: string;
|
||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
|
||||
defaultModelId?: string;
|
||||
/** Concrete session-scoped credential instance, resolved by agent-session-helpers. */
|
||||
resolvedCredentialInstance?: ProviderInstanceRef;
|
||||
/** Informational requested credential instance id. */
|
||||
credentialInstanceId?: string;
|
||||
/** Optional fallback model provider used when the primary selected model hits
|
||||
* a retryable provider-side failure such as rate limiting or overload. */
|
||||
fallbackProvider?: string;
|
||||
@@ -2299,7 +2304,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
logMcpForwardingSkipped({ runtimeId: "pi", provider: options.defaultProvider, skippedCount: requestedMcpServers.length, lane: "createFnAgent" });
|
||||
}
|
||||
const authStorage = createFusionAuthStorage();
|
||||
const modelRegistry = await createFusionModelRegistry(authStorage);
|
||||
const modelRegistry = await createFusionModelRegistry(authStorage, undefined, options.resolvedCredentialInstance);
|
||||
const modelRuntime = modelRegistry.modelRuntime;
|
||||
|
||||
// Resolve the project root early so extension providers, skill discovery,
|
||||
|
||||
@@ -132,6 +132,7 @@ export function makePrResponseAgentRunner(
|
||||
},
|
||||
defaultProvider: model.provider,
|
||||
defaultModelId: model.modelId,
|
||||
...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}),
|
||||
fallbackProvider: mergerFallbackModel.provider,
|
||||
fallbackModelId: mergerFallbackModel.modelId,
|
||||
fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel),
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface ReviewOptions {
|
||||
taskValidatorProvider?: string;
|
||||
/** Task-level validator model ID override. When both provider and modelId are set, takes precedence over project/global lanes. */
|
||||
taskValidatorModelId?: string;
|
||||
/** Credential instance paired with the task-level validator model selection. */
|
||||
taskValidatorCredentialInstanceId?: string;
|
||||
/** Project-level validator model provider override. Takes precedence over global validator lane. */
|
||||
projectValidatorProvider?: string;
|
||||
/** Project-level validator model ID override. Takes precedence over global validator lane. */
|
||||
@@ -302,6 +304,8 @@ export async function reviewStep(
|
||||
options.taskValidatorProvider,
|
||||
options.taskValidatorModelId,
|
||||
reviewerModelSettings,
|
||||
undefined,
|
||||
options.taskValidatorCredentialInstanceId,
|
||||
);
|
||||
const validatorProvider = reviewerModel.provider;
|
||||
const validatorModelId = reviewerModel.modelId;
|
||||
@@ -500,6 +504,9 @@ export async function reviewStep(
|
||||
onToolEnd: agentLogger?.onToolEnd,
|
||||
defaultProvider: overrides?.forceProvider ?? validatorProvider,
|
||||
defaultModelId: overrides?.forceModelId ?? validatorModelId,
|
||||
...(!overrides?.forceProvider && !overrides?.forceModelId && reviewerModel.credentialInstanceId
|
||||
? { credentialInstanceId: reviewerModel.credentialInstanceId }
|
||||
: {}),
|
||||
fallbackProvider: validatorFallbackProvider,
|
||||
fallbackModelId: validatorFallbackModelId,
|
||||
fallbackThinkingLevel: options.fallbackThinkingLevel
|
||||
|
||||
@@ -2684,12 +2684,14 @@ export class TriageProcessor {
|
||||
task.planningModelId,
|
||||
settings,
|
||||
assignedAgent?.runtimeConfig,
|
||||
task.planningCredentialInstanceId,
|
||||
);
|
||||
activePlanningProvider = planningModel.provider;
|
||||
|
||||
const planningSessionModelOptions = {
|
||||
defaultProvider: planningModel.provider,
|
||||
defaultModelId: planningModel.modelId,
|
||||
...(planningModel.credentialInstanceId ? { credentialInstanceId: planningModel.credentialInstanceId } : {}),
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user