Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
18edf83
Guard OAuth refresh commits with the stored generation
georgestagg Sep 8, 2026
97aa07e
Log device-code poll termination
bricestacey Sep 9, 2026
14bf3e9
Accept GOOGLE_API_KEY as an alias for the Gemini key
melissa-barca Sep 14, 2026
1bca1aa
Add a hostDefaults layer to the catalog loader
melissa-barca Sep 14, 2026
cc374e3
Add normalizeFoundryBaseUrl to ai-config
melissa-barca Sep 18, 2026
d44b49b
Resolve Google Vertex tokens from inline service-account env vars bef…
melissa-barca Sep 18, 2026
5aec5ba
Register custom entries through registerAllProviders and share the cr…
melissa-barca Sep 18, 2026
19ce1ea
Ignore DATABRICKS_TOKEN when Workbench manages the Databricks profile
melissa-barca Sep 18, 2026
560e5ac
Strip operation paths after /openai/v1 in normalizeFoundryBaseUrl
melissa-barca Sep 18, 2026
d809d4a
Declare the Vertex inline service-account variables in the provider e…
melissa-barca Sep 18, 2026
baf99a3
Classify rejected inline Vertex service-account credentials as an aut…
melissa-barca Sep 21, 2026
753d3f2
Fail inline Vertex service-account auth when no token is returned
melissa-barca Sep 21, 2026
18b9594
Describe the OAuth generation check as narrowing, not closing, the cr…
melissa-barca Sep 29, 2026
8b749d4
Capture DATABRICKS_CONFIG_FILE so the Workbench check survives enviro…
melissa-barca Sep 29, 2026
c834de7
Authenticate Vertex chat with the inline service account, not only di…
melissa-barca Sep 29, 2026
4807847
Keep transient Vertex token failures on the network-error path
melissa-barca Sep 29, 2026
5444c00
Strip URL fragments in normalizeFoundryBaseUrl
melissa-barca Sep 29, 2026
e19b07f
Type customProviders clientKind as a supported custom kind
melissa-barca Sep 29, 2026
f92bcd7
Document host defaults and inline Vertex service accounts in the memo…
melissa-barca Sep 29, 2026
93cd11b
Drop credential-shaping re-exports nothing consumes
melissa-barca Sep 29, 2026
caa84f0
Choose the Vertex auth-error guidance from the credential source in use
melissa-barca Sep 29, 2026
9fd3772
Bring the memory bank and env-mapping docs in line with the new crede…
melissa-barca Sep 29, 2026
9530a15
Build the hostDefaults source in one place for both loaders
melissa-barca Sep 29, 2026
4cea242
Treat Vertex token-request timeouts as transient
melissa-barca Sep 29, 2026
3bd0400
Ignore shell Databricks M2M credentials when Workbench manages the pr…
wch Sep 30, 2026
2a86e5b
Resolve the Vertex credential source in one place for discovery, chat…
wch Sep 30, 2026
f8e1435
Narrow Vertex token errors without type casts
wch Sep 30, 2026
3314dbb
Strip a bare trailing /openai in normalizeFoundryBaseUrl
wch Sep 30, 2026
0a533fe
Test that a refresh keeps a record another window committed mid-refresh
wch Sep 30, 2026
db11412
Skip custom provider entries with an unsupported kind instead of thro…
wch Sep 30, 2026
93e40dd
Assert the /openai/v10 path survives Foundry normalization
wch Sep 30, 2026
2905d6d
Match /openai/deployments as a whole path segment in normalizeFoundry…
wch Sep 30, 2026
fc73ee5
Bind OAuth refresh commits to the generation of the read that supplie…
wch Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion memory-bank/aiConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ Config flows through three stages: **assemble sources → resolve → watch**. P
`POSIT_AI_PROVIDERS_DEFAULT` (both remain strict JSON and are validated against the relaxed
`providersConfigFragmentSchema`), plus the legacy Positron layers the
loader opted into (`legacyPositronSettings` → `legacy-positron`,
`legacyPositronEnforcedSettings` → `legacy-positron-enforced`). Each
`legacyPositronEnforcedSettings` → `legacy-positron-enforced`), and the
host's `hostDefaults` fragment when the loader passes one (also `default`,
read after `POSIT_AI_PROVIDERS_DEFAULT`, for host-specific values such as
the OAuth client id the host is registered under). Each
reader returns `{ source?, issues }`; present sources are tagged with their
`kind` (`enforced` / `legacy-positron-enforced` / `user` /
`legacy-positron` / `default`).
Expand Down
12 changes: 8 additions & 4 deletions memory-bank/aiCredentialStore.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ compare-and-commit OAuth transactions. Values in `/store` remain fully generic.
The backend consumes storage through the `StoreBackendStorage` interface defined in
`/store-backend` (`get`/`set`/`withLock`/`watch`); `SingleFileStore` satisfies it
structurally, and non-file backings (e.g. VS Code SecretStorage) can be injected.
`withLock`'s lock scope is the backing's contract: OAuth compare-and-write and AWS
preserve mutations are read-modify-write transactions that require exclusion against
every writer of the same keys, so weaker backings (in-process mutex) are only safe
for whole-record API-key replace/clear configurations.
`withLock`'s lock scope is the backing's contract. AWS preserve mutations merge fields
into the current record, so they need exclusion against every writer of the same keys.
OAuth records carry a `generation`, and a commit lands only while the stored record
still holds the one the operation read. Under a cross-process lock (`SingleFileStore`)
that makes concurrent OAuth commits safe. Under in-process exclusion alone (VS Code
SecretStorage) it only narrows the race: the generation check and the write are
separate steps, so two windows can both pass the check before either writes. Whole-record
API-key replace/clear is last-writer-wins by design under either backing.

