fix(auth): do not admit ambient environment credentials behind declared profiles (#118458)

A provider credential present only in the process environment — named in
neither the provider entry, `auth.profiles`, nor `auth.order` — was appended to
the auth attempt list behind the operator's declared profiles. A run that left
a declared profile could therefore continue on an undeclared credential, which
may bill a different account, with no configuration authorizing the transition
and nothing reporting that it happened.

Add an explicit `authorization: "declared" | "ambient"` fact on direct auth
sources and enforce it during source selection. The field is required rather
than defaulted so every construction site is audited; `evidence` is left as
provenance, since a declared credential can legitimately be environment-sourced
via a `${VAR}` marker or a SecretRef.

An ambient credential may still serve a provider with no declared profiles (the
documented zero-config `PROVIDER_API_KEY` path), but it is no longer admitted
behind declared profiles, nor substituted for declared profiles that turned out
to be unusable. `auth.order` failover is unaffected: profiles are never filtered
by this change. The read-only availability evaluator applies the same rule so
status cannot advertise a credential the runtime refuses.

This restores the invariant that held before 1b1cebfe42 (#104685), where an
environment candidate was reachable only when a provider had no declared
profiles. The regression shipped in 2026.7.2-beta.1 onwards; no GA release is
affected.

Reported in #117956.


Claude-Session: https://claude.ai/code/session_01Nwk2KaB6zL71xa1i3Xwgwi

Co-authored-by: Marvinthebored <262704729+Marvinthebored@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marvinthebored
2026-08-04 08:15:35 +08:00
committed by GitHub
parent 67d67b5ca9
commit 778be6f5cc
10 changed files with 400 additions and 44 deletions

View File

@@ -213,7 +213,14 @@ export async function handleAssistantFailover(params: {
const markFailedProfilePromise = markFailedProfile();
if (timeoutFailure && !params.isProbeSession && failedProfileId) {
const timeoutLabel = terminal.idleTimedOut ? "idle timeout (model silent)" : "timed out";
params.warn(`Profile ${failedProfileId} ${timeoutLabel}. Trying next account...`);
// Only promise a next account when one was actually selected. Credentials
// that config does not authorize are not rotation targets, so this can end
// with no further account even when one exists in the environment.
params.warn(
rotated
? `Profile ${failedProfileId} ${timeoutLabel}. Trying next account...`
: `Profile ${failedProfileId} ${timeoutLabel}. No further authorized account for this provider; create a backup auth profile and add its id to auth.order to enable failover.`,
);
}
if (params.cloudCodeAssistFormatError && failedProfileId) {
params.warn(

View File

@@ -499,14 +499,18 @@ describe("createModelAuthAvailabilityResolver", () => {
route: subscriptionRoute,
mode: "oauth",
},
])("selects $label", ({ cfg, env, mode, profile, profileId, route }) => {
expect(evaluate({ cfg, env, store: authStore({ [profileId]: profile }) })).toMatchObject({
availability: true,
evidence: "environment",
selectedAuthMode: mode,
selectedRoute: route,
});
});
// An environment credential named nowhere in config is not authorized to
// stand in for a declared profile, including a declared profile that turned
// out to be unusable. Availability mirrors the runtime rule here so status
// does not advertise a credential the run would refuse to use.
])(
"reports $label unavailable rather than substituting an ambient credential",
({ cfg, env, profile, profileId }) => {
expect(evaluate({ cfg, env, store: authStore({ [profileId]: profile }) })).toMatchObject({
availability: false,
});
},
);
it.each([
{ env: { OPENAI_API_KEY: "resolved-key" }, availability: true },

View File

@@ -55,6 +55,7 @@ import {
buildProviderModelAuthSourcePlan,
fromProviderModelAuthReadiness,
toProviderModelAuthReadiness,
type ProviderModelAuthAuthorization,
type ProviderModelAuthEvidence,
type ProviderModelAuthProfileSource,
} from "./provider-model-auth-source-plan.js";
@@ -611,11 +612,15 @@ export function createModelAuthAvailabilityResolver(
selectedAuthMode: configured?.auth,
};
};
const directSource = (evaluation: AuthSourceEvaluation) =>
const directSource = (
evaluation: AuthSourceEvaluation,
authorization: ProviderModelAuthAuthorization = "declared",
) =>
buildProviderModelAuthDirectSource({
mode: evaluation.selectedAuthMode,
availability: evaluation.availability,
evidence: evaluation.evidence ?? "none",
authorization,
});
const automaticProfileSource = (
provider: string,
@@ -677,14 +682,28 @@ export function createModelAuthAvailabilityResolver(
(hasDirectMaterial && shouldPreferExplicitConfigApiKeyAuth(params.cfg, provider));
const environment = envAuth(provider);
const environmentMode = environment ? (configured?.auth ?? environment.mode) : undefined;
// Mirrors the runtime classification in runtime-plan/prepare-auth.ts: a
// credential is ambient only when it came from the environment and the
// provider entry declares no apiKey material pointing at it. Availability
// and runtime must agree, or status advertises a credential the run will
// refuse (or the reverse).
const ambientEnvironmentCredential =
!required && environmentMode !== undefined && environmentMode !== "aws-sdk"
? !hasDirectMaterial
: false;
const direct =
!required && environmentMode
? buildProviderModelAuthDirectSource({
mode: environmentMode,
availability: modeAllowed(provider, target, environmentMode),
evidence: environmentMode === "aws-sdk" ? "aws-sdk" : "environment",
authorization: ambientEnvironmentCredential ? "ambient" : "declared",
})
: directSource(unprofiledEvaluation(provider, target));
: ((evaluation) =>
directSource(
evaluation,
evaluation.evidence === "environment" && !hasDirectMaterial ? "ambient" : "declared",
))(unprofiledEvaluation(provider, target));
const hasDirectFallback = hasDirectMaterial || direct.evidence !== "none";
return {
binding,

View File

@@ -18,11 +18,24 @@ export type ProviderModelAuthProfileSource = {
cooldown: "active" | "clear";
};
/**
* Whether config authorizes this credential, as opposed to where it was found.
*
* `evidence` is provenance and is reported as such by status/probe surfaces; it
* cannot carry authorization, because a *declared* credential can legitimately
* be discovered in the environment (a `${VAR}` marker or a SecretRef naming a
* canonical variable). `"ambient"` means the opposite: the credential appears in
* neither the provider entry nor `auth.profiles`/`auth.order`, so nothing in
* config points at it and it may bill an account the operator never named here.
*/
export type ProviderModelAuthAuthorization = "declared" | "ambient";
export type ProviderModelAuthDirectSource = {
kind: "direct";
mode?: string;
readiness: ProviderModelAuthReadiness;
evidence: ProviderModelAuthEvidence;
authorization: ProviderModelAuthAuthorization;
};
export type ProviderModelAuthSource =
@@ -61,6 +74,14 @@ export type ProviderModelAuthSourcePlan =
orderedProfiles: readonly ProviderModelAuthProfileSource[];
allowCooldown: boolean;
fallback?: ProviderModelAuthDirectSource;
/**
* How many profiles the operator declared for this provider, before any
* readiness, cooldown or route-compatibility filtering. Route filtering
* rebuilds the plan from a narrowed profile list, so `profiles.kind` alone
* cannot distinguish "operator declared nothing" (zero-config) from
* "everything the operator declared was filtered out".
*/
declaredProfileCount: number;
};
export function toProviderModelAuthReadiness(
@@ -80,12 +101,19 @@ export function buildProviderModelAuthDirectSource(params: {
mode?: string;
availability?: boolean;
evidence: ProviderModelAuthEvidence;
/**
* Required, not defaulted: a permissive default would silently give every
* unaudited construction site full standing, which is exactly how a source
* escapes the ambient-credential rule. Make each caller state it.
*/
authorization: ProviderModelAuthAuthorization;
}): ProviderModelAuthDirectSource {
return {
kind: "direct",
mode: params.mode,
readiness: toProviderModelAuthReadiness(params.availability),
evidence: params.evidence,
authorization: params.authorization,
};
}
@@ -113,6 +141,8 @@ export function buildProviderModelAuthSourcePlan(params: {
explicitOrder?: boolean;
fallback?: ProviderModelAuthDirectSource;
allowCooldown?: boolean;
/** Overrides the declared count when rebuilding a plan from filtered profiles. */
declaredProfileCount?: number;
}): ProviderModelAuthSourcePlan {
if (params.ownership) {
return { kind: "required", ...params.ownership };
@@ -148,6 +178,7 @@ export function buildProviderModelAuthSourcePlan(params: {
profiles,
orderedProfiles: ordered,
allowCooldown: params.allowCooldown === true,
declaredProfileCount: params.declaredProfileCount ?? ordered.length,
...(params.fallback ? { fallback: params.fallback } : {}),
};
}

View File

@@ -6,6 +6,7 @@ import {
} from "./provider-model-auth-source-plan.js";
import {
resolveProviderModelRouteMaterializationAuthMode,
selectProviderModelAuthSources,
selectProviderModelRouteAuth,
} from "./provider-model-route-auth.js";
@@ -38,12 +39,16 @@ function profile(
return { kind: "profile", profileId, mode, readiness, cooldown };
}
function direct(mode: string): ProviderModelAuthDirectSource {
function direct(
mode: string,
authorization: ProviderModelAuthDirectSource["authorization"] = "declared",
): ProviderModelAuthDirectSource {
return {
kind: "direct",
mode,
readiness: "ready",
evidence: "provider-config",
authorization,
};
}
@@ -406,3 +411,69 @@ describe("provider model route auth", () => {
});
});
});
describe("ambient credential admission", () => {
const ambient = (mode: string): ProviderModelAuthDirectSource => direct(mode, "ambient");
const ready = (profileId: string, mode: string) => profile(profileId, mode, "ready");
const select = (plan: Parameters<typeof selectProviderModelAuthSources>[0]["plan"]) =>
selectProviderModelAuthSources({ provider: "openai", plan });
it("drops an ambient fallback queued behind usable profiles", () => {
const decision = select(
buildProviderModelAuthSourcePlan({
profiles: [ready("openai:chatgpt", "oauth")],
fallback: ambient("api-key"),
}),
);
expect(decision).toMatchObject({ kind: "selected" });
expect(decision.kind === "selected" && decision.attempts).toMatchObject([{ kind: "profile" }]);
});
it("keeps a declared fallback queued behind usable profiles", () => {
const decision = select(
buildProviderModelAuthSourcePlan({
profiles: [ready("openai:chatgpt", "oauth")],
fallback: direct("api-key"),
}),
);
expect(decision.kind === "selected" && decision.attempts).toMatchObject([
{ kind: "profile" },
{ kind: "direct" },
]);
});
it("keeps an ambient fallback when the provider declares no profiles", () => {
const decision = select(
buildProviderModelAuthSourcePlan({ profiles: [], fallback: ambient("api-key") }),
);
expect(decision.kind === "selected" && decision.attempts).toMatchObject([{ kind: "direct" }]);
});
it("does not substitute an ambient fallback for all-unavailable profiles", () => {
const decision = select(
buildProviderModelAuthSourcePlan({
profiles: [profile("openai:chatgpt", "oauth", "unavailable")],
fallback: ambient("api-key"),
}),
);
expect(decision.kind === "selected" && decision.attempts).toMatchObject([]);
});
it("does not re-admit an ambient fallback when route filtering empties the profile list", () => {
// Rebuild shape used by selectProviderModelRouteAuth when narrowing to a
// route-compatible subset: no profiles survive, but the operator declared one.
const decision = select(
buildProviderModelAuthSourcePlan({
profiles: [],
declaredProfileCount: 1,
fallback: ambient("api-key"),
}),
);
expect(decision.kind === "selected" && decision.attempts).toMatchObject([]);
});
});

View File

@@ -178,6 +178,22 @@ export function selectProviderModelAuthSources(params: {
...(profiles.kind === "all-unavailable" ? { source: profiles.first } : {}),
};
}
// An ambient credential — one config names nowhere — may serve a provider the
// operator left entirely unconfigured (the documented zero-config
// `PROVIDER_API_KEY` path), but it must never *succeed* a credential the
// operator did declare. Those can bill different accounts, so that transition
// needs a declaration, not a discovery. `auth.order` filtering already refuses
// to silently try a declared profile the operator omitted from an explicit
// order (docs/auth-credential-semantics.md, "Explicit auth order filtering");
// an undeclared credential cannot rank above that.
//
// `declaredProfileCount` rather than `profiles.kind`: route filtering rebuilds
// this plan from a narrowed profile list, so an operator who declared only
// route-incompatible profiles must not be treated as zero-config.
const authorizedFallback =
fallback?.authorization === "ambient" && params.plan.declaredProfileCount > 0
? undefined
: fallback;
if (profiles.kind === "usable") {
const winner = selectReadyProfile(profiles.profiles);
return {
@@ -185,15 +201,15 @@ export function selectProviderModelAuthSources(params: {
selection: winner ? { kind: "selected", source: winner } : { kind: "none" },
attempts: [
...profiles.profiles.map((source) => ({ kind: "profile" as const, source })),
...(fallback ? [directAttempt(fallback)] : []),
...(authorizedFallback ? [directAttempt(authorizedFallback)] : []),
],
};
}
if (fallback) {
if (authorizedFallback) {
return {
kind: "selected",
selection: { kind: "selected", source: fallback },
attempts: [directAttempt(fallback)],
selection: { kind: "selected", source: authorizedFallback },
attempts: [directAttempt(authorizedFallback)],
};
}
return {
@@ -306,6 +322,10 @@ export function selectProviderModelRouteAuth(params: {
),
explicitOrder: params.sourcePlan.profiles.explicitOrder,
allowCooldown: params.sourcePlan.allowCooldown,
// Preserve what the operator actually declared. Filtering to a
// route-compatible subset must not make a configured provider look
// zero-config and thereby re-admit an ambient credential.
declaredProfileCount: params.sourcePlan.declaredProfileCount,
...(params.sourcePlan.fallback ? { fallback: params.sourcePlan.fallback } : {}),
})
: params.sourcePlan;

View File

@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { AuthProfileStore } from "../auth-profiles.js";
import { prepareAgentRuntimeAuth } from "./prepare-auth.js";
vi.mock("../../plugins/provider-runtime.js", () => ({
buildProviderMissingAuthMessageWithPlugin: () => undefined,
resolveProviderSyntheticAuthWithPlugin: () => undefined,
shouldDeferProviderSyntheticProfileAuthWithPlugin: () => undefined,
}));
// Regression coverage for #117956: metered API usage on a route whose only
// declared credential was a subscription auth profile.
//
// Reported shape: the `anthropic` plugin disabled, so `claude-cli` resolves as a
// bare custom provider entry (no `api`, no `baseUrl`, no `apiKey`); a single
// subscription OAuth profile; no `auth.order`; and `ANTHROPIC_API_KEY` present
// in the process environment for other, separately-named consumers. The
// environment credential was appended as a second physical attempt and became
// the live credential once the run rotated off the profile.
const oauthStore = (expires: number): AuthProfileStore => ({
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "claude-cli",
access: "subscription-token",
refresh: "refresh-token",
expires,
},
} as unknown as AuthProfileStore["profiles"],
});
const config = {
plugins: { entries: { anthropic: { enabled: false } } },
models: {
providers: { "claude-cli": { models: [{ id: "claude-fable-5" }] } },
},
agents: {
defaults: {
model: { primary: "claude-cli/claude-fable-5" },
models: { "claude-cli/claude-fable-5": { alias: "fable5" } },
},
},
} as unknown as OpenClawConfig;
const ambientEnv = { ANTHROPIC_API_KEY: "ambient-anthropic-key" } as NodeJS.ProcessEnv;
describe("ambient provider credentials are not queued behind a declared profile", () => {
it.each([undefined, "anthropic-messages"])("modelApi=%s", (modelApi) => {
const prepared = prepareAgentRuntimeAuth({
provider: "claude-cli",
modelId: "claude-fable-5",
...(modelApi ? { modelApi } : {}),
config,
env: ambientEnv,
authProfileStore: oauthStore(Date.now() + 3_600_000),
});
expect(prepared.attempts).toMatchObject([
{ kind: "profile", profileId: "anthropic:claude-cli" },
]);
expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false);
});
// Being unable to use what was declared is not authorization to use what was
// not: an unusable profile must surface rather than silently move the run to
// an account that appears nowhere in config.
it("does not substitute an ambient credential for an unusable declared profile", () => {
const prepared = prepareAgentRuntimeAuth({
provider: "claude-cli",
modelId: "claude-fable-5",
config,
env: ambientEnv,
authProfileStore: oauthStore(0),
});
expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false);
});
// Documented `auth.order` failover is unaffected: this change gates ambient
// credentials only, never declared profiles. Every profile named in the
// explicit order stays in the attempt list, in the order given, even when an
// ambient credential is present in the environment.
it("preserves declared auth.order failover across multiple profiles", () => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
config: {
auth: { order: { openai: ["openai:primary", "openai:backup"] } },
} as OpenClawConfig,
env: { OPENAI_API_KEY: "ambient-platform-key" } as NodeJS.ProcessEnv,
authProfileStore: {
version: 1,
profiles: {
"openai:primary": {
type: "oauth",
provider: "openai",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 600_000,
},
"openai:backup": {
type: "oauth",
provider: "openai",
access: "backup-token",
refresh: "backup-refresh",
expires: Date.now() + 600_000,
},
},
order: { openai: ["openai:primary", "openai:backup"] },
} as unknown as AuthProfileStore,
});
expect(
prepared.attempts.map((attempt) =>
attempt.kind === "profile" ? attempt.profileId : "direct",
),
).toEqual(["openai:primary", "openai:backup"]);
});
// The documented remedy for operators who relied on an ambient key as a
// backup: declare an API-key profile and order it after the subscription
// profile (docs/concepts/model-failover.md). That path must keep working.
it("preserves a declared API-key backup ordered after a subscription profile", () => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
config: {
auth: { order: { openai: ["openai:chatgpt", "openai:platform"] } },
} as OpenClawConfig,
env: { OPENAI_API_KEY: "ambient-platform-key" } as NodeJS.ProcessEnv,
authProfileStore: {
version: 1,
profiles: {
"openai:chatgpt": {
type: "oauth",
provider: "openai",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 600_000,
},
"openai:platform": {
type: "api_key",
provider: "openai",
key: "declared-platform-key",
},
},
order: { openai: ["openai:chatgpt", "openai:platform"] },
} as unknown as AuthProfileStore,
});
expect(prepared.attempts.map((a) => (a.kind === "profile" ? a.profileId : "direct"))).toEqual([
"openai:chatgpt",
"openai:platform",
]);
});
});

