From a112910d0d10beb61e4826bdb9f897ebf07dfb54 Mon Sep 17 00:00:00 2001 From: Vizards Date: Mon, 15 Jun 2026 23:11:10 +0800 Subject: [PATCH] fix: disambiguate VS Code vision models --- README.md | 2 +- README.zh-cn.md | 2 +- package.nls.json | 2 +- package.nls.zh-cn.json | 2 +- src/provider/vision/log.ts | 105 +++++++++++++++++ src/provider/vision/resolve.ts | 7 +- src/provider/vision/service.ts | 11 +- src/provider/vision/sources/endpoint/test.ts | 38 +----- src/provider/vision/sources/vscode/index.ts | 117 +++++++++++++++---- src/provider/vision/sources/vscode/model.ts | 10 ++ src/provider/vision/types.ts | 1 + src/provider/vision/ui/html.ts | 6 +- src/provider/vision/ui/panel.ts | 31 +++-- src/provider/vision/ui/script.ts | 30 ++--- 14 files changed, 259 insertions(+), 105 deletions(-) create mode 100644 src/provider/vision/log.ts create mode 100644 src/provider/vision/sources/vscode/model.ts diff --git a/README.md b/README.md index e6bba1a..c028e26 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Both support optional thinking mode, tool calling, and 1M token context. | `deepseek-copilot.maxTokens` | `0` | Max output tokens (`0` = no limit). Useful for cost control | | `deepseek-copilot.modelIdOverrides` | prefilled official ID map | API model IDs to send for DeepSeek V4 Flash / Pro. Change only for compatible third-party APIs with different model names | | `deepseek-copilot.debugMode` | `minimal` | Diagnostic mode: `minimal` for token usage only, `metadata` for privacy-preserving logs, or `verbose` for full request dumps and pipeline snapshots under extension global storage. Full dumps may include sensitive prompt text, tool schemas, file snippets, and image descriptions. Use `DeepSeek: Open Request Dumps Folder` to open the dump location | -| `deepseek-copilot.visionModel` | *(auto)* | Which Copilot model to proxy images through | +| `deepseek-copilot.visionModel` | *(auto)* | VS Code vision model used to proxy images. Configure from `DeepSeek: Configure Vision Proxy`; new saves use `vendor/id`, while legacy bare model IDs are still read | | `deepseek-copilot.visionPrompt` | *(built-in)* | Prompt used to describe image attachments | | `deepseek-copilot.experimental.stabilizeToolList` | `false` | Experimental. Tries to pre-activate VS Code/Copilot virtual tools so the DeepSeek API `tools` parameter is more complete and stable across turns. May improve context-cache hit rate when enabled tools change between turns. Can increase input tokens because more function definitions may be included; cache-hit input tokens are cheaper but still count toward usage. Usually leave it off with 64 or fewer enabled tools unless the tool list still changes across turns; do not enable it with more than 128 enabled tools | diff --git a/README.zh-cn.md b/README.zh-cn.md index 3add3ba..cac586a 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -103,7 +103,7 @@ API Key 存储在 VS Code 的 `SecretStorage` 中(macOS 钥匙串 / Windows | `deepseek-copilot.maxTokens` | `0` | 最大输出 Token 数(`0` = 不限制)。可用于成本控制 | | `deepseek-copilot.modelIdOverrides` | 预填官方 ID 映射 | DeepSeek V4 Flash / Pro 对应的 API 模型 ID。仅在使用模型名不同的兼容第三方 API 时修改 | | `deepseek-copilot.debugMode` | `minimal` | 诊断模式:`minimal` 仅上报 token 用量,`metadata` 输出隐私安全日志,`verbose` 将完整请求 dump 和 pipeline snapshot 写入扩展 global storage。完整 dump 可能包含敏感提示词文本、工具定义、文件片段和图片描述。使用 `DeepSeek: 打开请求 Dump 目录` 打开 dump 位置 | -| `deepseek-copilot.visionModel` | *(自动)* | 用作视觉代理的 Copilot 模型 | +| `deepseek-copilot.visionModel` | *(自动)* | 用作图片代理的 VS Code 视觉模型。请通过 `DeepSeek: 配置视觉代理` 设置;新版保存为 `vendor/id`,旧版裸模型 ID 仍兼容读取 | | `deepseek-copilot.visionPrompt` | *(内置)* | 用于描述图片附件的提示词 | | `deepseek-copilot.experimental.stabilizeToolList` | `false` | 实验性设置。尝试预先激活 VS Code/Copilot 的虚拟工具,让传给 DeepSeek API 的 `tools` 参数在多轮对话中更完整、更稳定。当已启用工具跨轮次变化时,可能提高上下文缓存命中率。代价是 input tokens 可能增加;缓存命中的 input tokens 单价更低,但仍会计入用量。64 个或更少已启用工具时通常无需开启,除非工具列表仍在跨轮次变化;超过 128 个已启用工具时不建议开启 | diff --git a/package.nls.json b/package.nls.json index 34d81b6..31ad075 100644 --- a/package.nls.json +++ b/package.nls.json @@ -28,6 +28,6 @@ "deepseek-copilot.config.modelIdOverrides.description": "Override the API model ID sent for each DeepSeek model. Defaults are prefilled with official DeepSeek IDs; change them only when using a compatible third-party API that uses different model names.", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-flash.description": "API model ID for DeepSeek V4 Flash", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-pro.description": "API model ID for DeepSeek V4 Pro", - "deepseek-copilot.config.visionModel.description": "Compatibility setting managed by [Configure Vision Proxy](command:deepseek-copilot.setVisionModel). Stores the selected VS Code vision model when that source is active.", + "deepseek-copilot.config.visionModel.description": "Compatibility setting managed by [Configure Vision Proxy](command:deepseek-copilot.setVisionModel). Stores the selected VS Code vision model as `vendor/id` when that source is active. Legacy bare model IDs are read for compatibility.", "deepseek-copilot.config.visionPrompt.description": "Prompt sent to the vision proxy model when describing image attachments before forwarding them to DeepSeek.\n\n[Configure Vision Proxy](command:deepseek-copilot.setVisionModel)" } diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index e9b59ef..b51354f 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -28,6 +28,6 @@ "deepseek-copilot.config.modelIdOverrides.description": "覆盖各 DeepSeek 模型实际使用的 API 模型 ID,默认使用官方 DeepSeek ID,仅在对接不同模型名称的第三方 API 时需要修改。", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-flash.description": "DeepSeek V4 Flash 的 API 模型 ID。", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-pro.description": "DeepSeek V4 Pro 的 API 模型 ID。", - "deepseek-copilot.config.visionModel.description": "由[配置视觉代理](command:deepseek-copilot.setVisionModel)管理的兼容设置;当选择 VS Code 视觉模型来源时,用于保存所选模型。", + "deepseek-copilot.config.visionModel.description": "由[配置视觉代理](command:deepseek-copilot.setVisionModel)管理的兼容设置;当选择 VS Code 视觉模型来源时,以 `vendor/id` 保存所选模型。旧版裸模型 ID 仍会兼容读取。", "deepseek-copilot.config.visionPrompt.description": "在将图片附件转发给 DeepSeek 之前,发送给视觉代理模型的提示词。\n\n[配置视觉代理](command:deepseek-copilot.setVisionModel)" } diff --git a/src/provider/vision/log.ts b/src/provider/vision/log.ts new file mode 100644 index 0000000..5cc0660 --- /dev/null +++ b/src/provider/vision/log.ts @@ -0,0 +1,105 @@ +import type vscode from 'vscode'; +import { t } from '../../i18n'; +import { safeStringify } from '../../json'; +import { logger } from '../../logger'; +import { formatVisionProxyError } from './protocols/errors'; +import { getVSCodeVisionTargetChatSessionType } from './sources/vscode/model'; +import type { VisionProxyConfig } from './types'; + +export function showVisionLogs(): void { + logger.show(); +} + +export function logVSCodeVisionModelSelected(model: vscode.LanguageModelChat): void { + logger.info( + `${t('vision.proxyUsing', model.id)} selected=${formatVSCodeVisionModelIdentity(model)}`, + ); +} + +export function logVSCodeVisionModelNotFound(modelId: string): void { + logger.warn(t('vision.notFound', modelId)); +} + +export function logVisionApiEndpointSelected(modelId: string): void { + logger.info(`Vision proxy: ${modelId} source=api-endpoint`); +} + +export function logInvalidVisionProxyApiEndpointConfig( + source: string | undefined, + explicitApiEndpointSource: boolean, + error: unknown, +): void { + logger.warn( + `Invalid vision proxy API endpoint configuration; source=${source ?? 'unset'} fallback=${explicitApiEndpointSource ? 'none' : 'vscode-lm'}`, + error, + ); +} + +export function logVisionProxyUnavailable(): void { + logger.warn(t('vision.unavailable')); +} + +export function logVisionProxyDescribeFailed(error: unknown): void { + logger.error(t('vision.proxyError'), formatVisionProxyError(error)); +} + +export function logVisionProxyTestSucceeded( + config: VisionProxyConfig, + apiKey: string | undefined, + description: string, +): void { + logger.info( + 'Vision proxy test succeeded:', + formatVisionProxyTestDiagnostics(config, apiKey, description), + ); +} + +export function logVisionProxyTestFailed(error: unknown): void { + logger.error('Vision proxy test failed:', formatVisionProxyError(error)); +} + +function formatVSCodeVisionModelIdentity(model: vscode.LanguageModelChat): string { + return [ + formatLogField('id', model.id), + formatLogField('vendor', model.vendor), + formatLogField('name', model.name), + formatLogField('family', model.family), + formatLogField('version', model.version), + formatLogField('targetChatSessionType', getVSCodeVisionTargetChatSessionType(model)), + ].join(' '); +} + +function formatLogField(name: string, value: unknown): string { + return `${name}=${formatLogValue(value)}`; +} + +function formatLogValue(value: unknown): string { + const text = asString(value); + return text ? JSON.stringify(text) : 'n/a'; +} + +function formatVisionProxyTestDiagnostics( + config: VisionProxyConfig, + apiKey: string | undefined, + description: string, +): string { + return joinDiagnosticParts( + `kind=vision`, + `phase=describe`, + `providerFamily=${safeStringify(config.providerFamily)}`, + `apiType=${safeStringify(config.apiType)}`, + `model=${safeStringify(config.modelId)}`, + `endpoint=${safeStringify(config.url)}`, + `hasApiKey=${Boolean(apiKey?.trim())}`, + `responseChars=${description.length}`, + config.headers ? `headerNames=${safeStringify(Object.keys(config.headers).sort())}` : undefined, + ); +} + +function joinDiagnosticParts(...parts: (string | undefined)[]): string { + return parts.filter(Boolean).join(' '); +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} diff --git a/src/provider/vision/resolve.ts b/src/provider/vision/resolve.ts index 13b5001..da1982b 100644 --- a/src/provider/vision/resolve.ts +++ b/src/provider/vision/resolve.ts @@ -1,11 +1,9 @@ import vscode from 'vscode'; import { t } from '../../i18n'; import { toWellFormedString } from '../../json'; -import { logger } from '../../logger'; import { parseFirstReplayMarker } from '../replay'; import { createVisionProxyFailureNotice, createVisionProxyMissingNotice } from '../tools/notices'; import { - formatVisionProxyError, formatVisionProxyErrorCode, getVisionProxyErrorDisplayCode, isVisionProxyError, @@ -22,6 +20,7 @@ import type { VisionResolutionStats, } from './types'; import { getVisionPrompt } from './sources/vscode'; +import { logVisionProxyDescribeFailed, logVisionProxyUnavailable } from './log'; interface CurrentVisionResolution { text: string; @@ -238,7 +237,7 @@ async function resolveCurrentVisionText( ): Promise { if (!visionDescriber || token.isCancellationRequested) { if (!visionDescriber) { - logger.warn(t('vision.unavailable')); + logVisionProxyUnavailable(); } stats.unavailableImageMessages += 1; return { text: createVisionReplayText(IMAGE_DESCRIPTION_UNAVAILABLE, nonImageParts) }; @@ -262,7 +261,7 @@ async function resolveCurrentVisionText( stats.generatedImageMessages += 1; return { text: createVisionReplayText(createImageDescriptionText(description), nonImageParts) }; } catch (error) { - logger.error(t('vision.proxyError'), formatVisionProxyError(error)); + logVisionProxyDescribeFailed(error); stats.failedImageMessages += 1; return createFailedVisionResolution( getVisionProxyErrorDisplayCode(error), diff --git a/src/provider/vision/service.ts b/src/provider/vision/service.ts index 83a580f..19885b4 100644 --- a/src/provider/vision/service.ts +++ b/src/provider/vision/service.ts @@ -1,6 +1,6 @@ import vscode from 'vscode'; import { t } from '../../i18n'; -import { logger } from '../../logger'; +import { logInvalidVisionProxyApiEndpointConfig, logVisionApiEndpointSelected } from './log'; import { VISION_PROXY_API_KEY_SECRET, VisionProxyConfigStore } from './sources/endpoint/config'; import { createEndpointVisionDescriber } from './sources/endpoint'; import { openVisionProxyPanel } from './ui/panel'; @@ -55,7 +55,7 @@ export function createVisionService(context: vscode.ExtensionContext): { } const apiKey = await store.getApiKey(); const describer = createEndpointVisionDescriber(result.config, apiKey); - logger.info(`Vision proxy: ${describer.id} source=api-endpoint`); + logVisionApiEndpointSelected(describer.id); return describer; } @@ -63,7 +63,7 @@ export function createVisionService(context: vscode.ExtensionContext): { if (result.config) { const apiKey = await store.getApiKey(); const describer = createEndpointVisionDescriber(result.config, apiKey); - logger.info(`Vision proxy: ${describer.id} source=api-endpoint`); + logVisionApiEndpointSelected(describer.id); return describer; } return vscodeLm.get(); @@ -84,10 +84,7 @@ function getApiEndpointConfig( try { return { config: store.getConfig() }; } catch (error) { - logger.warn( - `Invalid vision proxy API endpoint configuration; source=${store.getSource() ?? 'unset'} fallback=${explicitApiEndpointSource ? 'none' : 'vscode-lm'}`, - error, - ); + logInvalidVisionProxyApiEndpointConfig(store.getSource(), explicitApiEndpointSource, error); return { error }; } } diff --git a/src/provider/vision/sources/endpoint/test.ts b/src/provider/vision/sources/endpoint/test.ts index c9bbb35..ebff45a 100644 --- a/src/provider/vision/sources/endpoint/test.ts +++ b/src/provider/vision/sources/endpoint/test.ts @@ -1,13 +1,8 @@ import vscode from 'vscode'; import { t } from '../../../../i18n'; -import { safeStringify } from '../../../../json'; -import { logger } from '../../../../logger'; +import { logVisionProxyTestFailed, logVisionProxyTestSucceeded } from '../../log'; import { VisionProxyClient } from '../../protocols/client'; -import { - formatVisionProxyError, - getVisionProxyErrorDisplayCode, - isVisionProxyError, -} from '../../protocols/errors'; +import { getVisionProxyErrorDisplayCode, isVisionProxyError } from '../../protocols/errors'; import type { VisionProxyConfig } from '../../types'; export interface VisionProxyTestResult { @@ -42,13 +37,10 @@ export async function testVisionProxyConnection( ], token: tokenSource.token, }); - logger.info( - 'Vision proxy test succeeded:', - formatVisionProxyTestDiagnostics(config, apiKey, description), - ); + logVisionProxyTestSucceeded(config, apiKey, description); return { ok: true, imageDataUrl: TEST_IMAGE_DATA_URL, response: description }; } catch (error) { - logger.error('Vision proxy test failed:', formatVisionProxyError(error)); + logVisionProxyTestFailed(error); if (isVisionProxyError(error)) { return { ok: false, @@ -65,25 +57,3 @@ export async function testVisionProxyConnection( tokenSource.dispose(); } } - -function formatVisionProxyTestDiagnostics( - config: VisionProxyConfig, - apiKey: string | undefined, - description: string, -): string { - return joinDiagnosticParts( - `kind=vision`, - `phase=describe`, - `providerFamily=${safeStringify(config.providerFamily)}`, - `apiType=${safeStringify(config.apiType)}`, - `model=${safeStringify(config.modelId)}`, - `endpoint=${safeStringify(config.url)}`, - `hasApiKey=${Boolean(apiKey?.trim())}`, - `responseChars=${description.length}`, - config.headers ? `headerNames=${safeStringify(Object.keys(config.headers).sort())}` : undefined, - ); -} - -function joinDiagnosticParts(...parts: (string | undefined)[]): string { - return parts.filter(Boolean).join(' '); -} diff --git a/src/provider/vision/sources/vscode/index.ts b/src/provider/vision/sources/vscode/index.ts index 6060497..af27913 100644 --- a/src/provider/vision/sources/vscode/index.ts +++ b/src/provider/vision/sources/vscode/index.ts @@ -1,14 +1,18 @@ import vscode from 'vscode'; import { t } from '../../../../i18n'; -import { logger } from '../../../../logger'; +import { logVSCodeVisionModelNotFound, logVSCodeVisionModelSelected } from '../../log'; import { DEFAULT_VISION_MODEL_ID, IMAGE_DESCRIPTION_PROMPT } from '../../consts'; import type { VisionDescriptionRequest, VisionDescriber, VisionLanguageModelOption, } from '../../types'; +import { getVSCodeVisionTargetChatSessionType } from './model'; const EXCLUDED_VISION_MODEL_IDS = new Set(['copilot-utility', 'copilot-utility-small']); +const EXCLUDED_VISION_MODEL_VENDORS = new Set(['deepseek', 'claude-code', 'copilotcli']); +const EXCLUDED_VISION_TARGET_CHAT_SESSION_TYPES = new Set(['claude-code', 'copilotcli']); +const VSCODE_VISION_MODEL_KEY_SEPARATOR = '/'; type LanguageModelPricingInfo = { readonly pricing?: unknown; @@ -41,16 +45,17 @@ export function createVSCodeLanguageModelVisionDescriberGetter(): { const requestGeneration = generation; const currentPromise = (async () => { const models = await listVSCodeVisionModels(); + const configuredKey = getConfiguredVisionModelKey(); if (requestGeneration !== generation) { return undefined; } - const model = pickPreferredVSCodeVisionModel(models, getConfiguredVisionModelId()); + const model = pickPreferredVSCodeVisionModel(models, configuredKey); if (model) { - logger.info(t('vision.proxyUsing', model.id)); + logVSCodeVisionModelSelected(model); describer = new VSCodeLanguageModelVisionDescriber(model); return describer; } - logger.warn(t('vision.notFound', getConfiguredVisionModelId() ?? DEFAULT_VISION_MODEL_ID)); + logVSCodeVisionModelNotFound(configuredKey ?? DEFAULT_VISION_MODEL_ID); return undefined; })(); describerPromise = currentPromise; @@ -116,23 +121,23 @@ export function getVisionPrompt(): string { ); } -export function getConfiguredVisionModelId(): string | undefined { +export function getConfiguredVisionModelKey(): string | undefined { const config = vscode.workspace.getConfiguration('deepseek-copilot'); - const id = config.get('visionModel', ''); - return id.trim() || undefined; + const key = config.get('visionModel', ''); + return key.trim() || undefined; } export function getDefaultVisionModelId(): string { return DEFAULT_VISION_MODEL_ID; } -export async function saveVSCodeVisionModelId(id: string): Promise { - const trimmed = id.trim(); - if (!trimmed) { +export async function saveVSCodeVisionModelKey(key: string): Promise { + const normalizedKey = await normalizeVSCodeVisionModelKeyForSave(key); + if (!normalizedKey) { throw new Error(t('vision.panel.error.required', t('vision.panel.source.vscodeLm'))); } const config = vscode.workspace.getConfiguration('deepseek-copilot'); - await config.update('visionModel', trimmed, vscode.ConfigurationTarget.Global); + await config.update('visionModel', normalizedKey, vscode.ConfigurationTarget.Global); } export async function listVSCodeVisionModelOptions(): Promise { @@ -140,6 +145,7 @@ export async function listVSCodeVisionModelOptions(): Promise { const costDescription = formatLanguageModelCost(model); return { + key: getVSCodeVisionModelKey(model), id: model.id, vendor: model.vendor, name: model.name, @@ -152,17 +158,21 @@ export async function listVSCodeVisionModelOptions(): Promise model.id === configuredId)) { - return configuredId; + if (configuredKey) { + const configured = pickConfiguredVSCodeVisionModelEntry(options, configuredKey); + if (configured) { + return configured.key; + } } - if (options.some((model) => model.id === DEFAULT_VISION_MODEL_ID)) { - return DEFAULT_VISION_MODEL_ID; + const preferred = options.find((model) => model.id === DEFAULT_VISION_MODEL_ID); + if (preferred) { + return preferred.key; } - return options[0]?.id; + return options[0]?.key; } async function listVSCodeVisionModels(): Promise { @@ -172,10 +182,10 @@ async function listVSCodeVisionModels(): Promise { function pickPreferredVSCodeVisionModel( models: readonly vscode.LanguageModelChat[], - configuredId: string | undefined, + configuredKey: string | undefined, ): vscode.LanguageModelChat | undefined { - if (configuredId) { - const configured = models.find((model) => model.id === configuredId); + if (configuredKey) { + const configured = pickConfiguredVSCodeVisionModelEntry(models, configuredKey); if (configured) { return configured; } @@ -190,18 +200,81 @@ function pickPreferredVSCodeVisionModel( function isVSCodeVisionModel(model: vscode.LanguageModelChat): boolean { return ( - model.vendor !== 'deepseek' && + !EXCLUDED_VISION_MODEL_VENDORS.has(model.vendor) && !EXCLUDED_VISION_MODEL_IDS.has(model.id) && + !EXCLUDED_VISION_TARGET_CHAT_SESSION_TYPES.has( + getVSCodeVisionTargetChatSessionType(model) ?? '', + ) && getSupportsImageToText(model) ); } +function getVSCodeVisionModelKey(model: Pick): string { + return `${model.vendor}${VSCODE_VISION_MODEL_KEY_SEPARATOR}${model.id}`; +} + +async function normalizeVSCodeVisionModelKeyForSave(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) { + return undefined; + } + const model = pickConfiguredVSCodeVisionModelEntry(await listVSCodeVisionModels(), trimmed); + if (!model) { + throw new Error(t('vision.notFound', trimmed)); + } + return getVSCodeVisionModelKey(model); +} + +function pickConfiguredVSCodeVisionModelEntry( + models: readonly T[], + configuredKey: string, +): T | undefined { + const legacyId = configuredKey.trim(); + const parsed = parseVSCodeVisionModelKey(configuredKey); + if (!parsed) { + return legacyId ? pickLegacyVSCodeVisionModelById(models, legacyId) : undefined; + } + if (parsed.vendor) { + const exact = models.find((model) => model.vendor === parsed.vendor && model.id === parsed.id); + // VS Code model ids are opaque and may contain "/", so preserve legacy bare-id + // settings by retrying the whole value when no provider-qualified key matches. + return exact ?? pickLegacyVSCodeVisionModelById(models, legacyId); + } + return pickLegacyVSCodeVisionModelById(models, parsed.id); +} + +function pickLegacyVSCodeVisionModelById( + models: readonly T[], + id: string, +): T | undefined { + const matches = models.filter((model) => model.id === id); + return matches.find((model) => model.vendor === 'copilot') ?? matches[0]; +} + +function parseVSCodeVisionModelKey( + value: string, +): { vendor: string | undefined; id: string } | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const separatorIndex = trimmed.indexOf(VSCODE_VISION_MODEL_KEY_SEPARATOR); + if (separatorIndex <= 0) { + return { vendor: undefined, id: trimmed }; + } + const vendor = trimmed.slice(0, separatorIndex).trim(); + const id = trimmed.slice(separatorIndex + VSCODE_VISION_MODEL_KEY_SEPARATOR.length).trim(); + return vendor && id ? { vendor, id } : undefined; +} + function getSupportsImageToText(model: vscode.LanguageModelChat): boolean { const capabilities = ( model as { capabilities?: { supportsImageToText?: boolean; imageInput?: boolean }; } ).capabilities; + // VS Code providers declare imageInput, while selected LanguageModelChat + // instances expose it as supportsImageToText in VS Code 1.116+. return capabilities?.supportsImageToText === true || capabilities?.imageInput === true; } diff --git a/src/provider/vision/sources/vscode/model.ts b/src/provider/vision/sources/vscode/model.ts new file mode 100644 index 0000000..d1bd8ef --- /dev/null +++ b/src/provider/vision/sources/vscode/model.ts @@ -0,0 +1,10 @@ +import type vscode from 'vscode'; + +export function getVSCodeVisionTargetChatSessionType( + model: vscode.LanguageModelChat, +): string | undefined { + // targetChatSessionType is a proposed/runtime VS Code property, so treat it as + // best-effort metadata. Vendor exclusions remain the stable fallback. + const value = (model as { targetChatSessionType?: unknown }).targetChatSessionType; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} diff --git a/src/provider/vision/types.ts b/src/provider/vision/types.ts index 686342d..22b1fba 100644 --- a/src/provider/vision/types.ts +++ b/src/provider/vision/types.ts @@ -8,6 +8,7 @@ export type VisionProxyProviderFamily = 'anthropic-compatible' | 'openai-compati export type VisionProxyApiType = 'messages' | 'chat-completions' | 'responses'; export interface VisionLanguageModelOption { + key: string; id: string; vendor: string; name: string; diff --git a/src/provider/vision/ui/html.ts b/src/provider/vision/ui/html.ts index 1b8ec2e..8d630ce 100644 --- a/src/provider/vision/ui/html.ts +++ b/src/provider/vision/ui/html.ts @@ -10,7 +10,7 @@ export interface VisionProxyPanelState { config?: VisionProxyConfig; hasApiKey: boolean; lmModels: VisionLanguageModelOption[]; - selectedLmModelId?: string; + selectedLmModelKey?: string; } export function getVisionProxyPanelHtml( @@ -66,8 +66,8 @@ export function getVisionProxyPanelHtml(
- - + +
diff --git a/src/provider/vision/ui/panel.ts b/src/provider/vision/ui/panel.ts index ea2ee2f..d6a4b45 100644 --- a/src/provider/vision/ui/panel.ts +++ b/src/provider/vision/ui/panel.ts @@ -1,24 +1,23 @@ import vscode from 'vscode'; import { t } from '../../../i18n'; -import { logger } from '../../../logger'; import { VisionProxyConfigStore, normalizeVisionProxyConfig, normalizeVisionProxySource, } from '../sources/endpoint/config'; import { - formatVisionProxyError, formatVisionProxyDisplayMessage, getVisionProxyErrorDisplayCode, isVisionProxyError, } from '../protocols/errors'; +import { logVisionProxyTestFailed, showVisionLogs } from '../log'; import { testVisionProxyConnection, type VisionProxyTestResult } from '../sources/endpoint/test'; import type { VisionLanguageModelOption, VisionProxyConfig, VisionProxySource } from '../types'; import { - getConfiguredVisionModelId, + getConfiguredVisionModelKey, listVSCodeVisionModelOptions, - pickPreferredVSCodeVisionModelId, - saveVSCodeVisionModelId, + pickPreferredVSCodeVisionModelKey, + saveVSCodeVisionModelKey, } from '../sources/vscode'; import { getVisionProxyPanelHtml, type VisionProxyPanelState } from './html'; @@ -81,13 +80,13 @@ async function handleMessage( } if (message.type === 'showLogs') { - logger.show(); + showVisionLogs(); return; } if (message.type === 'logVisionProxyTestFailure') { const errorMessage = getClientErrorMessage(message.value); - logger.error('Vision proxy test failed:', formatVisionProxyError(new Error(errorMessage))); + logVisionProxyTestFailed(new Error(errorMessage)); return; } @@ -105,8 +104,8 @@ async function handleMessage( if (message.type === 'saveConfig') { const payload = getWebviewPayload(message.value); if (payload.source === 'vscode-lm') { - await saveVSCodeVisionModelId( - getRequiredString(payload.lmModelId, t('vision.panel.source.vscodeLm')), + await saveVSCodeVisionModelKey( + getRequiredString(payload.lmModelKey, t('vision.panel.source.vscodeLm')), ); await store.saveSource('vscode-lm'); } else { @@ -154,7 +153,7 @@ async function handleMessage( } catch (error) { const isTestError = message.type === 'testConnection'; if (isTestError) { - logger.error('Vision proxy test failed:', formatVisionProxyError(error)); + logVisionProxyTestFailed(error); } postStatus( panel, @@ -180,9 +179,9 @@ async function postState(panel: vscode.WebviewPanel, store: VisionProxyConfigSto async function getState(store: VisionProxyConfigStore): Promise { const lmModels = await listVSCodeVisionModelOptions(); const config = getConfigForPanel(store); - const selectedLmModelId = pickPreferredVSCodeVisionModelId( + const selectedLmModelKey = pickPreferredVSCodeVisionModelKey( lmModels, - getConfiguredVisionModelId(), + getConfiguredVisionModelKey(), ); return { @@ -190,7 +189,7 @@ async function getState(store: VisionProxyConfigStore): Promise; apiKey: string | undefined; - lmModelId: string | undefined; + lmModelKey: string | undefined; testId: number | undefined; }; @@ -311,12 +310,12 @@ function getWebviewPayload(value: unknown): WebviewPayload { const config = asRecord(payload.config); const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey.trim() : ''; const source = normalizeVisionProxySource(payload.source) ?? 'api-endpoint'; - const lmModelId = typeof payload.lmModelId === 'string' ? payload.lmModelId.trim() : ''; + const lmModelKey = typeof payload.lmModelKey === 'string' ? payload.lmModelKey.trim() : ''; return { source, config, apiKey: apiKey || undefined, - lmModelId: lmModelId || undefined, + lmModelKey: lmModelKey || undefined, testId: toPositiveInteger(payload.testId), }; } diff --git a/src/provider/vision/ui/script.ts b/src/provider/vision/ui/script.ts index a3b937f..5baae49 100644 --- a/src/provider/vision/ui/script.ts +++ b/src/provider/vision/ui/script.ts @@ -10,7 +10,7 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: const sourceField = document.getElementById('sourceField'); const sourceInputs = Array.from(document.querySelectorAll('input[name="source"]')); const lmSection = document.getElementById('lmSection'); - const lmModelId = document.getElementById('lmModelId'); + const lmModelKey = document.getElementById('lmModelKey'); const lmModelCost = document.getElementById('lmModelCost'); const endpointSection = document.getElementById('endpointSection'); const url = document.getElementById('url'); @@ -37,7 +37,7 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: currentState = state; const config = state.config || {}; renderSummary(state); - renderLmModels(state.lmModels || [], state.selectedLmModelId); + renderLmModels(state.lmModels || [], state.selectedLmModelKey); setSelectedSource((state.lmModels || []).length > 0 ? state.source : 'api-endpoint'); url.value = config.url || ''; endpointType.value = getEndpointTypeValue(config); @@ -51,7 +51,7 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: renderApiKeyHint(state.hasApiKey); syncSourceVisibility(); if (getSelectedSource() === 'vscode-lm') { - setStatus(lmModelId.value ? strings.statusVscodeLmSelected : '', false); + setStatus(lmModelKey.value ? strings.statusVscodeLmSelected : '', false); } else { setStatus(state.hasApiKey ? strings.statusApiKeySet : strings.statusApiKeyNotSet, false); } @@ -70,7 +70,7 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: function getSummaryState(state) { const source = state.source || 'api-endpoint'; if (source === 'vscode-lm') { - const model = (state.lmModels || []).find((item) => item.id === state.selectedLmModelId); + const model = (state.lmModels || []).find((item) => item.key === state.selectedLmModelKey); if (!model) { return { tone: 'error', @@ -227,28 +227,28 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: ); } - function renderLmModels(models, selectedId) { - lmModelId.textContent = ''; + function renderLmModels(models, selectedKey) { + lmModelKey.textContent = ''; for (const model of models) { const option = document.createElement('option'); - option.value = model.id; + option.value = model.key; option.textContent = model.label || model.id; option.title = [model.description || model.vendor || '', model.costDescription || ''] .filter(Boolean) .join(' · '); - if (model.id === selectedId) { + if (model.key === selectedKey) { option.selected = true; } - lmModelId.appendChild(option); + lmModelKey.appendChild(option); } - if (!lmModelId.value && models[0]) { - lmModelId.value = models[0].id; + if (!lmModelKey.value && models[0]) { + lmModelKey.value = models[0].key; } updateLanguageModelCost(); } function updateLanguageModelCost() { - const model = (currentState.lmModels || []).find((item) => item.id === lmModelId.value); + const model = (currentState.lmModels || []).find((item) => item.key === lmModelKey.value); const costDescription = model ? model.costDescription || '' : ''; lmModelCost.textContent = costDescription; lmModelCost.hidden = !costDescription; @@ -308,7 +308,7 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: if (source === 'vscode-lm') { return { source, - lmModelId: lmModelId.value, + lmModelKey: lmModelKey.value, }; } return { @@ -440,10 +440,10 @@ export function getVisionProxyPanelScript(initialState: string, initialStrings: setStatus('', false); }); } - lmModelId.addEventListener('change', () => { + lmModelKey.addEventListener('change', () => { invalidateTestStatus(); updateLanguageModelCost(); - setStatus(lmModelId.value ? strings.statusVscodeLmSelected : '', false); + setStatus(lmModelKey.value ? strings.statusVscodeLmSelected : '', false); }); url.addEventListener('input', () => { invalidateTestStatus();