```ts
import { createDefaultStore, getDefaultStorePath } from "ai-credentials/store";
Expand Down
7 changes: 5 additions & 2 deletions memory-bank/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,10 @@ Credential resolution is split in two halves: session lookup (vscode-bound, `src
Bedrock's manual-key and `fromNodeProviderChain` branches converge in
`createAwsCredentialProvider()`. Converse, Anthropic Messages, Mantle
inference, and both discovery clients consume the same provider-function
shape, keeping credential precedence identical across protocols.
shape, keeping credential precedence identical across protocols. When
`AWS_WEB_IDENTITY_TOKEN_FILE` is set, the chain's STS token exchange uses the
configured Bedrock region; otherwise the region is left unset so an SSO profile's
own `sso_region` applies, matching Positron's authentication extension.

`AnthropicClient`'s auth parameter is a discriminated union,
`AnthropicClientAuth = { apiKey: string } | { authToken: string }` — the two
Expand Down Expand Up @@ -443,7 +446,7 @@ Positron's VS Code base includes it.

## Provider Registration

`register-all-providers.ts` registers every provider into a caller-owned `ProviderRegistry`, honoring `config.allowedProviders`. Its private registrar map is compile-time exhaustive over `PROVIDER_IDS`, while the public `ProviderRegistrationConfig` remains colocated with the orchestrator. Consumers that want to restrict the available provider set pass `allowedProviders`; there is no build-time provider filtering.
`register-all-providers.ts` registers every provider into a caller-owned `ProviderRegistry`, honoring `config.allowedProviders`. Its private registrar map is compile-time exhaustive over `PROVIDER_IDS`, while the public `ProviderRegistrationConfig` remains colocated with the orchestrator. Consumers that want to restrict the available provider set pass `allowedProviders`; there is no build-time provider filtering. `config.customProviders` lists `providers.custom` entries as `{ id, clientKind }`; each registers after the built-ins through a registrar table that is exhaustive over `SupportedCustomClientKind`, independent of `allowedProviders`, with its client factory keyed by kind (look it up with `ProviderRegistry.getClientForProviderOrKind`).

## Dependencies

Expand Down
26 changes: 20 additions & 6 deletions memory-bank/credentialResolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ credentials, and refresh grants. A per-provider mutex and jittered
proactive-refresh window prevent duplicate renewal in one process. The store
backend adds a provider-scoped transaction around stored refresh: check, lock,
re-read, adopt another process's result when possible, otherwise refresh and
persist the rotated token. Environment M2M tokens never enter that transaction
persist the rotated token. The transaction notes the record's generation, and the
refreshed tokens or a refresh error persist only while the record still holds it,
so a refresh that another writer overtook writes nothing. Environment M2M tokens never enter that transaction
because their derived tokens live only in process memory.