View File

@@ -1290,9 +1290,51 @@ describe("prepareAgentRuntimeAuthPlan", () => {
).toBe(true);
});
// Zero-config still works: when a provider has no usable auth profile at all,
// a bare `PROVIDER_API_KEY` remains the credential for the route. Refusing an
// undeclared credential is about not letting it silently *succeed a declared
// profile*, not about banning the documented zero-config path.
it("still uses an undeclared env key when the provider has no usable profile", () => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
env: { OPENAI_API_KEY: "ambient-platform-key" },
authProfileStore: authStore({}),
});
expect(prepared.attempts).toMatchObject([{ kind: "direct" }]);
expect(prepared.attempts.some((attempt) => attempt.kind === "profile")).toBe(false);
});
// Declared apiKey material keeps normal direct-source standing, so the
// narrowing is scoped to credentials that appear nowhere in config.
it("still routes a declared provider apiKey with no profiles present", () => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
config: {
models: {
providers: { openai: { apiKey: "configured-platform-key", baseUrl: "", models: [] } },
},
} as OpenClawConfig,
env: {},
authProfileStore: authStore({}),
});
expect(prepared.attempts).toMatchObject([{ kind: "direct" }]);
});
// An environment credential named nowhere in config is not an authorized
// route. `auth.order` filtering already refuses to silently try a *stored*
// profile the operator omitted from the explicit order
// (docs/auth-credential-semantics.md, "Explicit auth order filtering"), and
// docs/providers/openai.md reserves bare `OPENAI_API_KEY` for non-agent
// surfaces. An undeclared env key must therefore not be queued behind a
// declared profile, where it would silently absorb that profile's failures —
// potentially onto a different billing account.
it.each([
{
label: "OAuth profile then ambient Platform key",
label: "ambient Platform key behind an OAuth profile",
env: { OPENAI_API_KEY: "ambient-platform-key" },
profileId: "openai:chatgpt",
profile: {
@@ -1302,10 +1344,10 @@ describe("prepareAgentRuntimeAuthPlan", () => {
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
requirements: ["subscription", "api-key"],
requirements: ["subscription"],
},
{
label: "Platform profile then ambient OAuth token",
label: "ambient OAuth token behind a Platform profile",
config: {
models: { providers: { openai: { auth: "oauth", baseUrl: "", models: [] } } },
} as OpenClawConfig,
@@ -1316,32 +1358,23 @@ describe("prepareAgentRuntimeAuthPlan", () => {
provider: "openai",
key: "profile-platform-key",
},
requirements: ["api-key", "subscription"],
requirements: ["api-key"],
},
])(
"prepares $label as distinct physical attempts",
({ config, env, profile, profileId, requirements }) => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
config,
env,
authProfileStore: authStore({ [profileId]: profile }, { openai: [profileId] }),
});
])("does not queue $label", ({ config, env, profile, profileId, requirements }) => {
const prepared = prepareAgentRuntimeAuth({
provider: "openai",
modelId: "gpt-5.5",
config,
env,
authProfileStore: authStore({ [profileId]: profile }, { openai: [profileId] }),
});
expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual(
requirements,
);
expect(prepared.attempts).toMatchObject([
{ kind: "profile", profileId },
{
kind: "direct",
allowAuthProfileFallback: false,
requiresPriorProfileAttempt: true,
},
]);
},
);
expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual(
requirements,
);
expect(prepared.attempts).toMatchObject([{ kind: "profile", profileId }]);
expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false);
});
it("resolves an env SecretRef on its prepared Platform route", async () => {
vi.stubEnv("OPENAI_PLATFORM_KEY", "secret-ref-platform-key");

View File

@@ -339,7 +339,8 @@ export function prepareAgentRuntimeAuth(
? "runtime"
: "provider-config",
availability?: boolean,
) => buildProviderModelAuthDirectSource({ mode, evidence, availability });
authorization: ProviderModelAuthDirectSource["authorization"] = "declared",
) => buildProviderModelAuthDirectSource({ mode, evidence, availability, authorization });
const directPlanningCandidate = harnessAllowsAuthProfileForwarding
? resolveProviderDirectAuthPlanningEvidence(
authProfileSelectionProvider,
@@ -360,11 +361,19 @@ export function prepareAgentRuntimeAuth(
const directPlanningMode = directPlanningEvidence
? (configuredAuthMode ?? directPlanningEvidence.mode)
: undefined;
// Provenance ("where was it found") is not authorization ("may it be used
// here"). A credential found in the environment is still *declared* when the
// provider entry points at it — a literal apiKey, a `${VAR}` marker, or a
// SecretRef naming a canonical variable. Only a credential that nothing in
// config references is ambient, and only ambient credentials are restricted.
const fallbackIsAmbientCredential =
directPlanningEvidence?.kind === "environment" && !providerHasDirectMaterial;
const fallbackDirectSource = directPlanningMode
? directSource(
directPlanningMode,
directPlanningEvidence?.kind === "environment" ? "environment" : "runtime",
directPlanningEvidence?.kind === "environment" ? true : undefined,
fallbackIsAmbientCredential ? "ambient" : "declared",
)
: providerBindingNeedsNonProfileFallback
? directSource(selectedConfiguredAuthMode)

View File

@@ -331,6 +331,10 @@ export async function prepareSimpleCompletionModel(params: {
mode: auth.mode,
availability: true,
evidence: "runtime",
// The credential is already resolved by the caller and wrapped as
// required provider-binding ownership below, so it is not an
// ambient discovery competing with declared profiles.
authorization: "declared",
});
const routeAuthDecision = selectOpenAIModelRouteAuth({
resolution: routeResolution,