From 1fe52b1d2fa4d23008616ebbd3cff14858eb4c35 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 14 Jul 2026 11:58:22 -0400 Subject: [PATCH 01/29] Add ai-lib submodule and ai-config dependency to authentication --- .gitmodules | 3 +++ ai-lib | 1 + build/npm/dirs.ts | 1 + extensions/authentication/package-lock.json | 28 +++++++++++++++++++++ extensions/authentication/package.json | 4 +++ 5 files changed, 37 insertions(+) create mode 160000 ai-lib diff --git a/.gitmodules b/.gitmodules index c3517e7d345d..8268842dada0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "extensions/positron-r/ark"] path = extensions/positron-r/ark url = https://github.com/posit-dev/ark.git +[submodule "ai-lib"] + path = ai-lib + url = https://github.com/posit-dev/ai-lib.git diff --git a/ai-lib b/ai-lib new file mode 160000 index 000000000000..548bb4dc8c7f --- /dev/null +++ b/ai-lib @@ -0,0 +1 @@ +Subproject commit 548bb4dc8c7ff1b779a64ab98d625a81f088cf77 diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts index 7c435b08bfa8..6928498c7366 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -19,6 +19,7 @@ export let dirs = [ 'build/vite', 'extensions', // --- Start Positron --- + 'ai-lib/packages/ai-config', 'extensions/authentication', 'extensions/next-edit-suggestions', 'extensions/open-remote-ssh', diff --git a/extensions/authentication/package-lock.json b/extensions/authentication/package-lock.json index fb8806fc38ea..a7b834453d1e 100644 --- a/extensions/authentication/package-lock.json +++ b/extensions/authentication/package-lock.json @@ -7,9 +7,11 @@ "": { "name": "authentication", "version": "0.0.1", + "hasInstallScript": true, "dependencies": { "@aws-sdk/credential-providers": "^3.734.0", "@aws-sdk/types": "^3.734.0", + "ai-config": "file:../../ai-lib/packages/ai-config", "google-auth-library": "^9.15.1" }, "devDependencies": { @@ -19,6 +21,28 @@ "vscode": "^1.108.0" } }, + "../../ai-lib/packages/ai-config": { + "version": "0.0.1", + "dependencies": { + "proper-lockfile": "^4.1.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "22.x", + "@types/proper-lockfile": "^4.1.4", + "@types/vscode": "^1.100.0", + "typescript": "^7.0.2", + "vitest": "^3.2.1" + }, + "peerDependencies": { + "vscode": "^1.100.0" + }, + "peerDependenciesMeta": { + "vscode": { + "optional": true + } + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -1271,6 +1295,10 @@ "node": ">= 14" } }, + "node_modules/ai-config": { + "resolved": "../../ai-lib/packages/ai-config", + "link": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index d166de7056f8..f1a4e8750585 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -24,9 +24,13 @@ "onCommand:authentication.migrateApiKey" ], "main": "./out/extension.js", + "scripts": { + "postinstall": "npm --prefix ../../ai-lib/packages/ai-config run build" + }, "dependencies": { "@aws-sdk/credential-providers": "^3.734.0", "@aws-sdk/types": "^3.734.0", + "ai-config": "file:../../ai-lib/packages/ai-config", "google-auth-library": "^9.15.1" }, "devDependencies": { From 42f9977d66da58de04e55c30432522aa732b1666 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 14 Jul 2026 13:19:02 -0400 Subject: [PATCH 02/29] Map provider settings to a providers.json config --- .../src/migration/providersJson.ts | 172 ++++++++++++++++++ .../src/test/providersJsonMapping.test.ts | 82 +++++++++ 2 files changed, 254 insertions(+) create mode 100644 extensions/authentication/src/migration/providersJson.ts create mode 100644 extensions/authentication/src/test/providersJsonMapping.test.ts diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts new file mode 100644 index 000000000000..ac47383e489e --- /dev/null +++ b/extensions/authentication/src/migration/providersJson.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ProvidersConfig } from 'ai-config/node'; +import { normalizeToV1Url } from '../validation'; + +/** Reads one setting's explicitly-set GLOBAL value (undefined when unset). */ +export interface MigrationSettingsReader { + globalValue(key: string): T | undefined; +} + +export interface MappedProvidersConfig { + config: ProvidersConfig; + /** Number of settings.json entries consumed (for the success toast). */ + settingCount: number; +} + +interface ApiKeyConnectionSetting { + readonly configKey: string; + readonly providerId: string; + readonly normalizeBaseUrl?: (url: string) => string; +} + +/** authentication..{baseUrl,customHeaders} -> providers.. */ +const API_KEY_CONNECTION_SETTINGS: readonly ApiKeyConnectionSetting[] = [ + { configKey: 'anthropic', providerId: 'anthropic' }, + { configKey: 'openai-api', providerId: 'openai' }, + { configKey: 'google', providerId: 'gemini' }, + { configKey: 'deepseek-api', providerId: 'deepseek' }, + { configKey: 'foundry', providerId: 'ms-foundry', normalizeBaseUrl: normalizeToV1Url }, + { configKey: 'openai-compatible', providerId: 'openai-compatible' }, + { configKey: 'googleVertex', providerId: 'google-vertex' }, +]; + +interface EnablementSetting { + readonly providerId: string; + /** positron.assistant.provider..enable (older generation). */ + readonly oldKey?: string; + /** assistant.provider..enabled (newer generation; wins when both set). */ + readonly newKey?: string; +} + +const ENABLEMENT_SETTINGS: readonly EnablementSetting[] = [ + { providerId: 'anthropic', oldKey: 'positron.assistant.provider.anthropic.enable' }, + { providerId: 'openai', oldKey: 'positron.assistant.provider.openAI.enable' }, + { providerId: 'gemini', oldKey: 'positron.assistant.provider.google.enable' }, + { providerId: 'bedrock', oldKey: 'positron.assistant.provider.amazonBedrock.enable' }, + { providerId: 'snowflake-cortex', oldKey: 'positron.assistant.provider.snowflakeCortex.enable' }, + { providerId: 'ms-foundry', oldKey: 'positron.assistant.provider.msFoundry.enable' }, + { providerId: 'openai-compatible', oldKey: 'positron.assistant.provider.customProvider.enable' }, + { providerId: 'positai', oldKey: 'positron.assistant.provider.positAI.enable' }, + { providerId: 'copilot', oldKey: 'positron.assistant.provider.githubCopilot.enable' }, + { providerId: 'google-vertex', newKey: 'assistant.provider.googleVertex.enabled' }, + { providerId: 'deepseek', newKey: 'assistant.provider.deepseek.enabled' }, +]; + +/** + * Every setting the migration consumes; Task 3's hasMigratableSettings scans + * this. Task 8 appends the positron.assistant.models.overrides. keys + * when model-overrides migration lands. + */ +export const MIGRATABLE_SETTING_KEYS: readonly string[] = [ + ...API_KEY_CONNECTION_SETTINGS.flatMap(s => [ + `authentication.${s.configKey}.baseUrl`, + `authentication.${s.configKey}.customHeaders`, + ]), + 'authentication.googleVertex.credentials', + 'authentication.aws.credentials', + 'authentication.snowflake.credentials', + 'authentication.snowflake.customHeaders', + ...ENABLEMENT_SETTINGS.flatMap(s => [s.oldKey, s.newKey]).filter((k): k is string => !!k), +]; + +// providers.json blocks are plain data; build them as records and assign into +// the (structurally typed) ProvidersConfig at the end. +type Block = Record; + +export function buildProvidersConfigFromSettings( + reader: MigrationSettingsReader +): MappedProvidersConfig | undefined { + const providers: Record = {}; + let settingCount = 0; + + const merge = (providerId: string, fragment: Block) => { + providers[providerId] = { ...providers[providerId], ...fragment }; + }; + + // --- authentication..{baseUrl,customHeaders} ----------------- + for (const s of API_KEY_CONNECTION_SETTINGS) { + const rawBaseUrl = nonEmptyString(reader.globalValue(`authentication.${s.configKey}.baseUrl`)); + if (rawBaseUrl) { + merge(s.providerId, { baseUrl: s.normalizeBaseUrl ? s.normalizeBaseUrl(rawBaseUrl) : rawBaseUrl }); + settingCount++; + } + const headers = nonEmptyHeaders(reader.globalValue>(`authentication.${s.configKey}.customHeaders`)); + if (headers) { + merge(s.providerId, { customHeaders: headers }); + settingCount++; + } + } + + // --- authentication.googleVertex.credentials -> googleCloud ------------- + const vertexCreds = reader.globalValue>('authentication.googleVertex.credentials'); + const googleCloud: Block = {}; + if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_PROJECT)) { + googleCloud.project = vertexCreds!.GOOGLE_VERTEX_PROJECT; + } + if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_LOCATION)) { + googleCloud.location = vertexCreds!.GOOGLE_VERTEX_LOCATION; + } + if (Object.keys(googleCloud).length > 0) { + merge('google-vertex', { googleCloud }); + settingCount++; + } + + // --- authentication.aws.credentials -> bedrock.aws ----------------------- + const awsCreds = reader.globalValue>('authentication.aws.credentials'); + const aws: Block = {}; + if (nonEmptyString(awsCreds?.AWS_PROFILE)) { + aws.profile = awsCreds!.AWS_PROFILE; + } + if (nonEmptyString(awsCreds?.AWS_REGION)) { + aws.region = awsCreds!.AWS_REGION; + } + if (Object.keys(aws).length > 0) { + merge('bedrock', { aws }); + settingCount++; + } + + // --- authentication.snowflake.* -> snowflake-cortex ---------------------- + // SNOWFLAKE_HOME migrates in a later task once ai-lib ships snowflake.home + // (posit-dev/ai-lib#8); only SNOWFLAKE_ACCOUNT maps here. + const snowflakeCreds = reader.globalValue>('authentication.snowflake.credentials'); + if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_ACCOUNT)) { + merge('snowflake-cortex', { snowflake: { account: snowflakeCreds!.SNOWFLAKE_ACCOUNT } }); + settingCount++; + } + const snowflakeHeaders = nonEmptyHeaders(reader.globalValue>('authentication.snowflake.customHeaders')); + if (snowflakeHeaders) { + merge('snowflake-cortex', { customHeaders: snowflakeHeaders }); + settingCount++; + } + + // --- enablement toggles -> providers..enabled ------------------------ + for (const s of ENABLEMENT_SETTINGS) { + const newValue = s.newKey ? reader.globalValue(s.newKey) : undefined; + const oldValue = s.oldKey ? reader.globalValue(s.oldKey) : undefined; + const enabled = newValue ?? oldValue; + if (enabled !== undefined) { + merge(s.providerId, { enabled }); + settingCount++; + } + } + + if (Object.keys(providers).length === 0) { + return undefined; + } + return { + config: { providers } as ProvidersConfig, + settingCount, + }; +} + +function nonEmptyString(value: string | undefined): string | undefined { + return value && value.trim() !== '' ? value : undefined; +} + +function nonEmptyHeaders(headers: Record | undefined): Record | undefined { + return headers && Object.keys(headers).length > 0 ? headers : undefined; +} diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts new file mode 100644 index 000000000000..a5035e82e303 --- /dev/null +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { buildProvidersConfigFromSettings, MIGRATABLE_SETTING_KEYS, MigrationSettingsReader } from '../migration/providersJson'; + +function readerOf(values: Record): MigrationSettingsReader { + return { globalValue: (key: string) => values[key] as T | undefined }; +} + +suite('buildProvidersConfigFromSettings', () => { + test('returns undefined when nothing is set', () => { + assert.strictEqual(buildProvidersConfigFromSettings(readerOf({})), undefined); + }); + + test('maps connection settings to provider blocks', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'authentication.anthropic.baseUrl': 'https://gateway.example.com', + 'authentication.anthropic.customHeaders': { 'x-team': 'data-science' }, + 'authentication.openai-api.baseUrl': 'https://openai.example.com', + })); + assert.deepStrictEqual(result?.config.providers?.anthropic, { + baseUrl: 'https://gateway.example.com', + customHeaders: { 'x-team': 'data-science' }, + }); + assert.deepStrictEqual(result?.config.providers?.openai, { baseUrl: 'https://openai.example.com' }); + assert.strictEqual(result?.settingCount, 3); + }); + + test('normalizes the foundry base URL', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'authentication.foundry.baseUrl': 'https://my-resource.services.ai.azure.com', + })); + // normalizeToV1Url appends the versioned path; assert against its real output. + assert.ok(result?.config.providers?.['ms-foundry']?.baseUrl?.startsWith('https://my-resource.services.ai.azure.com')); + assert.notStrictEqual(result?.config.providers?.['ms-foundry']?.baseUrl, 'https://my-resource.services.ai.azure.com'); + }); + + test('omits empty strings and empty header maps', () => { + assert.strictEqual(buildProvidersConfigFromSettings(readerOf({ + 'authentication.anthropic.baseUrl': '', + 'authentication.anthropic.customHeaders': {}, + })), undefined); + }); + + test('maps grouped credential settings to their sections', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'authentication.aws.credentials': { AWS_PROFILE: 'default', AWS_REGION: 'us-east-1' }, + 'authentication.googleVertex.credentials': { GOOGLE_VERTEX_PROJECT: 'my-project', GOOGLE_VERTEX_LOCATION: 'us-central1' }, + 'authentication.snowflake.credentials': { SNOWFLAKE_ACCOUNT: 'MYORG-MYACCT', SNOWFLAKE_HOME: '/tmp/snow' }, + })); + assert.deepStrictEqual(result?.config.providers?.bedrock, { aws: { profile: 'default', region: 'us-east-1' } }); + assert.deepStrictEqual(result?.config.providers?.['google-vertex'], { googleCloud: { project: 'my-project', location: 'us-central1' } }); + // SNOWFLAKE_HOME is NOT migrated yet (posit-dev/ai-lib#8). + assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT' } }); + }); + + test('maps enablement toggles with the newer generation winning', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'positron.assistant.provider.anthropic.enable': false, + 'positron.assistant.provider.google.enable': true, + 'assistant.provider.deepseek.enabled': false, + })); + assert.strictEqual(result?.config.providers?.anthropic?.enabled, false); + assert.strictEqual(result?.config.providers?.gemini?.enabled, true); + assert.strictEqual(result?.config.providers?.deepseek?.enabled, false); + }); + + test('MIGRATABLE_SETTING_KEYS covers a spot-check of each family', () => { + for (const key of [ + 'authentication.anthropic.baseUrl', + 'authentication.aws.credentials', + 'authentication.snowflake.customHeaders', + 'positron.assistant.provider.githubCopilot.enable', + 'assistant.provider.googleVertex.enabled', + ]) { + assert.ok(MIGRATABLE_SETTING_KEYS.includes(key), `missing ${key}`); + } + }); +}); From 66057275e31064265d70f71c4842b14cd3205818 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 14 Jul 2026 15:59:17 -0400 Subject: [PATCH 03/29] Add one-shot providers.json migration with overwrite mode --- .../src/migration/migrateToProvidersJson.ts | 93 +++++++++++++++++++ .../src/test/migrateToProvidersJson.test.ts | 82 ++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 extensions/authentication/src/migration/migrateToProvidersJson.ts create mode 100644 extensions/authentication/src/test/migrateToProvidersJson.test.ts diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts new file mode 100644 index 000000000000..6aa79c645fcf --- /dev/null +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { log } from '../log'; +import { + buildProvidersConfigFromSettings, + MIGRATABLE_SETTING_KEYS, + MigrationSettingsReader, +} from './providersJson'; + +export type MigrationResult = + | { outcome: 'migrated'; settingCount: number } + | { outcome: 'skipped-populated' } + | { outcome: 'nothing-to-migrate' }; + +export interface RunMigrationOptions { + /** Replace a populated providers block instead of skipping (manual command only). */ + overwrite: boolean; + /** Override providers.json path (tests). */ + configPath?: string; + /** Override the settings source (tests). */ + reader?: MigrationSettingsReader; +} + +/** Reads explicitly-set GLOBAL values only; defaults and workspace scopes are ignored. */ +export function createGlobalSettingsReader(): MigrationSettingsReader { + return { + globalValue: (key: string) => + vscode.workspace.getConfiguration().inspect(key)?.globalValue, + }; +} + +export function hasMigratableSettings( + reader: MigrationSettingsReader = createGlobalSettingsReader() +): boolean { + return MIGRATABLE_SETTING_KEYS.some(key => reader.globalValue(key) !== undefined); +} + +/** + * True when the user's providers.json file already carries provider config. + * Reads through ai-config's source assembly (never raw fs) so file location, + * JSONC handling, and validation fallbacks stay in one place. + */ +export async function userProvidersFileIsPopulated(configPath?: string): Promise { + const { loadConfigSources } = await import('ai-config/node'); + const sources = await loadConfigSources({ + configPath, + logger: { debug: (m: string) => log.debug(m), warn: (m: string) => log.warn(m) }, + }); + const userSource = sources.find(source => source.kind === 'user'); + const providers = userSource?.config.providers; + return !!providers && Object.keys(providers).length > 0; +} + +/** + * One-shot migration: writes the mapped config through mutateProvidersConfig. + * The populated-file guard lives INSIDE the mutator, so the check and the + * write happen under ai-config's cross-process lock. + */ +export async function runMigration(opts: RunMigrationOptions): Promise { + const reader = opts.reader ?? createGlobalSettingsReader(); + const mapped = buildProvidersConfigFromSettings(reader); + if (!mapped) { + log.info('[migration] No provider settings to migrate'); + return { outcome: 'nothing-to-migrate' }; + } + + const { mutateProvidersConfig } = await import('ai-config/node'); + let skippedPopulated = false; + await mutateProvidersConfig( + current => { + if (!opts.overwrite && current.providers && Object.keys(current.providers).length > 0) { + skippedPopulated = true; + return current; + } + return { ...current, providers: mapped.config.providers }; + }, + { + configPath: opts.configPath, + logger: { debug: (m: string) => log.debug(m), warn: (m: string) => log.warn(m) }, + } + ); + + if (skippedPopulated) { + log.info('[migration] providers.json already has provider config; skipped'); + return { outcome: 'skipped-populated' }; + } + log.info(`[migration] Migrated ${mapped.settingCount} setting(s) to providers.json`); + return { outcome: 'migrated', settingCount: mapped.settingCount }; +} diff --git a/extensions/authentication/src/test/migrateToProvidersJson.test.ts b/extensions/authentication/src/test/migrateToProvidersJson.test.ts new file mode 100644 index 000000000000..328711e84eb7 --- /dev/null +++ b/extensions/authentication/src/test/migrateToProvidersJson.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + hasMigratableSettings, + runMigration, + userProvidersFileIsPopulated, +} from '../migration/migrateToProvidersJson'; +import { MigrationSettingsReader } from '../migration/providersJson'; + +function readerOf(values: Record): MigrationSettingsReader { + return { globalValue: (key: string) => values[key] as T | undefined }; +} + +suite('migrateToProvidersJson', () => { + let dir: string; + let configPath: string; + + setup(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'providers-json-migration-')); + configPath = path.join(dir, 'providers.json'); + }); + + teardown(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('hasMigratableSettings is false for empty settings and true for any migratable key', () => { + assert.strictEqual(hasMigratableSettings(readerOf({})), false); + assert.strictEqual(hasMigratableSettings(readerOf({ 'authentication.anthropic.baseUrl': 'https://x' })), true); + }); + + test('nothing to migrate leaves no file behind', async () => { + const result = await runMigration({ overwrite: false, configPath, reader: readerOf({}) }); + assert.deepStrictEqual(result, { outcome: 'nothing-to-migrate' }); + assert.strictEqual(fs.existsSync(configPath), false); + }); + + test('first migration writes the mapped config with seed metadata', async () => { + const result = await runMigration({ + overwrite: false, + configPath, + reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://gateway.example.com' }), + }); + assert.deepStrictEqual(result, { outcome: 'migrated', settingCount: 1 }); + const written = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(written.providers.anthropic.baseUrl, 'https://gateway.example.com'); + assert.strictEqual(written.version, 1); + assert.strictEqual(await userProvidersFileIsPopulated(configPath), true); + }); + + test('a populated file is skipped without overwrite', async () => { + await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://first' }) }); + const result = await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://second' }) }); + assert.deepStrictEqual(result, { outcome: 'skipped-populated' }); + const written = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(written.providers.anthropic.baseUrl, 'https://first'); + }); + + test('overwrite replaces the providers block', async () => { + await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://first' }) }); + const result = await runMigration({ + overwrite: true, + configPath, + reader: readerOf({ 'authentication.openai-api.baseUrl': 'https://second' }), + }); + assert.deepStrictEqual(result, { outcome: 'migrated', settingCount: 1 }); + const written = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(written.providers.anthropic, undefined); + assert.strictEqual(written.providers.openai.baseUrl, 'https://second'); + }); + + test('userProvidersFileIsPopulated is false for a missing file', async () => { + assert.strictEqual(await userProvidersFileIsPopulated(configPath), false); + }); +}); From 757ee2c7e29aabf16b038b70e3b5d321e75361e7 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 09:16:25 -0400 Subject: [PATCH 04/29] Bump ai-lib for nodenext-compatible ai-config dist --- ai-lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-lib b/ai-lib index 548bb4dc8c7f..f93769910423 160000 --- a/ai-lib +++ b/ai-lib @@ -1 +1 @@ -Subproject commit 548bb4dc8c7ff1b779a64ab98d625a81f088cf77 +Subproject commit f937699104230606abbed03401ec6f5a50fdf468 From 584a2a16398bc42a74204c1021780384342a18d6 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 09:31:30 -0400 Subject: [PATCH 05/29] Build ai-config with tsc directly to avoid its tsx prebuild --- extensions/authentication/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index f1a4e8750585..50b5d1c7d7e6 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -25,7 +25,7 @@ ], "main": "./out/extension.js", "scripts": { - "postinstall": "npm --prefix ../../ai-lib/packages/ai-config run build" + "postinstall": "node -e \"require('fs').rmSync('../../ai-lib/packages/ai-config/tsconfig.tsbuildinfo',{force:true})\" && npm --prefix ../../ai-lib/packages/ai-config exec --no -- tsc -b ../../ai-lib/packages/ai-config" }, "dependencies": { "@aws-sdk/credential-providers": "^3.734.0", From 06ee8bcea6eca9e7d838d053fe2a7cb0ede0cad5 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 10:07:05 -0400 Subject: [PATCH 06/29] Add providers.json migration command and first-launch prompt --- extensions/authentication/package.json | 5 + extensions/authentication/package.nls.json | 1 + extensions/authentication/src/extension.ts | 2 + .../src/migration/providersJsonUi.ts | 112 ++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 extensions/authentication/src/migration/providersJsonUi.ts diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 50b5d1c7d7e6..17be7d7a8de4 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -84,6 +84,11 @@ "command": "authentication.configureProviders", "title": "%commands.configureProviders.title%", "category": "%commands.category%" + }, + { + "command": "authentication.migrateSettingsToProvidersJson", + "title": "%commands.migrateSettingsToProvidersJson.title%", + "category": "%commands.category%" } ], "configuration": { diff --git a/extensions/authentication/package.nls.json b/extensions/authentication/package.nls.json index cf7541f2b429..2a9c50995d0d 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -40,5 +40,6 @@ "configuration.aiExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from AI features. Files matching these patterns will not have their contents sent to AI providers for inline completions or chat context. Patterns without a `/` will match against the filename only (e.g., `*.py` matches all Python files). Use `**/` prefix for explicit recursive matching.", "configuration.models.include.description": "Enforce strict restrictions on which language models are available. When configured, **only models matching these patterns will be available**\u2014other models will be excluded entirely.\n\nMatching is case-insensitive and supports wildcards (`*`) and regular expressions for advanced filtering.\n\n**Note:** This setting does not affect GitHub Copilot models.\n\nExamples:\n- `claude`: Only Claude models are available\n- `GPT-4o`: Only this specific model is available\n- `^claude.*opus`: Only Claude Opus models (matched by regex) are available", "commands.configureProviders.title": "Configure Language Model Providers", + "commands.migrateSettingsToProvidersJson.title": "Migrate AI Provider Settings to providers.json", "commands.category": "Authentication" } diff --git a/extensions/authentication/src/extension.ts b/extensions/authentication/src/extension.ts index c43ea047fe77..d51907924fea 100644 --- a/extensions/authentication/src/extension.ts +++ b/extensions/authentication/src/extension.ts @@ -44,6 +44,7 @@ import { log } from './log'; import { migrateAwsSettings } from './migration/aws'; import { migrateSnowflakeSettings } from './migration/snowflake'; import { registerMigrateApiKeyCommand } from './migration/apiKey'; +import { registerProvidersJsonMigration } from './migration/providersJsonUi'; import { AuthProviderLogger } from './authProviderLogger'; import { applyPwbPositAIDefault } from './pwbDefaults'; @@ -143,6 +144,7 @@ export async function activate(context: vscode.ExtensionContext) { ), ); registerMigrateApiKeyCommand(context); + registerProvidersJsonMigration(context); return { getLogs: () => log.formatEntriesForDiagnostics() }; } diff --git a/extensions/authentication/src/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts new file mode 100644 index 000000000000..5078725fafd1 --- /dev/null +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { log } from '../log'; +import { + hasMigratableSettings, + MigrationResult, + runMigration, + userProvidersFileIsPopulated, +} from './migrateToProvidersJson'; + +export const MIGRATE_COMMAND_ID = 'authentication.migrateSettingsToProvidersJson'; +const PROMPT_DISMISSED_KEY = 'providersJsonMigrationPromptDismissed'; + +/** Registers the migration command and schedules the one-time prompt. */ +export function registerProvidersJsonMigration(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand(MIGRATE_COMMAND_ID, () => runMigrationCommand()) + ); + // Fire-and-forget: never block activation on the prompt. + maybePromptForMigration(context).catch(err => + log.error(`providers.json migration prompt failed: ${err}`) + ); +} + +async function runMigrationCommand(): Promise { + if (!hasMigratableSettings()) { + vscode.window.showInformationMessage( + vscode.l10n.t('No provider settings to migrate.') + ); + return; + } + + let overwrite = false; + if (await userProvidersFileIsPopulated()) { + const overwriteAction = vscode.l10n.t('Overwrite'); + const choice = await vscode.window.showWarningMessage( + vscode.l10n.t('~/.posit/ai/providers.json already contains provider configuration that Positron is using. Overwrite it with the values from your Positron settings?'), + { modal: true }, + overwriteAction + ); + if (choice !== overwriteAction) { + return; + } + overwrite = true; + } + + await migrateAndReport({ overwrite }); +} + +async function maybePromptForMigration(context: vscode.ExtensionContext): Promise { + if (context.globalState.get(PROMPT_DISMISSED_KEY)) { + return; + } + if (!hasMigratableSettings()) { + return; + } + if (await userProvidersFileIsPopulated()) { + // Self-extinguishing: once providers.json is populated by any means, + // the prompt never shows again. + return; + } + + const migrateAction = vscode.l10n.t('Migrate'); + const dontAskAction = vscode.l10n.t("Don't Ask Again"); + const choice = await vscode.window.showInformationMessage( + vscode.l10n.t('Positron now stores AI provider and model configuration in ~/.posit/ai/providers.json. Migrate your settings to keep your providers and models working. Your existing settings will not be removed.'), + migrateAction, + dontAskAction + ); + + if (choice === migrateAction) { + await migrateAndReport({ overwrite: false }); + } else if (choice === dontAskAction) { + await context.globalState.update(PROMPT_DISMISSED_KEY, true); + } + // Plain dismissal: no flag, re-prompt on a later launch. +} + +async function migrateAndReport(opts: { overwrite: boolean }): Promise { + let result: MigrationResult; + try { + result = await runMigration(opts); + } catch (err) { + log.error(`providers.json migration failed: ${err}`); + vscode.window.showErrorMessage( + vscode.l10n.t('Failed to migrate provider settings: {0}. No changes were made to your settings.', String(err)) + ); + return; + } + + switch (result.outcome) { + case 'migrated': + vscode.window.showInformationMessage( + vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron reads provider configuration from this file; your original settings were not removed.', result.settingCount) + ); + break; + case 'skipped-populated': + vscode.window.showInformationMessage( + vscode.l10n.t('providers.json already contains provider configuration; nothing was migrated.') + ); + break; + case 'nothing-to-migrate': + vscode.window.showInformationMessage( + vscode.l10n.t('No provider settings to migrate.') + ); + break; + } +} From 65a8cb1c61463d8a8036c52be84445fb0d312cf9 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 10:11:37 -0400 Subject: [PATCH 07/29] Deprecate provider settings migrated to providers.json --- extensions/authentication/package.json | 29 ++++++++++++++++++++++ extensions/authentication/package.nls.json | 2 ++ 2 files changed, 31 insertions(+) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 17be7d7a8de4..4f4d9d3caf97 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -95,6 +95,7 @@ "title": "Authentication", "properties": { "authentication.aws.credentials": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.aws.credentials.description%", @@ -111,6 +112,7 @@ "additionalProperties": false }, "authentication.aws.inferenceProfileRegion": { + "markdownDeprecationMessage": "%configuration.deprecation.inferenceProfileRegion%", "type": "string", "default": null, "markdownDescription": "%configuration.aws.inferenceProfileRegion.description%" @@ -132,6 +134,7 @@ "additionalProperties": false }, "authentication.snowflake.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -143,11 +146,13 @@ ] }, "authentication.anthropic.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.anthropic.baseUrl.description%" }, "authentication.anthropic.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -159,11 +164,13 @@ ] }, "authentication.foundry.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.foundry.baseUrl.description%" }, "authentication.foundry.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -175,11 +182,13 @@ ] }, "authentication.openai-api.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.openai-api.baseUrl.description%" }, "authentication.openai-api.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -191,6 +200,7 @@ ] }, "authentication.google.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.google.baseUrl.description%", @@ -200,6 +210,7 @@ ] }, "authentication.google.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -212,6 +223,7 @@ ] }, "authentication.googleVertex.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.googleVertex.baseUrl.description%", @@ -220,6 +232,7 @@ ] }, "authentication.googleVertex.credentials": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.googleVertex.credentials.description%", @@ -239,6 +252,7 @@ ] }, "authentication.googleVertex.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -250,6 +264,7 @@ ] }, "authentication.openai-compatible.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.openai-compatible.baseUrl.description%", @@ -258,6 +273,7 @@ ] }, "authentication.openai-compatible.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -269,6 +285,7 @@ ] }, "authentication.deepseek-api.baseUrl": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.deepseek-api.baseUrl.description%", @@ -277,6 +294,7 @@ ] }, "authentication.deepseek-api.customHeaders": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -288,6 +306,7 @@ ] }, "assistant.provider.googleVertex.enabled": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.googleVertex.enabled.description%", @@ -296,6 +315,7 @@ ] }, "assistant.provider.deepseek.enabled": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.deepseek.enabled.description%", @@ -304,12 +324,14 @@ ] }, "positron.assistant.provider.anthropic.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.anthropic.enable.description%", "order": 1 }, "positron.assistant.provider.githubCopilot.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.githubCopilot.enable.description%", @@ -319,30 +341,35 @@ "order": 2 }, "positron.assistant.provider.amazonBedrock.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.amazonBedrock.enable.description%", "order": 3 }, "positron.assistant.provider.snowflakeCortex.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.snowflakeCortex.enable.description%", "order": 4 }, "positron.assistant.provider.msFoundry.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.msFoundry.enable.description%", "order": 5 }, "positron.assistant.provider.openAI.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.openAI.enable.description%", "order": 6 }, "positron.assistant.provider.customProvider.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.customProvider.enable.description%", @@ -352,12 +379,14 @@ "order": 7 }, "positron.assistant.provider.positAI.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.positAI.enable.description%", "order": 8 }, "positron.assistant.provider.google.enable": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.google.enable.description%", diff --git a/extensions/authentication/package.nls.json b/extensions/authentication/package.nls.json index 2a9c50995d0d..54c03db8e0a8 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -39,6 +39,8 @@ "configuration.models.overrides.google.description": "Model overrides for Google Gemini provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.aiExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from AI features. Files matching these patterns will not have their contents sent to AI providers for inline completions or chat context. Patterns without a `/` will match against the filename only (e.g., `*.py` matches all Python files). Use `**/` prefix for explicit recursive matching.", "configuration.models.include.description": "Enforce strict restrictions on which language models are available. When configured, **only models matching these patterns will be available**\u2014other models will be excluded entirely.\n\nMatching is case-insensitive and supports wildcards (`*`) and regular expressions for advanced filtering.\n\n**Note:** This setting does not affect GitHub Copilot models.\n\nExamples:\n- `claude`: Only Claude models are available\n- `GPT-4o`: Only this specific model is available\n- `^claude.*opus`: Only Claude Opus models (matched by regex) are available", + "configuration.deprecation.providersJson": "Deprecated: Positron reads AI provider and model configuration from `~/.posit/ai/providers.json`, and values there take precedence over this setting. Run the **Migrate AI Provider Settings to providers.json** command to keep your providers and models working.", + "configuration.deprecation.inferenceProfileRegion": "Deprecated: this setting is no longer read. Inference profile regions are derived from the AWS region. To use an explicit inference profile ID, define a custom model in `~/.posit/ai/providers.json`.", "commands.configureProviders.title": "Configure Language Model Providers", "commands.migrateSettingsToProvidersJson.title": "Migrate AI Provider Settings to providers.json", "commands.category": "Authentication" From b5c23773cddaee91939542fe57cbca4c6def34ef Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 11:01:21 -0400 Subject: [PATCH 08/29] Remove positron.assistant.models.include setting --- extensions/authentication/package.json | 18 ------------------ extensions/authentication/package.nls.json | 1 - .../contrib/chat/common/languageModels.ts | 5 +---- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 4f4d9d3caf97..c982dd453584 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -741,24 +741,6 @@ "items": { "type": "string" } - }, - "positron.assistant.models.include": { - "type": "array", - "default": [], - "markdownDescription": "%configuration.models.include.description%", - "items": { - "type": "string" - }, - "examples": [ - [ - "claude", - "gpt" - ], - [ - "Claude Sonnet 4.5", - "GPT-4o" - ] - ] } } } diff --git a/extensions/authentication/package.nls.json b/extensions/authentication/package.nls.json index 54c03db8e0a8..f4ee4415b823 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -38,7 +38,6 @@ "configuration.models.overrides.msFoundry.description": "Model overrides for Microsoft Foundry provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.models.overrides.google.description": "Model overrides for Google Gemini provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.aiExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from AI features. Files matching these patterns will not have their contents sent to AI providers for inline completions or chat context. Patterns without a `/` will match against the filename only (e.g., `*.py` matches all Python files). Use `**/` prefix for explicit recursive matching.", - "configuration.models.include.description": "Enforce strict restrictions on which language models are available. When configured, **only models matching these patterns will be available**\u2014other models will be excluded entirely.\n\nMatching is case-insensitive and supports wildcards (`*`) and regular expressions for advanced filtering.\n\n**Note:** This setting does not affect GitHub Copilot models.\n\nExamples:\n- `claude`: Only Claude models are available\n- `GPT-4o`: Only this specific model is available\n- `^claude.*opus`: Only Claude Opus models (matched by regex) are available", "configuration.deprecation.providersJson": "Deprecated: Positron reads AI provider and model configuration from `~/.posit/ai/providers.json`, and values there take precedence over this setting. Run the **Migrate AI Provider Settings to providers.json** command to keep your providers and models working.", "configuration.deprecation.inferenceProfileRegion": "Deprecated: this setting is no longer read. Inference profile regions are derived from the AWS region. To use an explicit inference profile ID, define a custom model in `~/.posit/ai/providers.json`.", "commands.configureProviders.title": "Configure Language Model Providers", diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index a8abc31a177b..698cdc481044 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -920,10 +920,7 @@ export class LanguageModelsService implements ILanguageModelsService { // Listen for changes to model configuration. The initial filtering and configuration // is done in the Positron Assistant extension when models are resolved. this._store.add(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('positron.assistant.models.include')) { - this._logService.trace('[LM] Included models configuration changed, re-resolving language models'); - this._reResolveLanguageModels(); - } else if (e.affectsConfiguration('positron.assistant.models.overrides')) { + if (e.affectsConfiguration('positron.assistant.models.overrides')) { this._logService.trace('[LM] Model overrides configuration changed, re-resolving language models'); this._reResolveLanguageModels(); } else if (e.affectsConfiguration('positron.assistant.models.preference')) { From 7983e1aca4882c9b81358292bdeb083fe7ba50ef Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 11:28:42 -0400 Subject: [PATCH 09/29] Fix migration guards for invalid files and empty settings --- .../src/migration/migrateToProvidersJson.ts | 56 ++++++++++++++----- .../src/test/migrateToProvidersJson.test.ts | 44 +++++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index 6aa79c645fcf..763b783fb8aa 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -3,11 +3,11 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; import * as vscode from 'vscode'; import { log } from '../log'; import { buildProvidersConfigFromSettings, - MIGRATABLE_SETTING_KEYS, MigrationSettingsReader, } from './providersJson'; @@ -33,32 +33,55 @@ export function createGlobalSettingsReader(): MigrationSettingsReader { }; } +/** True when the settings hold values the migration would actually write (empty values are filtered). */ export function hasMigratableSettings( reader: MigrationSettingsReader = createGlobalSettingsReader() ): boolean { - return MIGRATABLE_SETTING_KEYS.some(key => reader.globalValue(key) !== undefined); + return buildProvidersConfigFromSettings(reader) !== undefined; } /** - * True when the user's providers.json file already carries provider config. - * Reads through ai-config's source assembly (never raw fs) so file location, - * JSONC handling, and validation fallbacks stay in one place. + * True when the user's providers.json file already carries provider config, + * or holds content the migration must not silently replace. ai-config's read + * path coerces unparseable or schema-invalid files to an empty config, which + * would make a hand-edited file with one typo look unpopulated; this check + * deliberately reads the raw file and validates it with ai-config's schema + * so such files count as populated. */ export async function userProvidersFileIsPopulated(configPath?: string): Promise { - const { loadConfigSources } = await import('ai-config/node'); - const sources = await loadConfigSources({ - configPath, - logger: { debug: (m: string) => log.debug(m), warn: (m: string) => log.warn(m) }, - }); - const userSource = sources.find(source => source.kind === 'user'); - const providers = userSource?.config.providers; + const { PROVIDERS_CONFIG_PATH, providersConfigSchema } = await import('ai-config/node'); + const filePath = configPath ?? PROVIDERS_CONFIG_PATH; + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf-8'); + } catch { + return false; + } + if (raw.trim() === '') { + return false; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + log.warn(`[migration] ${filePath} is not valid JSON; treating it as populated`); + return true; + } + const result = providersConfigSchema.safeParse(parsed); + if (!result.success) { + log.warn(`[migration] ${filePath} does not match the providers schema; treating it as populated`); + return true; + } + const providers = result.data.providers; return !!providers && Object.keys(providers).length > 0; } /** * One-shot migration: writes the mapped config through mutateProvidersConfig. - * The populated-file guard lives INSIDE the mutator, so the check and the - * write happen under ai-config's cross-process lock. + * The populated-file check runs BEFORE the mutator so unparseable files (which + * the mutator's read coerces to an empty config) and no-op skips never touch + * the file, and again INSIDE the mutator so the parseable case stays guarded + * under ai-config's cross-process lock. */ export async function runMigration(opts: RunMigrationOptions): Promise { const reader = opts.reader ?? createGlobalSettingsReader(); @@ -68,6 +91,11 @@ export async function runMigration(opts: RunMigrationOptions): Promise { assert.strictEqual(hasMigratableSettings(readerOf({ 'authentication.anthropic.baseUrl': 'https://x' })), true); }); + test('hasMigratableSettings is false when only empty values are set', () => { + assert.strictEqual(hasMigratableSettings(readerOf({ + 'authentication.anthropic.baseUrl': '', + 'authentication.anthropic.customHeaders': {}, + })), false); + }); + test('nothing to migrate leaves no file behind', async () => { const result = await runMigration({ overwrite: false, configPath, reader: readerOf({}) }); assert.deepStrictEqual(result, { outcome: 'nothing-to-migrate' }); @@ -79,4 +86,41 @@ suite('migrateToProvidersJson', () => { test('userProvidersFileIsPopulated is false for a missing file', async () => { assert.strictEqual(await userProvidersFileIsPopulated(configPath), false); }); + + test('an unparseable providers.json counts as populated and is never overwritten', async () => { + fs.writeFileSync(configPath, '{ this is not JSON'); + assert.strictEqual(await userProvidersFileIsPopulated(configPath), true); + const result = await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://x' }) }); + assert.deepStrictEqual(result, { outcome: 'skipped-populated' }); + assert.strictEqual(fs.readFileSync(configPath, 'utf-8'), '{ this is not JSON'); + }); + + test('a schema-invalid providers.json counts as populated and is never overwritten', async () => { + const invalid = JSON.stringify({ unknownKey: true }); + fs.writeFileSync(configPath, invalid); + assert.strictEqual(await userProvidersFileIsPopulated(configPath), true); + const result = await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://x' }) }); + assert.deepStrictEqual(result, { outcome: 'skipped-populated' }); + assert.strictEqual(fs.readFileSync(configPath, 'utf-8'), invalid); + }); + + test('a valid file without providers is not populated', async () => { + fs.writeFileSync(configPath, JSON.stringify({ version: 1 })); + assert.strictEqual(await userProvidersFileIsPopulated(configPath), false); + }); + + test('overwrite replaces even an unparseable file after explicit confirmation', async () => { + fs.writeFileSync(configPath, '{ this is not JSON'); + const result = await runMigration({ overwrite: true, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://x' }) }); + assert.deepStrictEqual(result, { outcome: 'migrated', settingCount: 1 }); + const written = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(written.providers.anthropic.baseUrl, 'https://x'); + }); + + test('skipping a populated file does not rewrite it', async () => { + await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://first' }) }); + const before = fs.statSync(configPath).mtimeMs; + await runMigration({ overwrite: false, configPath, reader: readerOf({ 'authentication.anthropic.baseUrl': 'https://second' }) }); + assert.strictEqual(fs.statSync(configPath).mtimeMs, before); + }); }); From 2d38a7be234b20b4e1d9c6efaf15eb12dba95599 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 11:28:54 -0400 Subject: [PATCH 10/29] Validate the full settings mapping against the ai-config schema --- .../src/test/providersJsonMapping.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index a5035e82e303..091a6cdb5919 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -68,6 +68,32 @@ suite('buildProvidersConfigFromSettings', () => { assert.strictEqual(result?.config.providers?.deepseek?.enabled, false); }); + test('every migratable setting maps to config the real ai-config schema accepts', async () => { + const values: Record = {}; + for (const key of MIGRATABLE_SETTING_KEYS) { + if (key === 'authentication.aws.credentials') { + values[key] = { AWS_PROFILE: 'default', AWS_REGION: 'us-east-1' }; + } else if (key === 'authentication.googleVertex.credentials') { + values[key] = { GOOGLE_VERTEX_PROJECT: 'proj', GOOGLE_VERTEX_LOCATION: 'us-central1' }; + } else if (key === 'authentication.snowflake.credentials') { + values[key] = { SNOWFLAKE_ACCOUNT: 'MYORG-MYACCT' }; + } else if (key.endsWith('.baseUrl')) { + values[key] = 'https://gateway.example.com'; + } else if (key.endsWith('.customHeaders')) { + values[key] = { 'x-team': 'data-science' }; + } else if (key.endsWith('.enable') || key.endsWith('.enabled')) { + values[key] = true; + } else { + assert.fail(`unhandled migratable key ${key}; add a branch with a realistic value`); + } + } + const result = buildProvidersConfigFromSettings(readerOf(values)); + assert.ok(result, 'buildProvidersConfigFromSettings returned undefined'); + assert.strictEqual(result.settingCount, MIGRATABLE_SETTING_KEYS.length); + const { providersConfigSchema } = await import('ai-config/node'); + providersConfigSchema.parse(result.config); + }); + test('MIGRATABLE_SETTING_KEYS covers a spot-check of each family', () => { for (const key of [ 'authentication.anthropic.baseUrl', From a7031e54c209c9054fc53c7713e8184383df3ccb Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 13:54:58 -0400 Subject: [PATCH 11/29] Run ai-config's own build from the extension postinstall --- extensions/authentication/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index c982dd453584..99963f0988c8 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -25,7 +25,7 @@ ], "main": "./out/extension.js", "scripts": { - "postinstall": "node -e \"require('fs').rmSync('../../ai-lib/packages/ai-config/tsconfig.tsbuildinfo',{force:true})\" && npm --prefix ../../ai-lib/packages/ai-config exec --no -- tsc -b ../../ai-lib/packages/ai-config" + "postinstall": "npm --prefix ../../ai-lib run build -w ai-config" }, "dependencies": { "@aws-sdk/credential-providers": "^3.734.0", From 5b162c505d63494caece0325fa8f23351d869f1e Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Wed, 15 Jul 2026 15:22:54 -0400 Subject: [PATCH 12/29] Migrate model overrides to providers.json custom models --- extensions/authentication/package.json | 8 ++ .../src/migration/migrateToProvidersJson.ts | 23 ++++- .../src/migration/providersJson.ts | 88 +++++++++++++++++-- .../src/test/providersJsonMapping.test.ts | 61 +++++++++++-- 4 files changed, 161 insertions(+), 19 deletions(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 99963f0988c8..0c7f9efef0fe 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -396,6 +396,7 @@ "order": 9 }, "positron.assistant.models.overrides.anthropic": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.anthropic.description%", @@ -438,6 +439,7 @@ ] }, "positron.assistant.models.overrides.amazonBedrock": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.amazonBedrock.description%", @@ -474,6 +476,7 @@ } }, "positron.assistant.models.overrides.snowflakeCortex": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.snowflakeCortex.description%", @@ -516,6 +519,7 @@ ] }, "positron.assistant.models.overrides.msFoundry": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.msFoundry.description%", @@ -548,6 +552,7 @@ } }, "positron.assistant.models.overrides.openAI": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.openAI.description%", @@ -588,6 +593,7 @@ ] }, "positron.assistant.models.overrides.customProvider": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.customProvider.description%", @@ -633,6 +639,7 @@ ] }, "positron.assistant.models.overrides.positAI": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.positAI.description%", @@ -665,6 +672,7 @@ } }, "positron.assistant.models.overrides.google": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.google.description%", diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index 763b783fb8aa..35c90be3addc 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import { log } from '../log'; import { buildProvidersConfigFromSettings, + InferCapabilitiesFn, MigrationSettingsReader, } from './providersJson'; @@ -23,6 +24,8 @@ export interface RunMigrationOptions { configPath?: string; /** Override the settings source (tests). */ reader?: MigrationSettingsReader; + /** Override capability inference (tests). */ + inferCapabilities?: InferCapabilitiesFn; } /** Reads explicitly-set GLOBAL values only; defaults and workspace scopes are ignored. */ @@ -33,11 +36,25 @@ export function createGlobalSettingsReader(): MigrationSettingsReader { }; } +/** + * Zero-value capability synthesizer for presence checks. Capabilities only + * shape the values written into custom models, never whether a setting + * migrates, so hasMigratableSettings can stay synchronous instead of + * dynamically importing ai-config's real inferModelCapabilities. + */ +const PRESENCE_CHECK_CAPABILITIES: InferCapabilitiesFn = () => ({ + maxContextLength: 0, + supportsTools: false, + supportsImages: false, + supportsToolResultImages: false, + supportsWebSearch: false, +}); + /** True when the settings hold values the migration would actually write (empty values are filtered). */ export function hasMigratableSettings( reader: MigrationSettingsReader = createGlobalSettingsReader() ): boolean { - return buildProvidersConfigFromSettings(reader) !== undefined; + return buildProvidersConfigFromSettings(reader, PRESENCE_CHECK_CAPABILITIES) !== undefined; } /** @@ -85,7 +102,8 @@ export async function userProvidersFileIsPopulated(configPath?: string): Promise */ export async function runMigration(opts: RunMigrationOptions): Promise { const reader = opts.reader ?? createGlobalSettingsReader(); - const mapped = buildProvidersConfigFromSettings(reader); + const { mutateProvidersConfig, inferModelCapabilities } = await import('ai-config/node'); + const mapped = buildProvidersConfigFromSettings(reader, opts.inferCapabilities ?? inferModelCapabilities); if (!mapped) { log.info('[migration] No provider settings to migrate'); return { outcome: 'nothing-to-migrate' }; @@ -96,7 +114,6 @@ export async function runMigration(opts: RunMigrationOptions): Promise { diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts index ac47383e489e..bb4add3ad76f 100644 --- a/extensions/authentication/src/migration/providersJson.ts +++ b/extensions/authentication/src/migration/providersJson.ts @@ -3,9 +3,11 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import type { ProvidersConfig } from 'ai-config/node'; +import type { InferredModelCapabilities, ProvidersConfig } from 'ai-config/node'; import { normalizeToV1Url } from '../validation'; +export type InferCapabilitiesFn = (providerId: string, modelId: string) => InferredModelCapabilities; + /** Reads one setting's explicitly-set GLOBAL value (undefined when unset). */ export interface MigrationSettingsReader { globalValue(key: string): T | undefined; @@ -42,6 +44,31 @@ interface EnablementSetting { readonly newKey?: string; } +interface ModelOverrideSetting { + /** positron.assistant.models.overrides.. */ + readonly settingName: string; + readonly providerId: string; +} + +const MODEL_OVERRIDE_SETTINGS: readonly ModelOverrideSetting[] = [ + { settingName: 'anthropic', providerId: 'anthropic' }, + { settingName: 'amazonBedrock', providerId: 'bedrock' }, + { settingName: 'snowflakeCortex', providerId: 'snowflake-cortex' }, + { settingName: 'msFoundry', providerId: 'ms-foundry' }, + { settingName: 'openAI', providerId: 'openai' }, + { settingName: 'customProvider', providerId: 'openai-compatible' }, + { settingName: 'positAI', providerId: 'positai' }, + { settingName: 'google', providerId: 'gemini' }, +]; + +/** Shape of one legacy positron.assistant.models.overrides. entry. */ +interface LegacyModelOverride { + name: string; + identifier: string; + maxInputTokens?: number; + maxOutputTokens?: number; +} + const ENABLEMENT_SETTINGS: readonly EnablementSetting[] = [ { providerId: 'anthropic', oldKey: 'positron.assistant.provider.anthropic.enable' }, { providerId: 'openai', oldKey: 'positron.assistant.provider.openAI.enable' }, @@ -56,11 +83,7 @@ const ENABLEMENT_SETTINGS: readonly EnablementSetting[] = [ { providerId: 'deepseek', newKey: 'assistant.provider.deepseek.enabled' }, ]; -/** - * Every setting the migration consumes; Task 3's hasMigratableSettings scans - * this. Task 8 appends the positron.assistant.models.overrides. keys - * when model-overrides migration lands. - */ +/** Every setting the migration consumes; hasMigratableSettings scans this. */ export const MIGRATABLE_SETTING_KEYS: readonly string[] = [ ...API_KEY_CONNECTION_SETTINGS.flatMap(s => [ `authentication.${s.configKey}.baseUrl`, @@ -71,6 +94,7 @@ export const MIGRATABLE_SETTING_KEYS: readonly string[] = [ 'authentication.snowflake.credentials', 'authentication.snowflake.customHeaders', ...ENABLEMENT_SETTINGS.flatMap(s => [s.oldKey, s.newKey]).filter((k): k is string => !!k), + ...MODEL_OVERRIDE_SETTINGS.map(s => `positron.assistant.models.overrides.${s.settingName}`), ]; // providers.json blocks are plain data; build them as records and assign into @@ -78,7 +102,8 @@ export const MIGRATABLE_SETTING_KEYS: readonly string[] = [ type Block = Record; export function buildProvidersConfigFromSettings( - reader: MigrationSettingsReader + reader: MigrationSettingsReader, + inferCapabilities: InferCapabilitiesFn ): MappedProvidersConfig | undefined { const providers: Record = {}; let settingCount = 0; @@ -154,6 +179,22 @@ export function buildProvidersConfigFromSettings( } } + // --- model overrides -> models.custom + discovery off -------------------- + for (const s of MODEL_OVERRIDE_SETTINGS) { + const raw = reader.globalValue(`positron.assistant.models.overrides.${s.settingName}`); + if (!Array.isArray(raw)) { + continue; + } + const entries = raw.filter((e): e is LegacyModelOverride => + !!e && typeof e === 'object' && typeof e.name === 'string' && typeof e.identifier === 'string'); + if (entries.length === 0) { + continue; + } + const custom = entries.map(entry => buildCustomModel(s.providerId, entry, inferCapabilities)); + merge(s.providerId, { models: { discovery: 'off', custom } }); + settingCount++; + } + if (Object.keys(providers).length === 0) { return undefined; } @@ -163,6 +204,39 @@ export function buildProvidersConfigFromSettings( }; } +/** + * Legacy entries carry only name/identifier/token limits; capabilities are + * synthesized, user token limits win, and maxContextLength never drops below + * the user's maxInputTokens. + */ +function buildCustomModel(providerId: string, entry: LegacyModelOverride, inferCapabilities: InferCapabilitiesFn): Block { + const caps = inferCapabilities(providerId, entry.identifier); + const maxInputTokens = entry.maxInputTokens ?? caps.maxInputTokens; + const maxOutputTokens = entry.maxOutputTokens ?? caps.maxOutputTokens; + const model: Block = { + id: entry.identifier, + name: entry.name, + maxContextLength: Math.max(caps.maxContextLength, entry.maxInputTokens ?? 0), + supportsTools: caps.supportsTools, + supportsImages: caps.supportsImages, + supportsToolResultImages: caps.supportsToolResultImages, + supportsWebSearch: caps.supportsWebSearch, + }; + if (maxInputTokens !== undefined) { + model.maxInputTokens = maxInputTokens; + } + if (maxOutputTokens !== undefined) { + model.maxOutputTokens = maxOutputTokens; + } + if (caps.thinkingEffortLevels !== undefined) { + model.thinkingEffortLevels = caps.thinkingEffortLevels; + } + if (caps.protocol !== undefined) { + model.protocol = caps.protocol; + } + return model; +} + function nonEmptyString(value: string | undefined): string | undefined { return value && value.trim() !== '' ? value : undefined; } diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index 091a6cdb5919..b6477417c083 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -4,15 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { buildProvidersConfigFromSettings, MIGRATABLE_SETTING_KEYS, MigrationSettingsReader } from '../migration/providersJson'; +import { buildProvidersConfigFromSettings, InferCapabilitiesFn, MIGRATABLE_SETTING_KEYS, MigrationSettingsReader } from '../migration/providersJson'; function readerOf(values: Record): MigrationSettingsReader { return { globalValue: (key: string) => values[key] as T | undefined }; } +const fakeCaps: InferCapabilitiesFn = () => ({ + maxContextLength: 128_000, + supportsTools: true, + supportsImages: false, + supportsToolResultImages: false, + supportsWebSearch: false, +}); + suite('buildProvidersConfigFromSettings', () => { test('returns undefined when nothing is set', () => { - assert.strictEqual(buildProvidersConfigFromSettings(readerOf({})), undefined); + assert.strictEqual(buildProvidersConfigFromSettings(readerOf({}), fakeCaps), undefined); }); test('maps connection settings to provider blocks', () => { @@ -20,7 +28,7 @@ suite('buildProvidersConfigFromSettings', () => { 'authentication.anthropic.baseUrl': 'https://gateway.example.com', 'authentication.anthropic.customHeaders': { 'x-team': 'data-science' }, 'authentication.openai-api.baseUrl': 'https://openai.example.com', - })); + }), fakeCaps); assert.deepStrictEqual(result?.config.providers?.anthropic, { baseUrl: 'https://gateway.example.com', customHeaders: { 'x-team': 'data-science' }, @@ -32,7 +40,7 @@ suite('buildProvidersConfigFromSettings', () => { test('normalizes the foundry base URL', () => { const result = buildProvidersConfigFromSettings(readerOf({ 'authentication.foundry.baseUrl': 'https://my-resource.services.ai.azure.com', - })); + }), fakeCaps); // normalizeToV1Url appends the versioned path; assert against its real output. assert.ok(result?.config.providers?.['ms-foundry']?.baseUrl?.startsWith('https://my-resource.services.ai.azure.com')); assert.notStrictEqual(result?.config.providers?.['ms-foundry']?.baseUrl, 'https://my-resource.services.ai.azure.com'); @@ -42,7 +50,7 @@ suite('buildProvidersConfigFromSettings', () => { assert.strictEqual(buildProvidersConfigFromSettings(readerOf({ 'authentication.anthropic.baseUrl': '', 'authentication.anthropic.customHeaders': {}, - })), undefined); + }), fakeCaps), undefined); }); test('maps grouped credential settings to their sections', () => { @@ -50,7 +58,7 @@ suite('buildProvidersConfigFromSettings', () => { 'authentication.aws.credentials': { AWS_PROFILE: 'default', AWS_REGION: 'us-east-1' }, 'authentication.googleVertex.credentials': { GOOGLE_VERTEX_PROJECT: 'my-project', GOOGLE_VERTEX_LOCATION: 'us-central1' }, 'authentication.snowflake.credentials': { SNOWFLAKE_ACCOUNT: 'MYORG-MYACCT', SNOWFLAKE_HOME: '/tmp/snow' }, - })); + }), fakeCaps); assert.deepStrictEqual(result?.config.providers?.bedrock, { aws: { profile: 'default', region: 'us-east-1' } }); assert.deepStrictEqual(result?.config.providers?.['google-vertex'], { googleCloud: { project: 'my-project', location: 'us-central1' } }); // SNOWFLAKE_HOME is NOT migrated yet (posit-dev/ai-lib#8). @@ -62,13 +70,46 @@ suite('buildProvidersConfigFromSettings', () => { 'positron.assistant.provider.anthropic.enable': false, 'positron.assistant.provider.google.enable': true, 'assistant.provider.deepseek.enabled': false, - })); + }), fakeCaps); assert.strictEqual(result?.config.providers?.anthropic?.enabled, false); assert.strictEqual(result?.config.providers?.gemini?.enabled, true); assert.strictEqual(result?.config.providers?.deepseek?.enabled, false); }); + test('converts model overrides to custom models with discovery off', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'positron.assistant.models.overrides.anthropic': [ + { name: 'Sonnet (team)', identifier: 'claude-sonnet-4-5', maxInputTokens: 300_000 }, + { identifier: 'missing-name' }, // malformed: skipped + ], + }), fakeCaps); + const models = result?.config.providers?.anthropic?.models; + assert.strictEqual(models?.discovery, 'off'); + assert.strictEqual(models?.custom?.length, 1); + const model = models.custom[0]; + assert.strictEqual(model.id, 'claude-sonnet-4-5'); + assert.strictEqual(model.name, 'Sonnet (team)'); + assert.strictEqual(model.maxInputTokens, 300_000); + // maxContextLength floored at the user's maxInputTokens. + assert.ok(model.maxContextLength >= 300_000); + }); + + test('an overrides array with only malformed entries maps nothing', () => { + assert.strictEqual(buildProvidersConfigFromSettings(readerOf({ + 'positron.assistant.models.overrides.openAI': [{ nope: true }], + }), fakeCaps), undefined); + }); + + test('the real inferModelCapabilities satisfies the custom-model schema', async () => { + const { inferModelCapabilities, customModelSchema } = await import('ai-config/node'); + const result = buildProvidersConfigFromSettings(readerOf({ + 'positron.assistant.models.overrides.anthropic': [{ name: 'Sonnet', identifier: 'claude-sonnet-4-5' }], + }), inferModelCapabilities); + customModelSchema.parse(result?.config.providers?.anthropic?.models?.custom?.[0]); + }); + test('every migratable setting maps to config the real ai-config schema accepts', async () => { + const { inferModelCapabilities, providersConfigSchema } = await import('ai-config/node'); const values: Record = {}; for (const key of MIGRATABLE_SETTING_KEYS) { if (key === 'authentication.aws.credentials') { @@ -81,16 +122,17 @@ suite('buildProvidersConfigFromSettings', () => { values[key] = 'https://gateway.example.com'; } else if (key.endsWith('.customHeaders')) { values[key] = { 'x-team': 'data-science' }; + } else if (key.startsWith('positron.assistant.models.overrides.')) { + values[key] = [{ name: 'Team Model', identifier: 'team-model-1', maxInputTokens: 100_000 }]; } else if (key.endsWith('.enable') || key.endsWith('.enabled')) { values[key] = true; } else { assert.fail(`unhandled migratable key ${key}; add a branch with a realistic value`); } } - const result = buildProvidersConfigFromSettings(readerOf(values)); + const result = buildProvidersConfigFromSettings(readerOf(values), inferModelCapabilities); assert.ok(result, 'buildProvidersConfigFromSettings returned undefined'); assert.strictEqual(result.settingCount, MIGRATABLE_SETTING_KEYS.length); - const { providersConfigSchema } = await import('ai-config/node'); providersConfigSchema.parse(result.config); }); @@ -101,6 +143,7 @@ suite('buildProvidersConfigFromSettings', () => { 'authentication.snowflake.customHeaders', 'positron.assistant.provider.githubCopilot.enable', 'assistant.provider.googleVertex.enabled', + 'positron.assistant.models.overrides.positAI', ]) { assert.ok(MIGRATABLE_SETTING_KEYS.includes(key), `missing ${key}`); } From b25bff119aa91d8aca5cd46c6b1cbace93740e9a Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 09:43:36 -0400 Subject: [PATCH 13/29] Migrate SNOWFLAKE_HOME to providers.json and deprecate the setting --- extensions/authentication/package.json | 1 + .../authentication/src/migration/providersJson.ts | 11 ++++++++--- .../src/test/providersJsonMapping.test.ts | 5 ++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 0c7f9efef0fe..f426efbded85 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -118,6 +118,7 @@ "markdownDescription": "%configuration.aws.inferenceProfileRegion.description%" }, "authentication.snowflake.credentials": { + "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.snowflake.credentials.description%", diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts index bb4add3ad76f..c2971e0ae493 100644 --- a/extensions/authentication/src/migration/providersJson.ts +++ b/extensions/authentication/src/migration/providersJson.ts @@ -155,11 +155,16 @@ export function buildProvidersConfigFromSettings( } // --- authentication.snowflake.* -> snowflake-cortex ---------------------- - // SNOWFLAKE_HOME migrates in a later task once ai-lib ships snowflake.home - // (posit-dev/ai-lib#8); only SNOWFLAKE_ACCOUNT maps here. const snowflakeCreds = reader.globalValue>('authentication.snowflake.credentials'); + const snowflake: Block = {}; if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_ACCOUNT)) { - merge('snowflake-cortex', { snowflake: { account: snowflakeCreds!.SNOWFLAKE_ACCOUNT } }); + snowflake.account = snowflakeCreds!.SNOWFLAKE_ACCOUNT; + } + if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_HOME)) { + snowflake.home = snowflakeCreds!.SNOWFLAKE_HOME; + } + if (Object.keys(snowflake).length > 0) { + merge('snowflake-cortex', { snowflake }); settingCount++; } const snowflakeHeaders = nonEmptyHeaders(reader.globalValue>('authentication.snowflake.customHeaders')); diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index b6477417c083..ef9063b3de6e 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -61,8 +61,7 @@ suite('buildProvidersConfigFromSettings', () => { }), fakeCaps); assert.deepStrictEqual(result?.config.providers?.bedrock, { aws: { profile: 'default', region: 'us-east-1' } }); assert.deepStrictEqual(result?.config.providers?.['google-vertex'], { googleCloud: { project: 'my-project', location: 'us-central1' } }); - // SNOWFLAKE_HOME is NOT migrated yet (posit-dev/ai-lib#8). - assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT' } }); + assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT', home: '/tmp/snow' } }); }); test('maps enablement toggles with the newer generation winning', () => { @@ -117,7 +116,7 @@ suite('buildProvidersConfigFromSettings', () => { } else if (key === 'authentication.googleVertex.credentials') { values[key] = { GOOGLE_VERTEX_PROJECT: 'proj', GOOGLE_VERTEX_LOCATION: 'us-central1' }; } else if (key === 'authentication.snowflake.credentials') { - values[key] = { SNOWFLAKE_ACCOUNT: 'MYORG-MYACCT' }; + values[key] = { SNOWFLAKE_ACCOUNT: 'MYORG-MYACCT', SNOWFLAKE_HOME: '/opt/snowflake' }; } else if (key.endsWith('.baseUrl')) { values[key] = 'https://gateway.example.com'; } else if (key.endsWith('.customHeaders')) { From 7f9243bdd9e3e34f91461a98666f170912118434 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 10:47:50 -0400 Subject: [PATCH 14/29] Migrate providers.json settings automatically instead of prompting The first-launch modal prompt is replaced with a silent migration on activation, guarded by the same hasMigratableSettings and userProvidersFileIsPopulated checks. The manual command still confirms before overwriting an existing providers.json. --- .../src/migration/providersJsonUi.ts | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/extensions/authentication/src/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts index 5078725fafd1..600b2b378d02 100644 --- a/extensions/authentication/src/migration/providersJsonUi.ts +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -13,16 +13,15 @@ import { } from './migrateToProvidersJson'; export const MIGRATE_COMMAND_ID = 'authentication.migrateSettingsToProvidersJson'; -const PROMPT_DISMISSED_KEY = 'providersJsonMigrationPromptDismissed'; -/** Registers the migration command and schedules the one-time prompt. */ +/** Registers the migration command and runs the one-time automatic migration. */ export function registerProvidersJsonMigration(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand(MIGRATE_COMMAND_ID, () => runMigrationCommand()) ); - // Fire-and-forget: never block activation on the prompt. - maybePromptForMigration(context).catch(err => - log.error(`providers.json migration prompt failed: ${err}`) + // Fire-and-forget: never block activation on the migration. + maybeAutoMigrate().catch(err => + log.error(`providers.json automatic migration failed: ${err}`) ); } @@ -51,33 +50,17 @@ async function runMigrationCommand(): Promise { await migrateAndReport({ overwrite }); } -async function maybePromptForMigration(context: vscode.ExtensionContext): Promise { - if (context.globalState.get(PROMPT_DISMISSED_KEY)) { - return; - } +async function maybeAutoMigrate(): Promise { if (!hasMigratableSettings()) { return; } if (await userProvidersFileIsPopulated()) { // Self-extinguishing: once providers.json is populated by any means, - // the prompt never shows again. + // migration never runs again. return; } - const migrateAction = vscode.l10n.t('Migrate'); - const dontAskAction = vscode.l10n.t("Don't Ask Again"); - const choice = await vscode.window.showInformationMessage( - vscode.l10n.t('Positron now stores AI provider and model configuration in ~/.posit/ai/providers.json. Migrate your settings to keep your providers and models working. Your existing settings will not be removed.'), - migrateAction, - dontAskAction - ); - - if (choice === migrateAction) { - await migrateAndReport({ overwrite: false }); - } else if (choice === dontAskAction) { - await context.globalState.update(PROMPT_DISMISSED_KEY, true); - } - // Plain dismissal: no flag, re-prompt on a later launch. + await migrateAndReport({ overwrite: false }); } async function migrateAndReport(opts: { overwrite: boolean }): Promise { @@ -93,11 +76,19 @@ async function migrateAndReport(opts: { overwrite: boolean }): Promise { } switch (result.outcome) { - case 'migrated': - vscode.window.showInformationMessage( - vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron reads provider configuration from this file; your original settings were not removed.', result.settingCount) + case 'migrated': { + const viewFileAction = vscode.l10n.t('View File'); + const choice = await vscode.window.showInformationMessage( + vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron reads provider configuration from this file; your original settings were not removed.', result.settingCount), + viewFileAction ); + if (choice === viewFileAction) { + const { PROVIDERS_CONFIG_PATH } = await import('ai-config/node'); + const doc = await vscode.workspace.openTextDocument(PROVIDERS_CONFIG_PATH); + await vscode.window.showTextDocument(doc); + } break; + } case 'skipped-populated': vscode.window.showInformationMessage( vscode.l10n.t('providers.json already contains provider configuration; nothing was migrated.') From 17eabbec579d9856995e13ecc9f2ee8699e5f1a2 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 10:47:58 -0400 Subject: [PATCH 15/29] Shorten providers.json deprecation messages Match the codebase's terser deprecation-message convention and drop the stale reference to running the migrate command now that migration happens automatically. --- extensions/authentication/package.nls.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/authentication/package.nls.json b/extensions/authentication/package.nls.json index f4ee4415b823..bed3d83ed1db 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -38,8 +38,8 @@ "configuration.models.overrides.msFoundry.description": "Model overrides for Microsoft Foundry provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.models.overrides.google.description": "Model overrides for Google Gemini provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.aiExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from AI features. Files matching these patterns will not have their contents sent to AI providers for inline completions or chat context. Patterns without a `/` will match against the filename only (e.g., `*.py` matches all Python files). Use `**/` prefix for explicit recursive matching.", - "configuration.deprecation.providersJson": "Deprecated: Positron reads AI provider and model configuration from `~/.posit/ai/providers.json`, and values there take precedence over this setting. Run the **Migrate AI Provider Settings to providers.json** command to keep your providers and models working.", - "configuration.deprecation.inferenceProfileRegion": "Deprecated: this setting is no longer read. Inference profile regions are derived from the AWS region. To use an explicit inference profile ID, define a custom model in `~/.posit/ai/providers.json`.", + "configuration.deprecation.providersJson": "This setting is deprecated. Use `~/.posit/ai/providers.json` instead.", + "configuration.deprecation.inferenceProfileRegion": "This setting is deprecated and no longer read. Use a custom model in `~/.posit/ai/providers.json` instead.", "commands.configureProviders.title": "Configure Language Model Providers", "commands.migrateSettingsToProvidersJson.title": "Migrate AI Provider Settings to providers.json", "commands.category": "Authentication" From 63725f6f23491e3243f482ea5abb0ecff1931bf3 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 10:55:56 -0400 Subject: [PATCH 16/29] Log migrated provider IDs and link to the log from the toast The success log line now names which providers.json blocks were written instead of only a count, and the migration toast gains a Show Log action that reveals the Authentication output channel. --- .../authentication/src/migration/migrateToProvidersJson.ts | 3 ++- extensions/authentication/src/migration/providersJsonUi.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index 35c90be3addc..fb4dd0b384d0 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -133,6 +133,7 @@ export async function runMigration(opts: RunMigrationOptions): Promise { switch (result.outcome) { case 'migrated': { const viewFileAction = vscode.l10n.t('View File'); + const showLogAction = vscode.l10n.t('Show Log'); const choice = await vscode.window.showInformationMessage( vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron reads provider configuration from this file; your original settings were not removed.', result.settingCount), - viewFileAction + viewFileAction, + showLogAction ); if (choice === viewFileAction) { const { PROVIDERS_CONFIG_PATH } = await import('ai-config/node'); const doc = await vscode.workspace.openTextDocument(PROVIDERS_CONFIG_PATH); await vscode.window.showTextDocument(doc); + } else if (choice === showLogAction) { + log.show(); } break; } From a1079d23fc14a0498b5bb0e9c914ab4ff4496d04 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 11:04:16 -0400 Subject: [PATCH 17/29] Log each migrated setting as source -> providers.json path Replace the provider-ID summary with one log line per destination field, naming the source settings.json key and the dotted providers.json path it wrote. The toast count stays the number of distinct source settings; grouped credentials that fan out to several fields log a line each. --- .../src/migration/migrateToProvidersJson.ts | 6 ++- .../src/migration/providersJson.ts | 37 ++++++++++++++----- .../src/test/providersJsonMapping.test.ts | 14 +++++++ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index fb4dd0b384d0..a1cb50df5d4c 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -133,7 +133,9 @@ export async function runMigration(opts: RunMigrationOptions): Promise ${destination}`); + } return { outcome: 'migrated', settingCount: mapped.settingCount }; } diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts index c2971e0ae493..210d6a84077d 100644 --- a/extensions/authentication/src/migration/providersJson.ts +++ b/extensions/authentication/src/migration/providersJson.ts @@ -13,10 +13,20 @@ export interface MigrationSettingsReader { globalValue(key: string): T | undefined; } +/** One settings.json key mapped to the dotted providers.json path it wrote. */ +export interface SettingMigration { + /** Source settings.json key. */ + readonly source: string; + /** Destination providers.json dotted path (e.g. providers.openai.baseUrl). */ + readonly destination: string; +} + export interface MappedProvidersConfig { config: ProvidersConfig; /** Number of settings.json entries consumed (for the success toast). */ settingCount: number; + /** Source-to-destination record of every value written (for logging). */ + migrations: SettingMigration[]; } interface ApiKeyConnectionSetting { @@ -106,23 +116,26 @@ export function buildProvidersConfigFromSettings( inferCapabilities: InferCapabilitiesFn ): MappedProvidersConfig | undefined { const providers: Record = {}; - let settingCount = 0; + const migrations: SettingMigration[] = []; const merge = (providerId: string, fragment: Block) => { providers[providerId] = { ...providers[providerId], ...fragment }; }; + const record = (source: string, destination: string) => { + migrations.push({ source, destination }); + }; // --- authentication..{baseUrl,customHeaders} ----------------- for (const s of API_KEY_CONNECTION_SETTINGS) { const rawBaseUrl = nonEmptyString(reader.globalValue(`authentication.${s.configKey}.baseUrl`)); if (rawBaseUrl) { merge(s.providerId, { baseUrl: s.normalizeBaseUrl ? s.normalizeBaseUrl(rawBaseUrl) : rawBaseUrl }); - settingCount++; + record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`); } const headers = nonEmptyHeaders(reader.globalValue>(`authentication.${s.configKey}.customHeaders`)); if (headers) { merge(s.providerId, { customHeaders: headers }); - settingCount++; + record(`authentication.${s.configKey}.customHeaders`, `providers.${s.providerId}.customHeaders`); } } @@ -131,13 +144,14 @@ export function buildProvidersConfigFromSettings( const googleCloud: Block = {}; if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_PROJECT)) { googleCloud.project = vertexCreds!.GOOGLE_VERTEX_PROJECT; + record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.project'); } if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_LOCATION)) { googleCloud.location = vertexCreds!.GOOGLE_VERTEX_LOCATION; + record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.location'); } if (Object.keys(googleCloud).length > 0) { merge('google-vertex', { googleCloud }); - settingCount++; } // --- authentication.aws.credentials -> bedrock.aws ----------------------- @@ -145,13 +159,14 @@ export function buildProvidersConfigFromSettings( const aws: Block = {}; if (nonEmptyString(awsCreds?.AWS_PROFILE)) { aws.profile = awsCreds!.AWS_PROFILE; + record('authentication.aws.credentials', 'providers.bedrock.aws.profile'); } if (nonEmptyString(awsCreds?.AWS_REGION)) { aws.region = awsCreds!.AWS_REGION; + record('authentication.aws.credentials', 'providers.bedrock.aws.region'); } if (Object.keys(aws).length > 0) { merge('bedrock', { aws }); - settingCount++; } // --- authentication.snowflake.* -> snowflake-cortex ---------------------- @@ -159,18 +174,19 @@ export function buildProvidersConfigFromSettings( const snowflake: Block = {}; if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_ACCOUNT)) { snowflake.account = snowflakeCreds!.SNOWFLAKE_ACCOUNT; + record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.account'); } if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_HOME)) { snowflake.home = snowflakeCreds!.SNOWFLAKE_HOME; + record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.home'); } if (Object.keys(snowflake).length > 0) { merge('snowflake-cortex', { snowflake }); - settingCount++; } const snowflakeHeaders = nonEmptyHeaders(reader.globalValue>('authentication.snowflake.customHeaders')); if (snowflakeHeaders) { merge('snowflake-cortex', { customHeaders: snowflakeHeaders }); - settingCount++; + record('authentication.snowflake.customHeaders', 'providers.snowflake-cortex.customHeaders'); } // --- enablement toggles -> providers..enabled ------------------------ @@ -180,7 +196,7 @@ export function buildProvidersConfigFromSettings( const enabled = newValue ?? oldValue; if (enabled !== undefined) { merge(s.providerId, { enabled }); - settingCount++; + record(newValue !== undefined ? s.newKey! : s.oldKey!, `providers.${s.providerId}.enabled`); } } @@ -197,7 +213,7 @@ export function buildProvidersConfigFromSettings( } const custom = entries.map(entry => buildCustomModel(s.providerId, entry, inferCapabilities)); merge(s.providerId, { models: { discovery: 'off', custom } }); - settingCount++; + record(`positron.assistant.models.overrides.${s.settingName}`, `providers.${s.providerId}.models.custom`); } if (Object.keys(providers).length === 0) { @@ -205,7 +221,8 @@ export function buildProvidersConfigFromSettings( } return { config: { providers } as ProvidersConfig, - settingCount, + settingCount: new Set(migrations.map(m => m.source)).size, + migrations, }; } diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index ef9063b3de6e..ab93e2f33359 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -64,6 +64,20 @@ suite('buildProvidersConfigFromSettings', () => { assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT', home: '/tmp/snow' } }); }); + test('records source-to-destination migrations per destination field', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'authentication.openai-api.baseUrl': 'https://openai.example.com', + 'authentication.aws.credentials': { AWS_PROFILE: 'default', AWS_REGION: 'us-east-1' }, + }), fakeCaps); + assert.deepStrictEqual(result?.migrations, [ + { source: 'authentication.openai-api.baseUrl', destination: 'providers.openai.baseUrl' }, + { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.profile' }, + { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.region' }, + ]); + // The toast counts distinct source settings, not destination fields. + assert.strictEqual(result?.settingCount, 2); + }); + test('maps enablement toggles with the newer generation winning', () => { const result = buildProvidersConfigFromSettings(readerOf({ 'positron.assistant.provider.anthropic.enable': false, From 5649cdfc937d11fd90bba96cde95f53f07537819 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 11:15:23 -0400 Subject: [PATCH 18/29] Log the migrated value alongside each setting mapping Each migration line now shows the value written to providers.json. Custom header values can carry auth tokens, so headers log their names only; all other values (URLs, project/region/account, enabled flags, model identifiers) log verbatim. --- .../src/migration/migrateToProvidersJson.ts | 4 +- .../src/migration/providersJson.ts | 41 ++++++++++++------- .../src/test/providersJsonMapping.test.ts | 13 +++--- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index a1cb50df5d4c..a9368e7efcab 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -134,8 +134,8 @@ export async function runMigration(opts: RunMigrationOptions): Promise ${destination}`); + for (const { source, destination, value } of mapped.migrations) { + log.info(`[migration] ${source} -> ${destination} = ${value}`); } return { outcome: 'migrated', settingCount: mapped.settingCount }; } diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts index 210d6a84077d..4b91fa182ba9 100644 --- a/extensions/authentication/src/migration/providersJson.ts +++ b/extensions/authentication/src/migration/providersJson.ts @@ -19,6 +19,12 @@ export interface SettingMigration { readonly source: string; /** Destination providers.json dotted path (e.g. providers.openai.baseUrl). */ readonly destination: string; + /** + * Log-safe rendering of the written value. Header values are reduced to + * their names since they can carry auth tokens; all other values are safe + * to show verbatim. + */ + readonly value: string; } export interface MappedProvidersConfig { @@ -121,21 +127,22 @@ export function buildProvidersConfigFromSettings( const merge = (providerId: string, fragment: Block) => { providers[providerId] = { ...providers[providerId], ...fragment }; }; - const record = (source: string, destination: string) => { - migrations.push({ source, destination }); + const record = (source: string, destination: string, value: string) => { + migrations.push({ source, destination, value }); }; // --- authentication..{baseUrl,customHeaders} ----------------- for (const s of API_KEY_CONNECTION_SETTINGS) { const rawBaseUrl = nonEmptyString(reader.globalValue(`authentication.${s.configKey}.baseUrl`)); if (rawBaseUrl) { - merge(s.providerId, { baseUrl: s.normalizeBaseUrl ? s.normalizeBaseUrl(rawBaseUrl) : rawBaseUrl }); - record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`); + const baseUrl = s.normalizeBaseUrl ? s.normalizeBaseUrl(rawBaseUrl) : rawBaseUrl; + merge(s.providerId, { baseUrl }); + record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`, JSON.stringify(baseUrl)); } const headers = nonEmptyHeaders(reader.globalValue>(`authentication.${s.configKey}.customHeaders`)); if (headers) { merge(s.providerId, { customHeaders: headers }); - record(`authentication.${s.configKey}.customHeaders`, `providers.${s.providerId}.customHeaders`); + record(`authentication.${s.configKey}.customHeaders`, `providers.${s.providerId}.customHeaders`, headerNames(headers)); } } @@ -144,11 +151,11 @@ export function buildProvidersConfigFromSettings( const googleCloud: Block = {}; if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_PROJECT)) { googleCloud.project = vertexCreds!.GOOGLE_VERTEX_PROJECT; - record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.project'); + record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.project', JSON.stringify(googleCloud.project)); } if (nonEmptyString(vertexCreds?.GOOGLE_VERTEX_LOCATION)) { googleCloud.location = vertexCreds!.GOOGLE_VERTEX_LOCATION; - record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.location'); + record('authentication.googleVertex.credentials', 'providers.google-vertex.googleCloud.location', JSON.stringify(googleCloud.location)); } if (Object.keys(googleCloud).length > 0) { merge('google-vertex', { googleCloud }); @@ -159,11 +166,11 @@ export function buildProvidersConfigFromSettings( const aws: Block = {}; if (nonEmptyString(awsCreds?.AWS_PROFILE)) { aws.profile = awsCreds!.AWS_PROFILE; - record('authentication.aws.credentials', 'providers.bedrock.aws.profile'); + record('authentication.aws.credentials', 'providers.bedrock.aws.profile', JSON.stringify(aws.profile)); } if (nonEmptyString(awsCreds?.AWS_REGION)) { aws.region = awsCreds!.AWS_REGION; - record('authentication.aws.credentials', 'providers.bedrock.aws.region'); + record('authentication.aws.credentials', 'providers.bedrock.aws.region', JSON.stringify(aws.region)); } if (Object.keys(aws).length > 0) { merge('bedrock', { aws }); @@ -174,11 +181,11 @@ export function buildProvidersConfigFromSettings( const snowflake: Block = {}; if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_ACCOUNT)) { snowflake.account = snowflakeCreds!.SNOWFLAKE_ACCOUNT; - record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.account'); + record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.account', JSON.stringify(snowflake.account)); } if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_HOME)) { snowflake.home = snowflakeCreds!.SNOWFLAKE_HOME; - record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.home'); + record('authentication.snowflake.credentials', 'providers.snowflake-cortex.snowflake.home', JSON.stringify(snowflake.home)); } if (Object.keys(snowflake).length > 0) { merge('snowflake-cortex', { snowflake }); @@ -186,7 +193,7 @@ export function buildProvidersConfigFromSettings( const snowflakeHeaders = nonEmptyHeaders(reader.globalValue>('authentication.snowflake.customHeaders')); if (snowflakeHeaders) { merge('snowflake-cortex', { customHeaders: snowflakeHeaders }); - record('authentication.snowflake.customHeaders', 'providers.snowflake-cortex.customHeaders'); + record('authentication.snowflake.customHeaders', 'providers.snowflake-cortex.customHeaders', headerNames(snowflakeHeaders)); } // --- enablement toggles -> providers..enabled ------------------------ @@ -196,7 +203,7 @@ export function buildProvidersConfigFromSettings( const enabled = newValue ?? oldValue; if (enabled !== undefined) { merge(s.providerId, { enabled }); - record(newValue !== undefined ? s.newKey! : s.oldKey!, `providers.${s.providerId}.enabled`); + record(newValue !== undefined ? s.newKey! : s.oldKey!, `providers.${s.providerId}.enabled`, String(enabled)); } } @@ -213,7 +220,8 @@ export function buildProvidersConfigFromSettings( } const custom = entries.map(entry => buildCustomModel(s.providerId, entry, inferCapabilities)); merge(s.providerId, { models: { discovery: 'off', custom } }); - record(`positron.assistant.models.overrides.${s.settingName}`, `providers.${s.providerId}.models.custom`); + const modelIds = entries.map(e => e.identifier).join(', '); + record(`positron.assistant.models.overrides.${s.settingName}`, `providers.${s.providerId}.models.custom`, `[${modelIds}]`); } if (Object.keys(providers).length === 0) { @@ -266,3 +274,8 @@ function nonEmptyString(value: string | undefined): string | undefined { function nonEmptyHeaders(headers: Record | undefined): Record | undefined { return headers && Object.keys(headers).length > 0 ? headers : undefined; } + +/** Header names only; values can carry auth tokens and must not be logged. */ +function headerNames(headers: Record): string { + return `[${Object.keys(headers).join(', ')}]`; +} diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index ab93e2f33359..7beb7d74171f 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -64,18 +64,21 @@ suite('buildProvidersConfigFromSettings', () => { assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT', home: '/tmp/snow' } }); }); - test('records source-to-destination migrations per destination field', () => { + test('records source-to-destination migrations with log-safe values', () => { const result = buildProvidersConfigFromSettings(readerOf({ 'authentication.openai-api.baseUrl': 'https://openai.example.com', + 'authentication.openai-api.customHeaders': { 'x-api-key': 'sk-secret-token', 'x-team': 'data-science' }, 'authentication.aws.credentials': { AWS_PROFILE: 'default', AWS_REGION: 'us-east-1' }, }), fakeCaps); assert.deepStrictEqual(result?.migrations, [ - { source: 'authentication.openai-api.baseUrl', destination: 'providers.openai.baseUrl' }, - { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.profile' }, - { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.region' }, + { source: 'authentication.openai-api.baseUrl', destination: 'providers.openai.baseUrl', value: '"https://openai.example.com"' }, + // Header values can carry auth tokens; only names are logged. + { source: 'authentication.openai-api.customHeaders', destination: 'providers.openai.customHeaders', value: '[x-api-key, x-team]' }, + { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.profile', value: '"default"' }, + { source: 'authentication.aws.credentials', destination: 'providers.bedrock.aws.region', value: '"us-east-1"' }, ]); // The toast counts distinct source settings, not destination fields. - assert.strictEqual(result?.settingCount, 2); + assert.strictEqual(result?.settingCount, 3); }); test('maps enablement toggles with the newer generation winning', () => { From 863984ae216591370418a5ddacd890b5d4930c3f Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 11:40:46 -0400 Subject: [PATCH 19/29] Clarify the migration toast wording Name Posit Assistant providers as what the file feeds, and say Positron "now" reads them to signal the new behavior. --- extensions/authentication/src/migration/providersJsonUi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/authentication/src/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts index 1e6dea77b3ef..3228ffc95b52 100644 --- a/extensions/authentication/src/migration/providersJsonUi.ts +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -80,7 +80,7 @@ async function migrateAndReport(opts: { overwrite: boolean }): Promise { const viewFileAction = vscode.l10n.t('View File'); const showLogAction = vscode.l10n.t('Show Log'); const choice = await vscode.window.showInformationMessage( - vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron reads provider configuration from this file; your original settings were not removed.', result.settingCount), + vscode.l10n.t('Migrated {0} setting(s) to ~/.posit/ai/providers.json. Positron now reads Posit Assistant providers from this file; your original settings were not removed.', result.settingCount), viewFileAction, showLogAction ); From d40d35f85f4ac726a363ff4d0333c2039b10fbe8 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 13:11:47 -0400 Subject: [PATCH 20/29] Bump ai-lib to the merged model-capabilities release --- ai-lib | 2 +- extensions/authentication/package-lock.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ai-lib b/ai-lib index f93769910423..e5537a0691e9 160000 --- a/ai-lib +++ b/ai-lib @@ -1 +1 @@ -Subproject commit f937699104230606abbed03401ec6f5a50fdf468 +Subproject commit e5537a0691e94ed8b4470270b8ac588591be7c29 diff --git a/extensions/authentication/package-lock.json b/extensions/authentication/package-lock.json index a7b834453d1e..7afd118de99f 100644 --- a/extensions/authentication/package-lock.json +++ b/extensions/authentication/package-lock.json @@ -31,6 +31,7 @@ "@types/node": "22.x", "@types/proper-lockfile": "^4.1.4", "@types/vscode": "^1.100.0", + "tsx": "^4.20.6", "typescript": "^7.0.2", "vitest": "^3.2.1" }, From 26d1b0b8d5e3a976acec3158a900c218aaecc0df Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Thu, 16 Jul 2026 16:49:15 -0400 Subject: [PATCH 21/29] Add activation event for the migrate settings command --- extensions/authentication/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index f426efbded85..4eb6eb0ba372 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -21,7 +21,8 @@ "onAuthenticationRequest:google-cloud", "onAuthenticationRequest:deepseek-api", "onCommand:authentication.configureProviders", - "onCommand:authentication.migrateApiKey" + "onCommand:authentication.migrateApiKey", + "onCommand:authentication.migrateSettingsToProvidersJson" ], "main": "./out/extension.js", "scripts": { From e708f4d179c53f06969bb4f8a53afe1958ef8db6 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 17 Jul 2026 10:03:37 -0400 Subject: [PATCH 22/29] Bump ai-lib to the merged husky prepare guard --- ai-lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-lib b/ai-lib index e5537a0691e9..ed13e06f9d1f 160000 --- a/ai-lib +++ b/ai-lib @@ -1 +1 @@ -Subproject commit e5537a0691e94ed8b4470270b8ac588591be7c29 +Subproject commit ed13e06f9d1f349fedb9f30ed0b3337f03d56dde From 0e7fb29542cfdfa9b39fb8bf632dbff149fc6668 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 17 Jul 2026 10:38:14 -0400 Subject: [PATCH 23/29] Remove the dead migrateApiKey command --- extensions/authentication/package.json | 1 - extensions/authentication/src/configDialog.ts | 10 ------ extensions/authentication/src/extension.ts | 2 -- .../authentication/src/migration/apiKey.ts | 34 ------------------- 4 files changed, 47 deletions(-) delete mode 100644 extensions/authentication/src/migration/apiKey.ts diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 4eb6eb0ba372..85b59f984906 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -21,7 +21,6 @@ "onAuthenticationRequest:google-cloud", "onAuthenticationRequest:deepseek-api", "onCommand:authentication.configureProviders", - "onCommand:authentication.migrateApiKey", "onCommand:authentication.migrateSettingsToProvidersJson" ], "main": "./out/extension.js", diff --git a/extensions/authentication/src/configDialog.ts b/extensions/authentication/src/configDialog.ts index 0434bb8b0096..b15e304c39a2 100644 --- a/extensions/authentication/src/configDialog.ts +++ b/extensions/authentication/src/configDialog.ts @@ -48,16 +48,6 @@ export function registerAuthProvider( } } -/** - * Get the auth provider for a given provider ID. - * Used by the migrateApiKey command. - */ -export function getAuthProvider( - providerId: string -): AuthProvider | undefined { - return authProviders.get(providerId); -} - /** * Update a provider's signedIn and autoconfigure state from its current sessions. * The caller is responsible for fetching sessions via the appropriate mechanism. diff --git a/extensions/authentication/src/extension.ts b/extensions/authentication/src/extension.ts index d51907924fea..fa38f8d243cf 100644 --- a/extensions/authentication/src/extension.ts +++ b/extensions/authentication/src/extension.ts @@ -43,7 +43,6 @@ import * as fs from 'fs'; import { log } from './log'; import { migrateAwsSettings } from './migration/aws'; import { migrateSnowflakeSettings } from './migration/snowflake'; -import { registerMigrateApiKeyCommand } from './migration/apiKey'; import { registerProvidersJsonMigration } from './migration/providersJsonUi'; import { AuthProviderLogger } from './authProviderLogger'; import { applyPwbPositAIDefault } from './pwbDefaults'; @@ -143,7 +142,6 @@ export async function activate(context: vscode.ExtensionContext) { } ), ); - registerMigrateApiKeyCommand(context); registerProvidersJsonMigration(context); return { getLogs: () => log.formatEntriesForDiagnostics() }; diff --git a/extensions/authentication/src/migration/apiKey.ts b/extensions/authentication/src/migration/apiKey.ts deleted file mode 100644 index b046ad6bf489..000000000000 --- a/extensions/authentication/src/migration/apiKey.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (C) 2026 Posit Software, PBC. All rights reserved. - * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { getAuthProvider } from '../configDialog'; -import { log } from '../log'; - -/** - * Register the command that migrates API keys from positron-assistant - * secret storage to the auth extension. - */ -export function registerMigrateApiKeyCommand( - context: vscode.ExtensionContext -): void { - context.subscriptions.push( - vscode.commands.registerCommand( - 'authentication.migrateApiKey', - async (providerId: string, accountId: string, label: string, key: string) => { - const provider = getAuthProvider(providerId); - if (!provider) { - throw new Error(vscode.l10n.t("No auth provider registered for {0}", providerId)); - } - const existing = await provider.getSessions([], { account: { id: accountId, label: '' } }); - if (existing.length > 0) { - log.info(`Skipping migration for ${providerId}/${accountId}: session already exists`); - return; - } - await provider.storeKey(accountId, label, key); - } - ) - ); -} From 62b4f1e3f90109c8f7f37abe83daf062d0e570b9 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 20 Jul 2026 10:22:58 -0400 Subject: [PATCH 24/29] Migrate the Foundry base URL verbatim --- extensions/authentication/src/migration/providersJson.ts | 9 +++------ .../authentication/src/test/providersJsonMapping.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/extensions/authentication/src/migration/providersJson.ts b/extensions/authentication/src/migration/providersJson.ts index 4b91fa182ba9..159a64fcacaa 100644 --- a/extensions/authentication/src/migration/providersJson.ts +++ b/extensions/authentication/src/migration/providersJson.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import type { InferredModelCapabilities, ProvidersConfig } from 'ai-config/node'; -import { normalizeToV1Url } from '../validation'; export type InferCapabilitiesFn = (providerId: string, modelId: string) => InferredModelCapabilities; @@ -38,7 +37,6 @@ export interface MappedProvidersConfig { interface ApiKeyConnectionSetting { readonly configKey: string; readonly providerId: string; - readonly normalizeBaseUrl?: (url: string) => string; } /** authentication..{baseUrl,customHeaders} -> providers.. */ @@ -47,7 +45,7 @@ const API_KEY_CONNECTION_SETTINGS: readonly ApiKeyConnectionSetting[] = [ { configKey: 'openai-api', providerId: 'openai' }, { configKey: 'google', providerId: 'gemini' }, { configKey: 'deepseek-api', providerId: 'deepseek' }, - { configKey: 'foundry', providerId: 'ms-foundry', normalizeBaseUrl: normalizeToV1Url }, + { configKey: 'foundry', providerId: 'ms-foundry' }, { configKey: 'openai-compatible', providerId: 'openai-compatible' }, { configKey: 'googleVertex', providerId: 'google-vertex' }, ]; @@ -135,9 +133,8 @@ export function buildProvidersConfigFromSettings( for (const s of API_KEY_CONNECTION_SETTINGS) { const rawBaseUrl = nonEmptyString(reader.globalValue(`authentication.${s.configKey}.baseUrl`)); if (rawBaseUrl) { - const baseUrl = s.normalizeBaseUrl ? s.normalizeBaseUrl(rawBaseUrl) : rawBaseUrl; - merge(s.providerId, { baseUrl }); - record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`, JSON.stringify(baseUrl)); + merge(s.providerId, { baseUrl: rawBaseUrl }); + record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`, JSON.stringify(rawBaseUrl)); } const headers = nonEmptyHeaders(reader.globalValue>(`authentication.${s.configKey}.customHeaders`)); if (headers) { diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index 7beb7d74171f..4104091680aa 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -37,13 +37,13 @@ suite('buildProvidersConfigFromSettings', () => { assert.strictEqual(result?.settingCount, 3); }); - test('normalizes the foundry base URL', () => { + test('copies the foundry base URL verbatim', () => { const result = buildProvidersConfigFromSettings(readerOf({ 'authentication.foundry.baseUrl': 'https://my-resource.services.ai.azure.com', }), fakeCaps); - // normalizeToV1Url appends the versioned path; assert against its real output. - assert.ok(result?.config.providers?.['ms-foundry']?.baseUrl?.startsWith('https://my-resource.services.ai.azure.com')); - assert.notStrictEqual(result?.config.providers?.['ms-foundry']?.baseUrl, 'https://my-resource.services.ai.azure.com'); + assert.deepStrictEqual(result?.config.providers?.['ms-foundry'], { + baseUrl: 'https://my-resource.services.ai.azure.com', + }); }); test('omits empty strings and empty header maps', () => { From 1656bef77dc4aad56993b3f5974dd30ed34c41a7 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 20 Jul 2026 11:03:03 -0400 Subject: [PATCH 25/29] Validate the migrated config and report schema failures --- .../src/migration/migrateToProvidersJson.ts | 9 ++++-- .../src/migration/providersJson.ts | 19 +++++++----- .../src/migration/providersJsonUi.ts | 29 +++++++++++++++++-- .../src/test/providersJsonMapping.test.ts | 27 ++++++++++------- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts index a9368e7efcab..19f1a7531e83 100644 --- a/extensions/authentication/src/migration/migrateToProvidersJson.ts +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -102,13 +102,18 @@ export async function userProvidersFileIsPopulated(configPath?: string): Promise */ export async function runMigration(opts: RunMigrationOptions): Promise { const reader = opts.reader ?? createGlobalSettingsReader(); - const { mutateProvidersConfig, inferModelCapabilities } = await import('ai-config/node'); + const { mutateProvidersConfig, inferModelCapabilities, providersConfigSchema } = await import('ai-config/node'); const mapped = buildProvidersConfigFromSettings(reader, opts.inferCapabilities ?? inferModelCapabilities); if (!mapped) { log.info('[migration] No provider settings to migrate'); return { outcome: 'nothing-to-migrate' }; } + // The builder assembles loosely-typed blocks; validate the assembled config + // against ai-config's schema before writing so a bad mapping fails loudly + // here instead of writing malformed providers.json. + const config = providersConfigSchema.parse(mapped.config); + if (!opts.overwrite && await userProvidersFileIsPopulated(opts.configPath)) { log.info('[migration] providers.json already has provider config; skipped'); return { outcome: 'skipped-populated' }; @@ -121,7 +126,7 @@ export async function runMigration(opts: RunMigrationOptions): Promise InferredModelCapabilities; @@ -26,8 +26,17 @@ export interface SettingMigration { readonly value: string; } +/** A single provider block: plain data assembled field by field. */ +type Block = Record; + export interface MappedProvidersConfig { - config: ProvidersConfig; + /** + * Loosely-typed assembled config. runMigration validates it through + * providersConfigSchema before writing; the builder stays synchronous + * (hasMigratableSettings needs it) and cannot reach the schema value, which + * ai-config/node only exposes via dynamic import. + */ + config: { providers: Record }; /** Number of settings.json entries consumed (for the success toast). */ settingCount: number; /** Source-to-destination record of every value written (for logging). */ @@ -111,10 +120,6 @@ export const MIGRATABLE_SETTING_KEYS: readonly string[] = [ ...MODEL_OVERRIDE_SETTINGS.map(s => `positron.assistant.models.overrides.${s.settingName}`), ]; -// providers.json blocks are plain data; build them as records and assign into -// the (structurally typed) ProvidersConfig at the end. -type Block = Record; - export function buildProvidersConfigFromSettings( reader: MigrationSettingsReader, inferCapabilities: InferCapabilitiesFn @@ -225,7 +230,7 @@ export function buildProvidersConfigFromSettings( return undefined; } return { - config: { providers } as ProvidersConfig, + config: { providers }, settingCount: new Set(migrations.map(m => m.source)).size, migrations, }; diff --git a/extensions/authentication/src/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts index 3228ffc95b52..edfdd2813ac2 100644 --- a/extensions/authentication/src/migration/providersJsonUi.ts +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -68,9 +68,10 @@ async function migrateAndReport(opts: { overwrite: boolean }): Promise { try { result = await runMigration(opts); } catch (err) { - log.error(`providers.json migration failed: ${err}`); + const detail = formatMigrationError(err); + log.error(`providers.json migration failed: ${detail}`); vscode.window.showErrorMessage( - vscode.l10n.t('Failed to migrate provider settings: {0}. No changes were made to your settings.', String(err)) + vscode.l10n.t('Failed to migrate provider settings: {0}. No changes were made to your settings.', detail) ); return; } @@ -105,3 +106,27 @@ async function migrateAndReport(opts: { overwrite: boolean }): Promise { break; } } + +/** A Zod-style validation error: a list of per-field issues. */ +interface ZodLikeError { + issues: Array<{ message: string; path?: unknown[] }>; +} + +function isZodLikeError(err: unknown): err is ZodLikeError { + return !!err && typeof err === 'object' && Array.isArray((err as ZodLikeError).issues); +} + +/** + * Render a migration failure as a concise message. Schema validation throws a + * Zod error whose issues pinpoint the offending fields (e.g. an empty model + * name); flatten those to `path: message` pairs instead of the verbose default + * dump. Any other error falls back to its string form. + */ +function formatMigrationError(err: unknown): string { + if (isZodLikeError(err)) { + return err.issues + .map(issue => `${issue.path?.join('.') ?? ''}: ${issue.message}`) + .join('; '); + } + return String(err); +} diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts index 4104091680aa..ce58c797eeb6 100644 --- a/extensions/authentication/src/test/providersJsonMapping.test.ts +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -99,15 +99,21 @@ suite('buildProvidersConfigFromSettings', () => { { identifier: 'missing-name' }, // malformed: skipped ], }), fakeCaps); - const models = result?.config.providers?.anthropic?.models; - assert.strictEqual(models?.discovery, 'off'); - assert.strictEqual(models?.custom?.length, 1); - const model = models.custom[0]; - assert.strictEqual(model.id, 'claude-sonnet-4-5'); - assert.strictEqual(model.name, 'Sonnet (team)'); - assert.strictEqual(model.maxInputTokens, 300_000); - // maxContextLength floored at the user's maxInputTokens. - assert.ok(model.maxContextLength >= 300_000); + // The malformed entry is skipped; maxContextLength floors at the user's + // maxInputTokens (300_000 > the fakeCaps 128_000). + assert.deepStrictEqual(result?.config.providers?.anthropic?.models, { + discovery: 'off', + custom: [{ + id: 'claude-sonnet-4-5', + name: 'Sonnet (team)', + maxContextLength: 300_000, + maxInputTokens: 300_000, + supportsTools: true, + supportsImages: false, + supportsToolResultImages: false, + supportsWebSearch: false, + }], + }); }); test('an overrides array with only malformed entries maps nothing', () => { @@ -121,7 +127,8 @@ suite('buildProvidersConfigFromSettings', () => { const result = buildProvidersConfigFromSettings(readerOf({ 'positron.assistant.models.overrides.anthropic': [{ name: 'Sonnet', identifier: 'claude-sonnet-4-5' }], }), inferModelCapabilities); - customModelSchema.parse(result?.config.providers?.anthropic?.models?.custom?.[0]); + const models = result?.config.providers?.anthropic?.models as { custom?: unknown[] } | undefined; + customModelSchema.parse(models?.custom?.[0]); }); test('every migratable setting maps to config the real ai-config schema accepts', async () => { From 258ad69a82879b3fceaba0d313377f238e46f2a7 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 20 Jul 2026 14:13:35 -0400 Subject: [PATCH 26/29] save automatic migration and setting deprecation for the future --- extensions/authentication/package.json | 38 ------------------- .../src/migration/providersJsonUi.ts | 9 ++--- 2 files changed, 4 insertions(+), 43 deletions(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 85b59f984906..d080a8a007b3 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -95,7 +95,6 @@ "title": "Authentication", "properties": { "authentication.aws.credentials": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.aws.credentials.description%", @@ -112,13 +111,11 @@ "additionalProperties": false }, "authentication.aws.inferenceProfileRegion": { - "markdownDeprecationMessage": "%configuration.deprecation.inferenceProfileRegion%", "type": "string", "default": null, "markdownDescription": "%configuration.aws.inferenceProfileRegion.description%" }, "authentication.snowflake.credentials": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.snowflake.credentials.description%", @@ -135,7 +132,6 @@ "additionalProperties": false }, "authentication.snowflake.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -147,13 +143,11 @@ ] }, "authentication.anthropic.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.anthropic.baseUrl.description%" }, "authentication.anthropic.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -165,13 +159,11 @@ ] }, "authentication.foundry.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.foundry.baseUrl.description%" }, "authentication.foundry.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -183,13 +175,11 @@ ] }, "authentication.openai-api.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.openai-api.baseUrl.description%" }, "authentication.openai-api.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -201,7 +191,6 @@ ] }, "authentication.google.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.google.baseUrl.description%", @@ -211,7 +200,6 @@ ] }, "authentication.google.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -224,7 +212,6 @@ ] }, "authentication.googleVertex.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.googleVertex.baseUrl.description%", @@ -233,7 +220,6 @@ ] }, "authentication.googleVertex.credentials": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "markdownDescription": "%configuration.googleVertex.credentials.description%", @@ -253,7 +239,6 @@ ] }, "authentication.googleVertex.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -265,7 +250,6 @@ ] }, "authentication.openai-compatible.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.openai-compatible.baseUrl.description%", @@ -274,7 +258,6 @@ ] }, "authentication.openai-compatible.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -286,7 +269,6 @@ ] }, "authentication.deepseek-api.baseUrl": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "string", "default": "", "description": "%configuration.deepseek-api.baseUrl.description%", @@ -295,7 +277,6 @@ ] }, "authentication.deepseek-api.customHeaders": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "object", "default": {}, "additionalProperties": { @@ -307,7 +288,6 @@ ] }, "assistant.provider.googleVertex.enabled": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.googleVertex.enabled.description%", @@ -316,7 +296,6 @@ ] }, "assistant.provider.deepseek.enabled": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.deepseek.enabled.description%", @@ -325,14 +304,12 @@ ] }, "positron.assistant.provider.anthropic.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.anthropic.enable.description%", "order": 1 }, "positron.assistant.provider.githubCopilot.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.githubCopilot.enable.description%", @@ -342,35 +319,30 @@ "order": 2 }, "positron.assistant.provider.amazonBedrock.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.amazonBedrock.enable.description%", "order": 3 }, "positron.assistant.provider.snowflakeCortex.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.snowflakeCortex.enable.description%", "order": 4 }, "positron.assistant.provider.msFoundry.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.msFoundry.enable.description%", "order": 5 }, "positron.assistant.provider.openAI.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.openAI.enable.description%", "order": 6 }, "positron.assistant.provider.customProvider.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.customProvider.enable.description%", @@ -380,14 +352,12 @@ "order": 7 }, "positron.assistant.provider.positAI.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.positAI.enable.description%", "order": 8 }, "positron.assistant.provider.google.enable": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "boolean", "default": true, "markdownDescription": "%configuration.provider.google.enable.description%", @@ -397,7 +367,6 @@ "order": 9 }, "positron.assistant.models.overrides.anthropic": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.anthropic.description%", @@ -440,7 +409,6 @@ ] }, "positron.assistant.models.overrides.amazonBedrock": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.amazonBedrock.description%", @@ -477,7 +445,6 @@ } }, "positron.assistant.models.overrides.snowflakeCortex": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.snowflakeCortex.description%", @@ -520,7 +487,6 @@ ] }, "positron.assistant.models.overrides.msFoundry": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.msFoundry.description%", @@ -553,7 +519,6 @@ } }, "positron.assistant.models.overrides.openAI": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.openAI.description%", @@ -594,7 +559,6 @@ ] }, "positron.assistant.models.overrides.customProvider": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.customProvider.description%", @@ -640,7 +604,6 @@ ] }, "positron.assistant.models.overrides.positAI": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.positAI.description%", @@ -673,7 +636,6 @@ } }, "positron.assistant.models.overrides.google": { - "markdownDeprecationMessage": "%configuration.deprecation.providersJson%", "type": "array", "default": [], "markdownDescription": "%configuration.models.overrides.google.description%", diff --git a/extensions/authentication/src/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts index edfdd2813ac2..83b877c85379 100644 --- a/extensions/authentication/src/migration/providersJsonUi.ts +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -14,15 +14,14 @@ import { export const MIGRATE_COMMAND_ID = 'authentication.migrateSettingsToProvidersJson'; -/** Registers the migration command and runs the one-time automatic migration. */ export function registerProvidersJsonMigration(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand(MIGRATE_COMMAND_ID, () => runMigrationCommand()) ); - // Fire-and-forget: never block activation on the migration. - maybeAutoMigrate().catch(err => - log.error(`providers.json automatic migration failed: ${err}`) - ); + // Automatic migration is disabled until providers.json is fully in use + // maybeAutoMigrate().catch(err => + // log.error(`providers.json automatic migration failed: ${err}`) + // ); } async function runMigrationCommand(): Promise { From 740f4137357060280d42dfd6a3554487982da0c6 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 21 Jul 2026 14:16:00 -0400 Subject: [PATCH 27/29] Initialize and sync the ai-lib submodule on npm install --- build/npm/installStateHash.ts | 36 +++++++++++++++------------- build/npm/postinstall.ts | 44 +++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 34 deletions(-) diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index d3c82a0afd4c..89b8ffe7feb0 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -18,14 +18,15 @@ export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts - // --- Start Positron --- // The ark binary in extensions/positron-r/resources/ark is resolved by -// install-kernel.ts based on the submodule's HEAD SHA. Include that SHA in -// the postinstall state so a submodule pointer change invalidates the -// "up to date" check and re-runs the extensions install. -const arkSubmodulePath = 'extensions/positron-r/ark'; -const arkVirtualKey = `${arkSubmodulePath}@HEAD`; - -function getArkSubmoduleSha(): string | undefined { - const submoduleDir = path.join(root, arkSubmodulePath); +// install-kernel.ts based on the submodule's HEAD SHA, and the ai-config dist +// is built from the ai-lib submodule by the authentication extension's +// postinstall. Include those SHAs in the postinstall state so a submodule +// pointer change invalidates the "up to date" check and re-runs the +// extensions install. +const submodulePaths = ['extensions/positron-r/ark', 'ai-lib']; + +function getSubmoduleSha(submodulePath: string): string | undefined { + const submoduleDir = path.join(root, submodulePath); if (!fs.existsSync(path.join(submoduleDir, '.git'))) { return undefined; } @@ -37,6 +38,15 @@ function getArkSubmoduleSha(): string | undefined { return undefined; } } + +function addSubmoduleShas(fileMap: Record): void { + for (const submodulePath of submodulePaths) { + const sha = getSubmoduleSha(submodulePath); + if (sha) { + fileMap[`${submodulePath}@HEAD`] = sha; + } + } +} // --- End Positron --- export function collectInputFiles(): string[] { @@ -124,10 +134,7 @@ export function computeState(options?: { ignoreNodeVersion?: boolean }): Postins } } // --- Start Positron --- - const arkSha = getArkSubmoduleSha(); - if (arkSha) { - fileHashes[arkVirtualKey] = arkSha; - } + addSubmoduleShas(fileHashes); // --- End Positron --- return { nodeVersion: options?.ignoreNodeVersion ? '' : process.versions.node, fileHashes }; } @@ -142,10 +149,7 @@ export function computeContents(): Record { } } // --- Start Positron --- - const arkSha = getArkSubmoduleSha(); - if (arkSha) { - fileContents[arkVirtualKey] = arkSha; - } + addSubmoduleShas(fileContents); // --- End Positron --- return fileContents; } diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index c6ed1cd4051d..a2769d48e2ab 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -355,24 +355,23 @@ function generateRehWebPackageJson() { } /** - * If the ark submodule pointer recorded in this commit differs from what's - * currently checked out in `extensions/positron-r/ark`, sync it — but only - * when it's safe to do so. "Safe" means: the current submodule HEAD is - * detached and is an ancestor of ark's `origin/main` (i.e., no in-progress - * dev work would be lost). Anything else (named branch, unmerged commits, - * offline, CI) is left alone. + * If the submodule pointer recorded in this commit differs from what's + * currently checked out, sync it — but only when it's safe to do so. "Safe" + * means: the current submodule HEAD is detached and is an ancestor of the + * submodule's `origin/main` (i.e., no in-progress dev work would be lost). + * Anything else (named branch, unmerged commits, offline, CI) is left alone. * * Runs before the dirs loop so the subsequent extensions install (which - * triggers install-kernel) sees the synced submodule contents. + * triggers install-kernel and the authentication extension's postinstall) + * sees the synced submodule contents. */ -async function syncArkSubmoduleIfSafe(): Promise { +async function syncSubmoduleIfSafe(submodulePath: string): Promise { // Skip in CI — checkouts there already pin the submodule via `submodules: true`. if (process.env['CI']) { - log('.', 'Skipping ark submodule sync in CI environment'); + log(submodulePath, 'Skipping submodule sync in CI environment'); return; } - const submodulePath = 'extensions/positron-r/ark'; const submoduleAbs = path.join(root, submodulePath); // Not initialized yet — install-kernel's ensureSubmoduleReady handles that path. @@ -404,7 +403,7 @@ async function syncArkSubmoduleIfSafe(): Promise { if (branch) { log(submodulePath, `Pointer differs (${currentSha.slice(0, 7)} → ${recordedSha.slice(0, 7)}), ` + - `but ark is on branch '${branch}'. Skipping auto-sync.`); + `but the submodule is on branch '${branch}'. Skipping auto-sync.`); return; } } catch { @@ -425,12 +424,12 @@ async function syncArkSubmoduleIfSafe(): Promise { } catch { log(submodulePath, `Pointer differs (${currentSha.slice(0, 7)} → ${recordedSha.slice(0, 7)}), ` + - `but HEAD is not reachable from ark's origin/main — looks like in-progress ` + - `work. Skipping. Run \`git submodule update -- ${submodulePath}\` to sync manually.`); + `but HEAD is not reachable from the submodule's origin/main — looks like ` + + `in-progress work. Skipping. Run \`git submodule update -- ${submodulePath}\` to sync manually.`); return; } - log(submodulePath, `Syncing ark submodule ${currentSha.slice(0, 7)} → ${recordedSha.slice(0, 7)}...`); + log(submodulePath, `Syncing submodule ${currentSha.slice(0, 7)} → ${recordedSha.slice(0, 7)}...`); run('git', ['submodule', 'update', '--', submodulePath], { cwd: root, stdio: 'inherit' }); } // --- End Positron --- @@ -488,12 +487,21 @@ async function runWithConcurrency(tasks: (() => Promise)[], concurrency: n async function main() { // --- Start Positron --- - // Sync the ark submodule before anything else — extensions install runs - // install-kernel, which reads the submodule's working tree. + // Sync the submodules before anything else — extensions install runs + // install-kernel, which reads the ark submodule's working tree, and the + // authentication extension's postinstall, which builds ai-config from the + // ai-lib submodule. Unlike ark, ai-lib has no install step of its own to + // initialize it, so a missing ai-lib checkout is initialized here first — + // there is no working tree to lose. try { - await syncArkSubmoduleIfSafe(); + if (!fs.existsSync(path.join(root, 'ai-lib', '.git'))) { + log('ai-lib', 'Submodule not initialized; running `git submodule update --init`...'); + run('git', ['submodule', 'update', '--init', '--', 'ai-lib'], { cwd: root, stdio: 'inherit' }); + } + await syncSubmoduleIfSafe('extensions/positron-r/ark'); + await syncSubmoduleIfSafe('ai-lib'); } catch (err) { - console.error('Error in syncArkSubmoduleIfSafe:', err); + console.error('Error syncing submodules:', err); throw err; } // --- End Positron --- From 73afb884e62cec0b18f2342f1da7bcec1c2afcff Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 21 Jul 2026 14:16:12 -0400 Subject: [PATCH 28/29] Deprecate the unread inferenceProfileRegion setting --- extensions/authentication/package.json | 1 + extensions/authentication/package.nls.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index d080a8a007b3..66c91418889f 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -111,6 +111,7 @@ "additionalProperties": false }, "authentication.aws.inferenceProfileRegion": { + "markdownDeprecationMessage": "%configuration.deprecation.inferenceProfileRegion%", "type": "string", "default": null, "markdownDescription": "%configuration.aws.inferenceProfileRegion.description%" diff --git a/extensions/authentication/package.nls.json b/extensions/authentication/package.nls.json index bed3d83ed1db..b422cc2d6fb7 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -39,7 +39,7 @@ "configuration.models.overrides.google.description": "Model overrides for Google Gemini provider.\n\nThese models will be used instead of retrieving the model listing from the provider. Each model must have a `name` (display name) and `identifier` (model ID for API calls). Optionally specify `maxInputTokens` and `maxOutputTokens`.\n\nRequires a restart to take effect.", "configuration.aiExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from AI features. Files matching these patterns will not have their contents sent to AI providers for inline completions or chat context. Patterns without a `/` will match against the filename only (e.g., `*.py` matches all Python files). Use `**/` prefix for explicit recursive matching.", "configuration.deprecation.providersJson": "This setting is deprecated. Use `~/.posit/ai/providers.json` instead.", - "configuration.deprecation.inferenceProfileRegion": "This setting is deprecated and no longer read. Use a custom model in `~/.posit/ai/providers.json` instead.", + "configuration.deprecation.inferenceProfileRegion": "This setting is deprecated and no longer read. Bedrock cross-region inference profiles are derived from the AWS region.", "commands.configureProviders.title": "Configure Language Model Providers", "commands.migrateSettingsToProvidersJson.title": "Migrate AI Provider Settings to providers.json", "commands.category": "Authentication" From d99fd2f142e578d373a2d765b22ecbab91606e17 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 21 Jul 2026 14:16:14 -0400 Subject: [PATCH 29/29] Remove the dead models.preference listener and tests --- .../contrib/chat/common/languageModels.ts | 3 - test/e2e/pages/positronAssistant.ts | 137 ------------------ .../assistant/positron-assistant.test.ts | 132 ----------------- 3 files changed, 272 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 698cdc481044..4124fd786145 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -923,9 +923,6 @@ export class LanguageModelsService implements ILanguageModelsService { if (e.affectsConfiguration('positron.assistant.models.overrides')) { this._logService.trace('[LM] Model overrides configuration changed, re-resolving language models'); this._reResolveLanguageModels(); - } else if (e.affectsConfiguration('positron.assistant.models.preference')) { - this._logService.trace('[LM] Model preference configuration changed, re-resolving language models'); - this._reResolveLanguageModels(); } })); diff --git a/test/e2e/pages/positronAssistant.ts b/test/e2e/pages/positronAssistant.ts index fb6ef6ca210b..c43e4ca526fd 100644 --- a/test/e2e/pages/positronAssistant.ts +++ b/test/e2e/pages/positronAssistant.ts @@ -1014,143 +1014,6 @@ export class Assistant { } } - /** - * Asserts that a model item with the given text exists in the picker dropdown. - * The dropdown must already be open before calling this method. - * @param text The text to match (string for contains, RegExp for exact matching) - */ - async expectModelInPicker(text: string | RegExp): Promise { - await test.step(`Expect model in picker: ${text}`, async () => { - await this._expandOtherModelsSection(); - const filterInput = this.code.driver.currentPage.locator('.action-list-filter-input'); - const locator = this.code.driver.currentPage.locator('.monaco-list-row.action span.title', { hasText: text }); - - if (await filterInput.isVisible()) { - // Use the search input to filter models — avoids scrolling in virtualized list - const searchText = typeof text === 'string' ? text : text.source.replace(/^\^/, '').replace(/\$$/, ''); - await filterInput.fill(searchText); - try { - await expect(locator).toHaveCount(1, { timeout: 3000 }); - } finally { - // Always clear the filter so MODEL_DROPDOWN_ITEM items remain visible for - // subsequent calls (closeModelPickerDropdown relies on them being visible). - await filterInput.clear(); - } - } else { - // No filter input available; item should be directly visible - await expect(locator).toHaveCount(1, { timeout: 10000 }); - } - }); - } - - /** - * Asserts that a vendor separator with the given name exists in the picker dropdown. - * The dropdown must already be open before calling this method. - * @param vendor The vendor name to match - */ - async expectVendorSeparator(vendor: string): Promise { - await test.step(`Expect vendor separator: ${vendor}`, async () => { - await this._expandOtherModelsSection(); - const locator = this.code.driver.currentPage.locator('.monaco-list-row.separator span.separator-text', { hasText: vendor }); - await expect(locator).toHaveCount(1); - }); - } - - /** - * Gets all model items from the model picker dropdown. - * Returns an array of objects containing the model label and whether it's marked as default. - * The dropdown must already be open before calling this method. - */ - async getModelPickerItems(): Promise> { - // Select only action items (models), excluding separators (vendor headers) - const modelRows = this.code.driver.currentPage.locator('.monaco-list-row.action'); - const titles = await modelRows.locator('span.title').allTextContents(); - - return titles - .map(text => text.trim()) - .filter(text => text.length > 0) - .map(label => ({ - label, - isDefault: label.includes('(default)') - })); - } - - /** - * Gets model items for a specific vendor from the model picker dropdown. - * Returns models in their displayed order. - * The dropdown must already be open before calling this method. - * @param vendor The vendor name to filter by (e.g., 'Echo', 'Anthropic') - */ - async getModelPickerItemsForVendor(vendor: string): Promise> { - await this._expandOtherModelsSection(); - const allRows = this.code.driver.currentPage.locator('.action-widget .monaco-list-row'); - const count = await allRows.count(); - const vendorModels: Array<{ label: string; isDefault: boolean }> = []; - let inVendorSection = false; - - for (let i = 0; i < count; i++) { - const item = allRows.nth(i); - const classAttr = await item.getAttribute('class') || ''; - - // Check if this is a separator (vendor header) - if (classAttr.includes('separator')) { - const labelText = await item.locator('span.separator-text').textContent(); - inVendorSection = labelText?.trim().toLowerCase() === vendor.toLowerCase(); - continue; - } - - // If we're in the vendor section and this is an action item, collect the model - if (inVendorSection && classAttr.includes('action')) { - const titleText = await item.locator('span.title').textContent(); - if (titleText) { - const label = titleText.trim(); - vendorModels.push({ - label, - isDefault: label.includes('(default)') - }); - } - } - } - - return vendorModels; - } - - /** - * Verifies that a specific model shows the "(default)" indicator in the model picker. - * @param modelName The base model name (without the "(default)" suffix) - */ - async verifyModelHasDefaultIndicator(modelName: string) { - await test.step(`Verify model "${modelName}" has default indicator`, async () => { - const models = await this.getModelPickerItems(); - const modelWithDefault = models.find(m => m.label === `${modelName} (default)`); - expect(modelWithDefault, `Expected to find model "${modelName}" with "(default)" indicator`).toBeDefined(); - expect(modelWithDefault?.isDefault).toBe(true); - }); - } - - /** - * Verifies that a model does NOT have the "(default)" indicator. - * @param modelName The base model name - */ - async verifyModelDoesNotHaveDefaultIndicator(modelName: string) { - await test.step(`Verify model "${modelName}" does not have default indicator`, async () => { - const models = await this.getModelPickerItems(); - const modelWithDefault = models.find(m => m.label === `${modelName} (default)`); - expect(modelWithDefault, `Expected model "${modelName}" to NOT have "(default)" indicator`).toBeUndefined(); - }); - } - - /** - * Closes the model picker dropdown by pressing Escape if it is open. - */ - async closeModelPickerDropdown() { - const dropdownItem = this.code.driver.currentPage.locator(MODEL_DROPDOWN_ITEM).first(); - if (await dropdownItem.isVisible()) { - await this.code.driver.currentPage.keyboard.press('Escape'); - await expect(dropdownItem).not.toBeVisible(); - } - } - async getChatResponseText(exportFolder?: string) { // Export and find the chat file with retry (export may silently fail or file may not be ready) let chatExportFile: string | null = null; diff --git a/test/e2e/tests/assistant/positron-assistant.test.ts b/test/e2e/tests/assistant/positron-assistant.test.ts index f9e74d569724..76152ae7cd70 100644 --- a/test/e2e/tests/assistant/positron-assistant.test.ts +++ b/test/e2e/tests/assistant/positron-assistant.test.ts @@ -178,135 +178,3 @@ test.describe.skip('Positron Assistant Chat Editing', { tag: [tags.WIN, tags.ASS }).toPass({ timeout: 30000 }); }); }); - -/** - * Test suite for the model picker default indicator feature. - * Verifies that models configured as default show "(default)" suffix. - * @see https://github.com/posit-dev/positron/issues/11166 - * @see https://github.com/posit-dev/positron/pull/11299 - */ -test.describe.skip('Positron Assistant Model Picker Default Indicator', { tag: [tags.WIN, tags.ASSISTANT, tags.WEB] }, () => { - test.beforeAll('Enable Assistant and sign in to Echo provider', async function ({ app }) { - await app.workbench.assistant.openPositronAssistantChat(); - await app.workbench.assistant.loginModelProvider('echo'); - }); - - test.afterAll('Sign out of Assistant', async function ({ app }) { - await expect(async () => { - await app.workbench.assistant.logoutModelProvider('echo'); - }).toPass({ timeout: 30000 }); - }); - - /** - * Test Case 1: Single Provider with Default Model - * Verifies that when a user configures a default model for a provider, - * the model picker shows the "(default)" suffix next to the model's name - * and no suffix on the other models. - * - * Note: this test previously also asserted "default model appears first - * in its vendor group." Upstream now hoists the selected/recent model into - * a promoted section above the vendor groups and sorts the remaining - * vendor-group models alphabetically, so the ordering assertion is no - * longer meaningful and has been dropped. - */ - test.skip('Verify default model indicator and ordering for single provider', async function ({ settings, assistant }) { - // Configure the Echo Language Model v2 as the default for the echo provider - await settings.set({ - 'positron.assistant.models.preference.echo': 'Echo Language Model v2' - }, { reload: true }); - - // Open the model picker dropdown - await assistant.pickModel(); - - // Verify that Echo Language Model v2 shows "(default)" suffix - await assistant.expectModelInPicker('Echo Language Model v2 (default)'); - - // Verify that the other Echo model does NOT have "(default)" suffix - await assistant.expectModelInPicker(/^Echo$/); - - // Close the dropdown - await assistant.closeModelPickerDropdown(); - - // Clean up: remove the setting (avoids editor interaction that can fail when chat input has focus) - await settings.remove(['positron.assistant.models.preference.echo']); - }); - -}); - -/** - * Test suite for the model picker default indicator with multiple providers. - * Uses loginModelProvider which handles auto-sign-in detection: - * - If ANTHROPIC_API_KEY is set, Anthropic is auto-signed-in (no manual steps needed) - * - If ANTHROPIC_KEY is set, signs in manually using API key - * @see https://github.com/posit-dev/positron/issues/11166 - * @see https://github.com/posit-dev/positron/pull/11299 - */ -test.describe.skip('Positron Assistant Model Picker Default Indicator - Multiple Providers', { tag: [tags.WIN, tags.ASSISTANT, tags.WEB] }, () => { - test.beforeAll('Enable Assistant and sign in to providers', async function ({ app }) { - await app.workbench.assistant.openPositronAssistantChat(); - await app.workbench.assistant.loginModelProvider('echo'); - }); - - test.afterAll('Sign out of providers and clean up', async function ({ app, settings }) { - // Clean up settings (use remove to avoid editor interaction that can fail when chat input has focus) - await settings.remove(['positron.assistant.models.preference.anthropic', 'positron.assistant.models.preference.echo']); - - // Sign out of providers (methods handle auto-sign-in detection) - await expect(async () => { - await app.workbench.assistant.logoutModelProvider('echo'); - }).toPass({ timeout: 30000 }); - - await expect(async () => { - await app.workbench.assistant.logoutModelProvider('anthropic-api'); - }).toPass({ timeout: 30000 }); - }); - - /** - * Test Case 2: Multiple Providers with Different Defaults - * Verifies that when a user configures default models for multiple providers: - * 1. Each provider shows its respective default model with "(default)" suffix - * 2. Each default model appears first in its provider group - */ - // FIXME: The model picker has changed where detecting the default model fails because of the recent model section of the picker - test.fixme('Verify default model indicators and ordering for multiple providers', async function ({ settings, assistant }) { - // Configure defaults for both Anthropic and Echo providers - await settings.set({ - 'positron.assistant.models.preference.anthropic': 'Claude Haiku 4.5', - 'positron.assistant.models.preference.echo': 'Echo Language Model v2' - }, { reload: true }); - - // Sign in to Anthropic (method handles auto-sign-in detection and waits for - // the "Language Model has been added successfully" notification before returning, - // so models are ready in the picker by the time it returns) - await assistant.loginModelProvider('anthropic-api'); - - // Verify Anthropic default - Claude Haiku 4.5 should have "(default)" - await assistant.pickModel(); - await assistant.expectModelInPicker('Claude Haiku 4.5 (default)'); - - // Verify other Anthropic models do NOT have "(default)" - await assistant.expectModelInPicker(/^Claude Sonnet 4$/); - - // Verify Echo default - Echo Language Model v2 should have "(default)" - await assistant.expectModelInPicker('Echo Language Model v2 (default)'); - - // Verify other Echo model does NOT have "(default)" - await assistant.expectModelInPicker(/^Echo$/); - - // Verify vendor separators are visible - await assistant.expectVendorSeparator('Anthropic'); - await assistant.expectVendorSeparator('Echo'); - - // Verify ordering by getting all model items and checking vendor group order - const anthropicModels = await assistant.getModelPickerItemsForVendor('Anthropic'); - expect(anthropicModels.length).toBeGreaterThanOrEqual(2); - expect(anthropicModels[0].label).toBe('Claude Haiku 4.5 (default)'); - - const echoModels = await assistant.getModelPickerItemsForVendor('Echo'); - expect(echoModels.length).toBeGreaterThanOrEqual(2); - expect(echoModels[0].label).toBe('Echo Language Model v2 (default)'); - - // Close dropdown - await assistant.closeModelPickerDropdown(); - }); -});