### Refresh failure policy — terminal vs. transient
Expand All @@ -115,7 +117,7 @@ the `withRefreshTransaction` callback so a concurrent refresher cannot
overwrite the terminal record. Every other failure is _transient_ — network
errors, the 30s `AbortSignal.timeout` on the refresh exchange (so a hung fetch
cannot hold the cross-process file lock), 429/5xx, unknown 4xx codes, malformed
bodies, a rejected `persistRefreshedTokens`, or the transaction itself failing
bodies, a rejected token commit, or the transaction itself failing
(lock/IO) — and resolves as: return null, leave the stored record untouched,
and start a ~60s in-memory per-provider cooldown so status polling cannot
hammer the token endpoint during an outage. An expired cooldown is removed
Expand Down Expand Up @@ -145,15 +147,20 @@ M2M `clientCredentialsAuth`. Explicit stored credentials win over environment
credentials. With environment-only configuration, `DATABRICKS_TOKEN` wins
unless `DATABRICKS_AUTH_TYPE=oauth-m2m`; environment M2M requires
`DATABRICKS_HOST`, `DATABRICKS_CLIENT_ID`, and `DATABRICKS_CLIENT_SECRET`.
When `DATABRICKS_CONFIG_FILE` points at a `posit-workbench` path, the admin-managed
profile outranks every Databricks credential in the environment (`DATABRICKS_TOKEN`
and the M2M variables alike) and the environment resolves nothing.
Status exposes only source, origin, readiness, expiry, and sanitized workspace
metadata.

The Databricks entry in `PROVIDER_ENV_MAPPINGS` declares both PAT and M2M
names. `StoreBackend` reads M2M fields through that mapping, and
`captureProviderEnvironment` enumerates the same fields, so an authenticated
host cannot omit `DATABRICKS_CLIENT_SECRET` from its capture/scrub inventory.
The same single-source guarantee covers the Vertex ADC path
(`GOOGLE_APPLICATION_CREDENTIALS`) and the Azure SDK names (`AZURE_*`): both
The same single-source guarantee covers the Vertex credentials (the ADC path
`GOOGLE_APPLICATION_CREDENTIALS` and the inline service account
`GOOGLE_CLIENT_EMAIL`/`GOOGLE_PRIVATE_KEY`/`GOOGLE_PRIVATE_KEY_ID`), the Azure SDK
names (`AZURE_*`), and the Databricks `DATABRICKS_CONFIG_FILE` marker: all
are declared as `sdkCredentialEnvironment` descriptors on their provider
entries and consumed by the bridge exclusively through
`readSdkCredentialEnvironment`.
Expand Down Expand Up @@ -205,6 +212,11 @@ resolve without any host-application import:
`SUPPORTED_CUSTOM_CLIENT_KIND_VALUES ⊆ CLIENT_KIND_VALUES`. Custom
`anthropic`, `openai`, and `gemini` map to required `apikey` auth;
product-bound `positai`, `copilot`, and `databricks` remain excluded.
- **Custom-provider auth mapping and session tokens (`customProviderAuthMapping`,
`serializeSessionToken`)** — `customProviderAuthMapping` returns a custom
entry's mapping: the host's aggregate auth provider, with the entry name as the
scope. `serializeSessionToken` writes the Google Cloud and AWS session tokens in
the shape `shapeCredentials` reads back.

## On-disk format — `StoredProviderCredentials`

