Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
{
"command": "deepseek-copilot.openRequestDumpsFolder",
"title": "%deepseek-copilot.command.openRequestDumpsFolder%"
},
{
"command": "deepseek-copilot.clearCloudCache",
"title": "%deepseek-copilot.command.clearCloudCache%"
}
],
"walkthroughs": [
Expand Down Expand Up @@ -135,6 +139,25 @@
"experimental"
]
},
"deepseek-copilot.experimental.filesApi": {
"type": "boolean",
"default": false,
"markdownDescription": "%deepseek-copilot.config.experimental.filesApi.description%",
"tags": [
"experimental"
]
},
"deepseek-copilot.cacheExpiresDays": {
"type": "number",
"default": 7,
"minimum": -1,
"maximum": 30,
"description": "%deepseek-copilot.config.cacheExpiresDays.description%",
"markdownDescription": "%deepseek-copilot.config.cacheExpiresDays.markdownDescription%",
"tags": [
"experimental"
]
},
"deepseek-copilot.modelIdOverrides": {
"type": "object",
"default": {
Expand Down
4 changes: 4 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"deepseek-copilot.command.openSettings": "DeepSeek: Open Settings",
"deepseek-copilot.command.showLogs": "DeepSeek: Show Logs",
"deepseek-copilot.command.openRequestDumpsFolder": "DeepSeek: Open Request Dumps Folder",
"deepseek-copilot.command.clearCloudCache": "DeepSeek: Clear Cloud Cache",
"deepseek-copilot.walkthrough.title": "DeepSeek V4 for Copilot Chat",
"deepseek-copilot.walkthrough.description": "Set up DeepSeek V4 models in Copilot Chat.",
"deepseek-copilot.walkthrough.setApiKey.title": "Set your DeepSeek API key",
Expand All @@ -18,6 +19,9 @@
"deepseek-copilot.config.baseUrl.description": "DeepSeek API base URL. Defaults to official DeepSeek API endpoint.",
"deepseek-copilot.config.maxTokens.description": "Maximum number of output tokens per request. Set to 0 to use the API default (no limit). Useful for controlling costs.",
"deepseek-copilot.config.experimental.stabilizeToolList.description": "**Experimental**: improve DeepSeek context-cache hit rate by pre-activating available tools.\n- When the enabled tools list changes across turns, this may improve DeepSeek context-cache hit rate.\n- Requests will include more function definitions, so input tokens may increase. Cache-hit input tokens are billed at a lower price, but still count toward usage.\n- This may add internal preflight tool calls to the current Copilot chat history. If you switch to another model in the same conversation, that model provider may reject or mishandle the replayed history. Start a new chat if model switching behaves unexpectedly.\n\nUse [Configure Tools](command:workbench.action.chat.configureTools) to **view and manage** your tool list:\n\n- 64 or fewer enabled tools: usually no need to enable this unless the tool list still changes across turns.\n- More than 128 enabled tools: not recommended. DeepSeek supports at most 128 functions in one `tools` request. Consider disabling tools you rarely use.",
"deepseek-copilot.config.experimental.filesApi.description": "**Experimental**: upload images to the DeepSeek Files API and reference them by `file_id` instead of embedding base64 data URLs.\n\nApplies only to the `deepseek-v4-flash-vision-exp` model and only on the official DeepSeek API endpoint. Images are content-addressed and cached locally, so repeated images reuse the same `file_id` while it is valid, and expired files are re-uploaded automatically.\n\nWhen enabled, image payloads are smaller and cost fewer tokens; when disabled, images embed as base64.",
"deepseek-copilot.config.cacheExpiresDays.description": "How long image files uploaded to the DeepSeek Files API are kept. Set to 0 for 1 hour, or -1 to keep files permanently. Positive values are days and are clamped to the API's allowed range (1-30 days).",
"deepseek-copilot.config.cacheExpiresDays.markdownDescription": "How long uploaded image files stay cached in the DeepSeek Files API.\n\n- **-1** — keep files permanently (never expire).\n- **0** — 1 hour (the API's minimum).\n- **1-30** — number of days, clamped to the API's allowed range.\n\nFiles are content-addressed and deduplicated, so re-uploading the same image reuses the existing `file_id` while it is still valid.\n\nOnly applies when `#deepseek-copilot.experimental.filesApi#` is enabled.",
"deepseek-copilot.config.debugMode.description": "Controls what diagnostic information DeepSeek Copilot writes. Token usage is always reported to Copilot regardless of this setting.\n\n- **Minimal** — Token usage only. No diagnostic logs or request dumps.\n- **Metadata** — Privacy-safe diagnostic metadata (request hashes, prefix overlap, tool schema changes). Does not contain prompt text — safe to share in public issue reports. View with [`DeepSeek: Show Logs`](command:deepseek-copilot.showLogs).\n- **Verbose** — Complete request payloads written to disk for local debugging. **Warning: contains sensitive prompt content.** View with [`DeepSeek: Open Request Dumps Folder`](command:deepseek-copilot.openRequestDumpsFolder).",
"deepseek-copilot.config.debugMode.minimal.label": "Minimal",
"deepseek-copilot.config.debugMode.minimal.description": "Token usage only. No diagnostic logs or dumps.",
Expand Down
4 changes: 4 additions & 0 deletions package.nls.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"deepseek-copilot.command.openSettings": "DeepSeek: 打开设置",
"deepseek-copilot.command.showLogs": "DeepSeek: 显示日志",
"deepseek-copilot.command.openRequestDumpsFolder": "DeepSeek: 打开请求 Dump 目录",
"deepseek-copilot.command.clearCloudCache": "DeepSeek: 清空云端缓存",
"deepseek-copilot.walkthrough.title": "DeepSeek V4 for Copilot Chat",
"deepseek-copilot.walkthrough.description": "在 Copilot Chat 中配置 DeepSeek V4 模型。",
"deepseek-copilot.walkthrough.setApiKey.title": "设置你的 DeepSeek API Key",
Expand All @@ -18,6 +19,9 @@
"deepseek-copilot.config.baseUrl.description": "DeepSeek API 基础 URL,默认为官方 DeepSeek API 端点。",
"deepseek-copilot.config.maxTokens.description": "每次请求的最大输出 Token 数,设为 0 则不限制,可用于控制成本。",
"deepseek-copilot.config.experimental.stabilizeToolList.description": "**实验性功能**:通过预先激活可用的工具来提升 DeepSeek 上下文缓存命中率。\n- 当已启用工具列表跨轮次变化时,这可能提高 DeepSeek 上下文缓存命中率;\n- 请求中将包含更多函数工具定义,input tokens 可能增加,虽然缓存命中的 input tokens 单价更低,但仍会计入用量;\n- 此设置可能会在当前 Copilot 对话历史中加入内部预检工具调用。在同一对话中切换到其他模型时,部分模型提供方可能无法正确重放这段历史;如果切换模型后请求异常,请新建对话后重试。\n\n 通过 [配置工具](command:workbench.action.chat.configureTools) **查看和管理**工具列表:\n\n- 64 个或更少已启用工具时通常无需开启,除非工具列表仍在跨轮次变化;\n- 超过 128 个已启用工具时不建议开启:DeepSeek 单次 `tools` 请求最多支持 128 个 functions。考虑禁用部分不常用工具。",
"deepseek-copilot.config.experimental.filesApi.description": "**实验性功能**:将图片上传到 DeepSeek Files API,并通过 `file_id` 引用,而不是嵌入 base64 data URL。\n\n仅适用于 `deepseek-v4-flash-vision-exp` 模型,且只在官方 DeepSeek API 端点生效。图片按内容寻址并在本地缓存,重复图片在有效期内复用同一个 `file_id`,过期文件自动重新上传。\n\n开启后图片 payload 更小、token 更省;关闭时图片以 base64 嵌入。",
"deepseek-copilot.config.cacheExpiresDays.description": "上传到 DeepSeek Files API 的图片文件保留时长。设为 0 表示 1 小时,或设为 -1 永久保留。正数表示天数,并会被钳制到 API 允许范围内(1-30 天)。",
"deepseek-copilot.config.cacheExpiresDays.markdownDescription": "上传的图片文件在 DeepSeek Files API 中的缓存保留时长。\n\n- **-1** — 永久保留(永不过期)。\n- **0** — 1 小时(API 允许的最小值)。\n- **1-30** — 天数,钳制到 API 允许范围内。\n\n文件按内容寻址并去重,因此重复上传同一张图片时,只要 `file_id` 仍有效就会复用。\n\n仅在启用 `#deepseek-copilot.experimental.filesApi#` 时生效。",
"deepseek-copilot.config.debugMode.description": "控制 DeepSeek Copilot 写入的诊断信息量。无论此设置如何,token 用量始终上报给 Copilot。\n\n- **基本** — 仅上报 token 用量,不输出诊断日志或请求 dump。\n- **元数据** — 隐私安全的诊断元数据(请求哈希、前缀重合度、工具定义变更)。不含提示词原文,可安全附在公开 issue 中反馈问题。使用 [`DeepSeek: 显示日志`](command:deepseek-copilot.showLogs) 查看。\n- **详细** — 将完整请求体写入磁盘,供本地调试。**警告:包含敏感的提示词内容。** 使用 [`DeepSeek: 打开请求 Dump 目录`](command:deepseek-copilot.openRequestDumpsFolder) 浏览。",
"deepseek-copilot.config.debugMode.minimal.label": "基本",
"deepseek-copilot.config.debugMode.minimal.description": "仅上报 token 用量,不输出诊断日志或 dump。",
Expand Down
17 changes: 12 additions & 5 deletions src/client/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,17 @@ function getContentChars(content: DeepSeekMessage['content']): number {
if (typeof content === 'string') {
return content.length;
}
return content.reduce(
(total, part) => total + (part.type === 'text' ? part.text.length : part.image_url.url.length),
0,
);
return content.reduce((total, part) => {
if (part.type === 'text') {
return total + part.text.length;
}
if (part.type === 'image_url') {
// data URLs can dominate request size, so count them.
return total + part.image_url.url.length;
}
// file (Files API) refs are short; treat as negligible.
return total;
}, 0);
}

/**
Expand All @@ -330,7 +337,7 @@ function countImageParts(content: DeepSeekMessage['content']): number {
if (typeof content === 'string') {
return 0;
}
return content.filter((part) => part.type === 'image_url').length;
return content.filter((part) => part.type === 'image_url' || part.type === 'file').length;
}

function joinDiagnosticParts(...parts: (string | undefined)[]): string {
Expand Down
60 changes: 60 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import vscode from 'vscode';
import { CONFIG_SECTION } from './consts';
import {
CLOUD_CACHE_SECONDS,
MAX_CACHE_SECONDS,
MIN_CACHE_SECONDS,
} from './provider/vision/filesApi';

export type DebugMode = 'minimal' | 'metadata' | 'verbose';

Expand Down Expand Up @@ -69,6 +74,61 @@ export function getStabilizeToolListEnabled(): boolean {
return config.get<boolean>('experimental.stabilizeToolList', false);
}

/**
* Whether the experimental DeepSeek Files API native-vision route is enabled.
*
* When enabled (and the model supports it and the endpoint is the official
* DeepSeek API), images are uploaded via POST /files and referenced by file_id.
*/
export function getFilesApiEnabled(): boolean {
const config = vscode.workspace.getConfiguration(CONFIG_SECTION);
return config.get<boolean>('experimental.filesApi', false);
}

/**
* Get the configured cloud cache lifetime (in seconds) for Files API uploads.
*
* Reads `cacheExpiresDays` from settings:
* - `-1` → permanent (0, no expires_after)
* - `0` → 1 hour (the API's minimum)
* - a plain number `N` → N days
*
* The result is clamped to the API's valid range [MIN_CACHE_SECONDS, MAX_CACHE_SECONDS].
* Returns 0 only when the user explicitly wants permanent files (never expires).
*/
export function getCacheExpiresSeconds(): number {
const config = vscode.workspace.getConfiguration(CONFIG_SECTION);
// 兜底为 7(天),与 package.json 的 schema default 保持一致,
// 避免配置读取失败时落入 0 → 1 小时的语义。
const days = config.get<number>('cacheExpiresDays', 7);
return parseCacheExpiresSeconds(days);
}

/**
* Parse a `cacheExpiresDays` numeric value into seconds. Clamps to the API's
* valid range, or returns 0 for permanent.
*/
export function parseCacheExpiresSeconds(raw: number | undefined | null): number {
if (raw == null) {
return CLOUD_CACHE_SECONDS; // 默认 7 天
}
// 永久:-1 或任意负数
if (raw < 0) {
return 0;
}
// 0 → 1 小时(API 允许的最小值)
if (raw === 0) {
return MIN_CACHE_SECONDS;
}
// 正数 → 天,并钳制到 API 允许范围
return clampToValidCacheRange(raw * 24 * 60 * 60);
}

/** Clamp a raw duration (seconds) to the API's valid range. */
function clampToValidCacheRange(seconds: number): number {
return Math.min(Math.max(Math.round(seconds), MIN_CACHE_SECONDS), MAX_CACHE_SECONDS);
}

/**
* Migrate the legacy boolean `deepseek-copilot.debug` setting to `debugMode`.
*
Expand Down
1 change: 1 addition & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const MODELS: ModelDefinition[] = [
toolCalling: DEEPSEEK_TOOLS_LIMIT,
imageInput: true,
nativeImageInput: true,
visionNative: true,
thinking: {
supportedEfforts: ['low', 'high', 'max'],
defaultEffort: 'high',
Expand Down
18 changes: 18 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const zh: Translations = {
'auth.removed': 'API Key 已移除。',
'auth.notConfigured': 'API Key 未配置,请在命令面板运行 "DeepSeek: 设置 API Key"。',

// Cloud cache (Files API)
'cloudCache.noApiKey': '需要先配置 DeepSeek API Key 才能清空云端缓存。',
'cloudCache.clearConfirm':
'确定要清空 DeepSeek Files API 云端缓存吗?这将永久删除所有已上传的图片文件。',
'cloudCache.clearConfirmAction': '清空',
'cloudCache.clearing': '正在清空云端缓存...',
'cloudCache.cleared': '已清空云端缓存,共删除 {0} 个文件。',
'cloudCache.clearFailed': '清空云端缓存失败,请查看日志。',

// Thinking Effort — short labels for model picker dropdown
'status.thinking': '思考模式',
'thinking.none': '停用',
Expand Down Expand Up @@ -230,6 +239,15 @@ const en: Translations = {
'auth.notConfigured':
'DeepSeek API key not configured. Run "DeepSeek: Set API Key" from the Command Palette.',

// Cloud cache (Files API)
'cloudCache.noApiKey': 'A DeepSeek API key is required to clear the cloud cache.',
'cloudCache.clearConfirm':
'Clear the DeepSeek Files API cloud cache? This permanently deletes all uploaded image files.',
'cloudCache.clearConfirmAction': 'Clear',
'cloudCache.clearing': 'Clearing cloud cache...',
'cloudCache.cleared': 'Cloud cache cleared. Deleted {0} file(s).',
'cloudCache.clearFailed': 'Failed to clear cloud cache. Check the logs.',

// Thinking Effort
'status.thinking': 'Thinking Effort',
'thinking.none': 'None',
Expand Down
53 changes: 44 additions & 9 deletions src/provider/convert.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import vscode from 'vscode';
import * as crypto from 'crypto';
import { safeStringify } from '../json';
import type {
DeepSeekContentPart,
Expand All @@ -8,6 +9,14 @@ import type {
} from '../types';
import { parseFirstReplayMarker } from './replay';

/**
* Options controlling multimodal conversion for the DeepSeek Files API route.
*/
export interface ConvertMessageOptions {
/** Keyed by the sha256 content hash of an image; value is its file_id. */
filesApiFileIdByHash?: ReadonlyMap<string, string>;
}

/**
* Convert VS Code chat messages to DeepSeek format.
* Injects marker-replayed reasoning_content for assistant messages.
Expand All @@ -16,7 +25,9 @@ export function convertMessages(
messages: readonly vscode.LanguageModelChatRequestMessage[],
isThinkingModel: boolean,
nativeImageInput: boolean,
options?: ConvertMessageOptions,
): DeepSeekMessage[] {
const filesApiFileIdByHash = options?.filesApiFileIdByHash;
const result: DeepSeekMessage[] = [];

for (const message of messages) {
Expand All @@ -38,12 +49,20 @@ export function convertMessages(
});
}
} else if (nativeImageInput && role === 'user' && isImageDataPart(part)) {
nativeVisionContentParts.push({
type: 'image_url',
image_url: {
url: toImageDataUrl(part),
},
});
const fileId = resolveFileId(part, filesApiFileIdByHash);
if (fileId) {
nativeVisionContentParts.push({
type: 'file',
file_id: fileId,
});
} else {
nativeVisionContentParts.push({
type: 'image_url',
image_url: {
url: toImageDataUrl(part),
},
});
}
} else if (isLanguageModelThinkingPart(part)) {
thinkingContent += normalizeThinkingPartText(part.value);
} else if (part instanceof vscode.LanguageModelToolCallPart) {
Expand Down Expand Up @@ -122,6 +141,21 @@ function toImageDataUrl(part: vscode.LanguageModelDataPart): string {
return `data:${part.mimeType};base64,${Buffer.from(part.data).toString('base64')}`;
}

/**
* Resolve the Files API file_id for an image part by its sha256 content hash,
* or return undefined when no Files API mapping exists (falls back to base64).
*/
function resolveFileId(
part: vscode.LanguageModelDataPart,
fileIdByHash: ReadonlyMap<string, string> | undefined,
): string | undefined {
if (!fileIdByHash) {
return undefined;
}
const hash = crypto.createHash('sha256').update(part.data).digest('hex');
return fileIdByHash.get(hash);
}

function getReasoningContent(
replayMarker: ReturnType<typeof parseFirstReplayMarker>,
thinkingContent: string,
Expand Down Expand Up @@ -201,9 +235,10 @@ function getMessageContentChars(content: DeepSeekMessage['content']): number {
for (const part of content) {
if (part.type === 'text') {
total += part.text.length;
} else if (part.type === 'image_url') {
// Do not count base64 URL chars. Native-image requests are excluded from
// adaptive charsPerToken updates, and image cost is handled separately.
} else if (part.type === 'image_url' || part.type === 'file') {
// Do not count base64 URL chars (image_url) or file_id refs (file).
// Native-image requests are excluded from adaptive charsPerToken
// updates, and image cost is handled separately.
total += 0;
}
}
Expand Down
Loading