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..ed13e06f9d1f --- /dev/null +++ b/ai-lib @@ -0,0 +1 @@ +Subproject commit ed13e06f9d1f349fedb9f30ed0b3337f03d56dde 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..7afd118de99f 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,29 @@ "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", + "tsx": "^4.20.6", + "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 +1296,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..d080a8a007b3 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -21,12 +21,16 @@ "onAuthenticationRequest:google-cloud", "onAuthenticationRequest:deepseek-api", "onCommand:authentication.configureProviders", - "onCommand:authentication.migrateApiKey" + "onCommand:authentication.migrateSettingsToProvidersJson" ], "main": "./out/extension.js", + "scripts": { + "postinstall": "npm --prefix ../../ai-lib run build -w ai-config" + }, "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": { @@ -80,6 +84,11 @@ "command": "authentication.configureProviders", "title": "%commands.configureProviders.title%", "category": "%commands.category%" + }, + { + "command": "authentication.migrateSettingsToProvidersJson", + "title": "%commands.migrateSettingsToProvidersJson.title%", + "category": "%commands.category%" } ], "configuration": { @@ -703,24 +712,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 cf7541f2b429..bed3d83ed1db 100644 --- a/extensions/authentication/package.nls.json +++ b/extensions/authentication/package.nls.json @@ -38,7 +38,9 @@ "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": "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" } 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 c43ea047fe77..fa38f8d243cf 100644 --- a/extensions/authentication/src/extension.ts +++ b/extensions/authentication/src/extension.ts @@ -43,7 +43,7 @@ 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'; @@ -142,7 +142,7 @@ 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); - } - ) - ); -} diff --git a/extensions/authentication/src/migration/migrateToProvidersJson.ts b/extensions/authentication/src/migration/migrateToProvidersJson.ts new file mode 100644 index 000000000000..19f1a7531e83 --- /dev/null +++ b/extensions/authentication/src/migration/migrateToProvidersJson.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * 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, + InferCapabilitiesFn, + 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; + /** Override capability inference (tests). */ + inferCapabilities?: InferCapabilitiesFn; +} + +/** 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, + }; +} + +/** + * 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, PRESENCE_CHECK_CAPABILITIES) !== undefined; +} + +/** + * 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 { 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 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(); + 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' }; + } + + let skippedPopulated = false; + await mutateProvidersConfig( + current => { + if (!opts.overwrite && current.providers && Object.keys(current.providers).length > 0) { + skippedPopulated = true; + return current; + } + return { ...current, providers: 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:`); + 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 new file mode 100644 index 000000000000..eb97ee8e3d01 --- /dev/null +++ b/extensions/authentication/src/migration/providersJson.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { InferredModelCapabilities } from 'ai-config/node'; + +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; +} + +/** 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; + /** + * 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; +} + +/** A single provider block: plain data assembled field by field. */ +type Block = Record; + +export interface MappedProvidersConfig { + /** + * 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). */ + migrations: SettingMigration[]; +} + +interface ApiKeyConnectionSetting { + readonly configKey: string; + readonly providerId: 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' }, + { 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; +} + +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' }, + { 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; hasMigratableSettings scans this. */ +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), + ...MODEL_OVERRIDE_SETTINGS.map(s => `positron.assistant.models.overrides.${s.settingName}`), +]; + +export function buildProvidersConfigFromSettings( + reader: MigrationSettingsReader, + inferCapabilities: InferCapabilitiesFn +): MappedProvidersConfig | undefined { + const providers: Record = {}; + const migrations: SettingMigration[] = []; + + const merge = (providerId: string, fragment: Block) => { + providers[providerId] = { ...providers[providerId], ...fragment }; + }; + 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: rawBaseUrl }); + record(`authentication.${s.configKey}.baseUrl`, `providers.${s.providerId}.baseUrl`, JSON.stringify(rawBaseUrl)); + } + 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`, headerNames(headers)); + } + } + + // --- 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; + 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', JSON.stringify(googleCloud.location)); + } + if (Object.keys(googleCloud).length > 0) { + merge('google-vertex', { googleCloud }); + } + + // --- 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; + 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', JSON.stringify(aws.region)); + } + if (Object.keys(aws).length > 0) { + merge('bedrock', { aws }); + } + + // --- authentication.snowflake.* -> snowflake-cortex ---------------------- + const snowflakeCreds = reader.globalValue>('authentication.snowflake.credentials'); + const snowflake: Block = {}; + if (nonEmptyString(snowflakeCreds?.SNOWFLAKE_ACCOUNT)) { + snowflake.account = snowflakeCreds!.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', JSON.stringify(snowflake.home)); + } + if (Object.keys(snowflake).length > 0) { + merge('snowflake-cortex', { snowflake }); + } + const snowflakeHeaders = nonEmptyHeaders(reader.globalValue>('authentication.snowflake.customHeaders')); + if (snowflakeHeaders) { + merge('snowflake-cortex', { customHeaders: snowflakeHeaders }); + record('authentication.snowflake.customHeaders', 'providers.snowflake-cortex.customHeaders', headerNames(snowflakeHeaders)); + } + + // --- 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 }); + record(newValue !== undefined ? s.newKey! : s.oldKey!, `providers.${s.providerId}.enabled`, String(enabled)); + } + } + + // --- 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 } }); + 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) { + return undefined; + } + return { + config: { providers }, + settingCount: new Set(migrations.map(m => m.source)).size, + migrations, + }; +} + +/** + * 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; +} + +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/migration/providersJsonUi.ts b/extensions/authentication/src/migration/providersJsonUi.ts new file mode 100644 index 000000000000..83b877c85379 --- /dev/null +++ b/extensions/authentication/src/migration/providersJsonUi.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export function registerProvidersJsonMigration(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand(MIGRATE_COMMAND_ID, () => runMigrationCommand()) + ); + // 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 { + 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 maybeAutoMigrate(): Promise { + if (!hasMigratableSettings()) { + return; + } + if (await userProvidersFileIsPopulated()) { + // Self-extinguishing: once providers.json is populated by any means, + // migration never runs again. + return; + } + + await migrateAndReport({ overwrite: false }); +} + +async function migrateAndReport(opts: { overwrite: boolean }): Promise { + let result: MigrationResult; + try { + result = await runMigration(opts); + } catch (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.', detail) + ); + return; + } + + 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 now reads Posit Assistant providers from this file; your original settings were not removed.', result.settingCount), + 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; + } + 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; + } +} + +/** 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/migrateToProvidersJson.test.ts b/extensions/authentication/src/test/migrateToProvidersJson.test.ts new file mode 100644 index 000000000000..32c50b16a401 --- /dev/null +++ b/extensions/authentication/src/test/migrateToProvidersJson.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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('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' }); + 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); + }); + + 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); + }); +}); diff --git a/extensions/authentication/src/test/providersJsonMapping.test.ts b/extensions/authentication/src/test/providersJsonMapping.test.ts new file mode 100644 index 000000000000..ce58c797eeb6 --- /dev/null +++ b/extensions/authentication/src/test/providersJsonMapping.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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({}), fakeCaps), 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', + }), fakeCaps); + 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('copies the foundry base URL verbatim', () => { + const result = buildProvidersConfigFromSettings(readerOf({ + 'authentication.foundry.baseUrl': 'https://my-resource.services.ai.azure.com', + }), fakeCaps); + assert.deepStrictEqual(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': {}, + }), fakeCaps), 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' }, + }), 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' } }); + assert.deepStrictEqual(result?.config.providers?.['snowflake-cortex'], { snowflake: { account: 'MYORG-MYACCT', home: '/tmp/snow' } }); + }); + + 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', 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, 3); + }); + + 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, + }), 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); + // 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', () => { + 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); + 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 () => { + const { inferModelCapabilities, providersConfigSchema } = await import('ai-config/node'); + 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', SNOWFLAKE_HOME: '/opt/snowflake' }; + } 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.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), inferModelCapabilities); + assert.ok(result, 'buildProvidersConfigFromSettings returned undefined'); + assert.strictEqual(result.settingCount, MIGRATABLE_SETTING_KEYS.length); + providersConfigSchema.parse(result.config); + }); + + 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', + 'positron.assistant.models.overrides.positAI', + ]) { + assert.ok(MIGRATABLE_SETTING_KEYS.includes(key), `missing ${key}`); + } + }); +}); 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')) {