Expand Down Expand Up @@ -257,8 +269,10 @@ Every mapped field is an `EnvironmentFieldDescriptor` (`{ name, scrub }`), so
one declaration drives env resolution, host capture/scrubbing, and SDK
credential construction. `scrub: true` means captured AND deleted from the
ambient environment; `scrub: false` means captured only (the non-secret Azure
tenant/client IDs, which user code may legitimately read). Fields a provider
SDK reads directly are declared under `sdkCredentialEnvironment`, keyed by the
tenant/client IDs, which user code may legitimately read). Fields read outside
the API-key and OAuth mappings (by a provider SDK directly, or by a
credential-source check such as the Workbench Databricks marker) are declared
under `sdkCredentialEnvironment`, keyed by the
semantic fields of `SdkCredentialEnvironment` — a misspelled key is a compile
error, and a behavioral test proves every declared key is represented in the
reader's result.
Expand Down
18 changes: 14 additions & 4 deletions memory-bank/providerGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,13 @@ Providers whose auth comes from a cloud CLI / ambient identity (no stored
secret) carry a credential type with **no secret material** and let the cloud
SDK own the token lifecycle:

- `GoogleCloudCredentials` (`google-cloud`) — google-auth-library resolves ADC.
- `GoogleCloudCredentials` (`google-cloud`) — a brokered access token when the
host supplies one; otherwise an inline service account from
`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY` (optional `GOOGLE_PRIVATE_KEY_ID`)
when both are set; otherwise google-auth-library resolves ADC. Discovery and
chat use the same order. A rejected inline service account is an auth error
and is not retried against ADC; a transient token-service failure takes the
network-error path.
- `AzureEntraCredentials` (`azure-entra`) — `baseUrl` + required `scope` +
optional `tenantId`/`customHeaders`. `src/model-clients/azure-entra-token.ts`
caches `getBearerTokenProvider(new DefaultAzureCredential(...), scope)` per
Expand All @@ -279,9 +285,13 @@ Hosts that scrub credential variables pass a captured credential environment
through `ProviderRegistrationConfig`. The bridge never reads SDK wire names
directly: both providers call `readSdkCredentialEnvironment` from
`ai-credentials/store-backend`, which maps the ai-credentials
`sdkCredentialEnvironment` declaration onto a typed struct. Vertex supplies
the captured `googleApplicationCredentials` path to both model discovery's
`GoogleAuth` and the chat SDK's `googleAuthOptions`. Foundry materializes a
`sdkCredentialEnvironment` declaration onto a typed struct. Vertex decides its
credential source once, in `resolveGoogleVertexCredentialSource`
(`src/google-vertex-credentials.ts`): a brokered token, else the captured
inline service account, else ADC with the captured
`googleApplicationCredentials` path. Model discovery's token minting, the chat
SDK's `googleAuthOptions` and the auth-error guidance all switch on that one
source. Foundry materializes a
`ClientSecretCredential` or `ClientCertificateCredential` from the captured
Azure values; when neither is complete it retains `DefaultAzureCredential`
for managed identity and CLI sources. Because the names come from the
Expand Down
47 changes: 47 additions & 0 deletions packages/ai-config/src/__tests__/base-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";

import {
normalizeBaseUrlForProvider,
normalizeFoundryBaseUrl,
normalizeOpenRouterBaseUrl,
OPENROUTER_DEFAULT_BASE_URL,
} from "../base-url.js";
Expand Down Expand Up @@ -37,6 +38,52 @@ describe("normalizeBaseUrlForProvider", () => {
});
});

describe("normalizeFoundryBaseUrl", () => {
it.each([
[
"https://r.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01",
"https://r.openai.azure.com/openai/v1",
],
[
"https://r.openai.azure.com/openai/deployments/gpt-4o",
"https://r.openai.azure.com/openai/v1",
],
["https://r.openai.azure.com/openai/deployments/", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com/openai/deployments", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com/openai/v1", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com/openai/v1/", "https://r.openai.azure.com/openai/v1"],
[
"https://r.openai.azure.com/openai/v1/chat/completions",
"https://r.openai.azure.com/openai/v1",
],
[
"https://r.openai.azure.com/openai/v1/responses?api-version=preview",
"https://r.openai.azure.com/openai/v1",
],
["https://r.openai.azure.com/openai", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com/openai/", "https://r.openai.azure.com/openai/v1"],
["https://gateway.example/foundry", "https://gateway.example/foundry/openai/v1"],
["https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com?api-version=1", "https://r.openai.azure.com/openai/v1"],
["https://r.openai.azure.com/openai/v1#section", "https://r.openai.azure.com/openai/v1"],
[
"https://r.openai.azure.com/openai/v1/responses#frag?not-a-query",
"https://r.openai.azure.com/openai/v1",
],
["", ""],
[" ", ""],
])("normalizes %s", (input, expected) => {
expect(normalizeFoundryBaseUrl(input)).toBe(expected);
});

it("matches /openai/v1 only as a whole path segment", () => {
expect(normalizeFoundryBaseUrl("https://r.openai.azure.com/openai/v10/chat")).toMatch(
/^https:\/\/r\.openai\.azure\.com\/openai\/v10\/chat(\/|$)/,
);
});
});

describe("normalizeOpenRouterBaseUrl", () => {
it("defaults to the canonical API root", () => {
expect(normalizeOpenRouterBaseUrl()).toBe(OPENROUTER_DEFAULT_BASE_URL);
Expand Down
31 changes: 31 additions & 0 deletions packages/ai-config/src/__tests__/load-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,4 +951,35 @@ describe("loadResolvedProviderCatalog", () => {
expect((await loadProviderCatalogReport(opts)).issues).toEqual([]);
});
});

describe("hostDefaults", () => {
it("folds hostDefaults below the user file", async () => {
await fixture.writeTypedConfig({
providers: { positai: { positaiLogin: { host: "login.example.test" } } },
});
const catalog = await loadResolvedProviderCatalog({
configPath,
envVars: {},
hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } },
});
const positai = findProvider(catalog, "positai");
expect(positai?.connection.positaiLogin).toEqual({
host: "login.example.test",
clientId: "positron",
scope: "prism",
});
});

it("lets the user file override a hostDefaults value", async () => {
await fixture.writeTypedConfig({
providers: { positai: { positaiLogin: { clientId: "mine" } } },
});
const catalog = await loadResolvedProviderCatalog({
configPath,
envVars: {},
hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } },
});
expect(findProvider(catalog, "positai")?.connection.positaiLogin?.clientId).toBe("mine");
});
});
});
27 changes: 27 additions & 0 deletions packages/ai-config/src/__tests__/watch-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,4 +391,31 @@ describe("watchResolvedProviderCatalog", () => {
expect(probe.changes).toHaveLength(3);
expect(mockLogger.warn).toHaveBeenCalledTimes(warningsAfterAdd + 1);
});

it("keeps hostDefaults through a user-file rebuild", async () => {
await fixture.writeTypedConfigAtomic({
providers: { positai: { positaiLogin: { host: "login.example.test" } } },
});
const probe = createChangeProbe();
const watcher = watchResolvedProviderCatalog(probe.handler, {
configPath,
logger: mockLogger,
envVars: {},
hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } },
});
await awaitReady(watcher);

const changed = probe.next((change) => change.connectionChanged, "connection change");
await fixture.writeTypedConfigAtomic({
providers: { positai: { positaiLogin: { host: "login.other.test" } } },
});
const change = await changed;
watcher.dispose();

expect(change.catalog.find((p) => p.id === "positai")?.connection.positaiLogin).toEqual({
host: "login.other.test",
clientId: "positron",
scope: "prism",
});
});
});
36 changes: 36 additions & 0 deletions packages/ai-config/src/base-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,39 @@ export function normalizeBaseUrlForProvider(providerId: BuiltinProviderId, url:
}
return url;
}

const FOUNDRY_OPENAI_PATH = "/openai";
const FOUNDRY_V1_PATH = `${FOUNDRY_OPENAI_PATH}/v1`;
const FOUNDRY_DEPLOYMENTS_PATH = `${FOUNDRY_OPENAI_PATH}/deployments`;

/** Index of the first `path` in `url` that ends at a path-segment boundary, or -1. */
function pathSegmentIndex(url: string, path: string): number {
for (let i = url.indexOf(path); i !== -1; i = url.indexOf(path, i + 1)) {
const next = url.charAt(i + path.length);
if (next === "" || next === "/") return i;
}
return -1;
}

/**
* Normalize a Microsoft Foundry endpoint to its `/openai/v1` base URL: strips
* the query string, trailing slashes, any `/openai/deployments/...` suffix,
* any operation path after `/openai/v1` that users paste from the portal, and
* a bare trailing `/openai`, so the suffix is never doubled.
* Empty input stays empty.
*/
export function normalizeFoundryBaseUrl(rawUrl: string): string {
let url = rawUrl.trim();
if (!url) return "";
const suffixIndex = url.search(/[?#]/);
if (suffixIndex !== -1) url = url.substring(0, suffixIndex);
url = url.replace(/\/+$/, "");
if (!url) return "";
const deploymentIndex = pathSegmentIndex(url, FOUNDRY_DEPLOYMENTS_PATH);
if (deploymentIndex !== -1) url = url.substring(0, deploymentIndex);
const v1Index = pathSegmentIndex(url, FOUNDRY_V1_PATH);
if (v1Index !== -1) url = url.substring(0, v1Index + FOUNDRY_V1_PATH.length);
if (url.endsWith(FOUNDRY_OPENAI_PATH)) url = url.slice(0, -FOUNDRY_OPENAI_PATH.length);
if (!url.endsWith(FOUNDRY_V1_PATH)) url += FOUNDRY_V1_PATH;
return url;
}
16 changes: 16 additions & 0 deletions packages/ai-config/src/config-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import type { SourcedConfigIssue } from "./config-issue.js";
import type { ProviderConfigSource } from "./resolve-catalog.js";
import type { ProvidersConfigFragment } from "./types.js";

/**
* A resource that can be disposed.
Expand Down Expand Up @@ -45,3 +46,18 @@ export interface ProviderConfigSourceProvider {
/** Subscribe to change signals. Returns a disposable. Optional for static sources. */
watch?(onChange: () => void): Disposable;
}

/** The host's `hostDefaults` fragment as a static `default` source; none when the host passes none. */
export function createHostDefaultsSourceProviders(
hostDefaults: ProvidersConfigFragment | undefined,
): ProviderConfigSourceProvider[] {
if (!hostDefaults) return [];
return [
{
read: () => ({
source: { kind: "default", label: "host defaults", config: hostDefaults },
issues: [],
}),
},
];
}
2 changes: 2 additions & 0 deletions packages/ai-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
CLIENT_KIND_VALUES,
CUSTOM_KIND_API_KEY_OPTIONAL_DEFAULT,
isBuiltinProviderId,
isSupportedCustomClientKind,
PROTOCOL_VALUES,
RESERVED_PROVIDER_KEYS,
SUPPORTED_CUSTOM_CLIENT_KIND_VALUES,
Expand Down Expand Up @@ -179,6 +180,7 @@ export {
LMSTUDIO_API_VERSION,
LMSTUDIO_HOST,
normalizeBaseUrlForProvider,
normalizeFoundryBaseUrl,
normalizeOpenRouterBaseUrl,
OPENCODE_DEFAULT_PRODUCT,
OPENCODE_GO_BASE_URL,
Expand Down
8 changes: 6 additions & 2 deletions packages/ai-config/src/node/load-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { formatConfigIssue } from "../config-issue.js";
import type { SourcedConfigIssue } from "../config-issue.js";
import { createHostDefaultsSourceProviders } from "../config-source.js";
import { createLegacyPositronSourceProviders } from "../legacy-positron-settings/sources.js";
import { resolveProviderCatalogReport } from "../resolve-catalog.js";
import type { ResolvedProvider } from "../types.js";
Expand All @@ -29,8 +30,11 @@ export async function loadProviderCatalogReport(
env,
});

const legacyProviders = createLegacyPositronSourceProviders(opts, env);
reports.push(...(await Promise.all(legacyProviders.map((provider) => provider.read()))));
const extraProviders = [
...createLegacyPositronSourceProviders(opts, env),
...createHostDefaultsSourceProviders(opts.hostDefaults),
];
reports.push(...(await Promise.all(extraProviders.map((provider) => provider.read()))));

const loaded = reports.flatMap((report) => (report.source ? [report.source] : []));
const sources = opts.transformSource ? loaded.map(opts.transformSource) : loaded;
Expand Down
Loading
Loading