diff --git a/packages/bundle/image-recognition/cordis.patch.yml b/packages/bundle/image-recognition/cordis.patch.yml index 8e4b917fa4..73ed6f0a00 100644 --- a/packages/bundle/image-recognition/cordis.patch.yml +++ b/packages/bundle/image-recognition/cordis.patch.yml @@ -16,7 +16,10 @@ - id: image-recognition-http name: '@deepseek-ai/dsh-image-recognition-http' config: - apiKeyEnv: DEEPSEEK_API_KEY + # Image recognition has its own credential plane, distinct from the + # chat model's. Pointing this at DEEPSEEK_API_KEY would let a save here + # overwrite the model's key (and vice versa); use the dedicated ref. + apiKeyEnv: IMAGE_RECOGNITION_API_KEY - id: tool-image-recognition name: '@deepseek-ai/dsh-tool-image-recognition' diff --git a/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx b/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx index a3583fb20f..a373a685a6 100644 --- a/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx @@ -42,6 +42,8 @@ export function ImageRecognitionCard(props: ImageRecognitionCardProps) { text={state.apiKey.text} configured={state.apiKeyConfigured} stateLabel={state.apiKeyConfigured ? t('imageRecognitionApiKeySet') : t('imageRecognitionApiKeyUnset')} + clearLabel={t('imageRecognitionClearKey')} + onClear={props.clearApiKey} onEdit={(text) => { props.edit('apiKey', text) }} /> void }) { return (
+ {props.onClear !== undefined + ? ( + + ) + : null} {props.stateLabel} diff --git a/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts b/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts index fda7f35803..c2ad872acb 100644 --- a/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts @@ -23,7 +23,14 @@ import { export const IMAGE_RECOGNITION_NS = 'image-recognition-http' /** Credential reference the provider resolves when the section names none. */ -const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' +const DEFAULT_API_KEY_REF = 'IMAGE_RECOGNITION_API_KEY' + +/** + * The main chat model's credential references. Image recognition must never + * resolve through these, or saving its key would overwrite the model's (and vice + * versa). A stale section that declares one of them is treated as unset. + */ +const MODEL_API_KEY_REFS = new Set(['DEEPSEEK_API_KEY', 'DEEPSEEK_OFFICIAL_API_KEY']) /** Form field the credential control stages under. */ const API_KEY_FIELD = 'apiKey' @@ -64,6 +71,8 @@ export interface ImageRecognitionCardState extends CardShell { /** The registration-side face the image-recognition card's slot entry injects. */ export interface ImageRecognitionCardFace extends CardActions { + /** Delete the configured image-recognition credential, leaving the section empty. */ + clearApiKey: () => void hooks: { /** Card snapshot bound by the renderer as useImageRecognitionCard. */ imageRecognitionCard: SnapshotStore @@ -138,7 +147,11 @@ export class ImageRecognitionCardController { /** Build the face the card's slot registration injects. */ inject(): ImageRecognitionCardFace { - return { hooks: { imageRecognitionCard: this.store }, ...this.form.actions() } + return { + hooks: { imageRecognitionCard: this.store }, + clearApiKey: () => { void this.clearKey() }, + ...this.form.actions(), + } } /** Write the staged key, then re-read whether the Host now holds one. */ @@ -151,10 +164,22 @@ export class ImageRecognitionCardController { await this.readCredential() return this.credential.configured } + + /** Delete the referenced credential, leaving the section empty (no vision model used). */ + private async clearKey(): Promise { + try { + await this.api.credentials.unset({ ref: refOf(this.scope.getSnapshot()) }) + } catch (_credentialUnsetFailure) { + // Refusals surface through the re-read below. + } + await this.readCredential() + } } /** The credential reference the section names, or the provider's default. */ function refOf(snapshot: SettingsScopeSnapshot): string { const declared = snapshot.value?.apiKeyEnv - return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF + // A declared model ref would conflate this card with the chat model; ignore it. + if (declared === undefined || declared.length === 0 || MODEL_API_KEY_REFS.has(declared)) return DEFAULT_API_KEY_REF + return declared } diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 229c4d2ec1..177296c690 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -12,7 +12,7 @@ export type PluginsSettingsLocaleKey = | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' | 'imageRecognitionTitle' | 'imageRecognitionDescription' - | 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset' + | 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset' | 'imageRecognitionClearKey' | 'imageRecognitionBaseUrl' | 'imageRecognitionBaseUrlHint' | 'imageRecognitionModel' | 'imageRecognitionModelHint' @@ -61,6 +61,7 @@ export const en: Record = { imageRecognitionApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', imageRecognitionApiKeySet: 'A key is configured.', imageRecognitionApiKeyUnset: 'No key is configured; recognition is unavailable until one is.', + imageRecognitionClearKey: 'Clear key', imageRecognitionBaseUrl: 'Endpoint', imageRecognitionBaseUrlHint: 'Leave blank to use the provider default.', imageRecognitionModel: 'Model', @@ -106,12 +107,13 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', - imageRecognitionTitle: '图像识别(官方之外的新增)', - imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力', + imageRecognitionTitle: '图像识别', + imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力。', imageRecognitionApiKey: 'API Key', imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', imageRecognitionApiKeySet: '已配置密钥。', imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。', + imageRecognitionClearKey: '清除密钥', imageRecognitionBaseUrl: '接口地址', imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。', imageRecognitionModel: '模型', diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index f407c4aa6c..99a239e572 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -116,9 +116,11 @@ describe('ui-settings-plugins apply', () => { // A key written on another surface changes no settings section, so this // event is the only thing that reaches the card. - ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) + ctx.remote.$dispatch('credentials/updated', ['IMAGE_RECOGNITION_API_KEY']) - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(2) }) + // Only the image-recognition card watches this ref now (web search uses + // DEEPSEEK_API_KEY), so one re-read fires. + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) it('ignores a credential change for a reference no card watches', async () => { diff --git a/packages/client/ui-settings-plugins/tests/image-recognition-card.client.spec.tsx b/packages/client/ui-settings-plugins/tests/image-recognition-card.client.spec.tsx index 516e394423..9f8d3da28b 100644 --- a/packages/client/ui-settings-plugins/tests/image-recognition-card.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/image-recognition-card.client.spec.tsx @@ -15,17 +15,18 @@ afterEach(cleanup) const t = (key: string): string => key -function renderCard() { +function renderCard(value: ImageRecognitionSettings = {}) { const host = stubSettingsScope() const credentials = { describe: vi.fn(() => Promise.resolve({ rpcId: 'c' as never, - result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: false, writable: true } } } }, + result: { ok: true as const, value: { credentials: { IMAGE_RECOGNITION_API_KEY: { configured: false, writable: true } } } }, })), set: vi.fn(), + unset: vi.fn(), } - const controller = new ImageRecognitionCardController(host.scope, credentials as never) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const controller = new ImageRecognitionCardController(host.scope, { credentials }) + host.publish({ status: 'ready', writable: true, value, user: {} }) const face = controller.inject() render( [] as never} useWorkspaces={() => [] as never} />, @@ -52,4 +54,16 @@ describe('ImageRecognitionCard', () => { expect(screen.getByLabelText('imageRecognitionBaseUrl')).toBeTruthy() expect(screen.getByLabelText('imageRecognitionModel')).toBeTruthy() }) + + it('clears the configured credential through the clear-key button', () => { + const { credentials } = renderCard() + fireEvent.click(screen.getByRole('button', { name: 'imageRecognitionClearKey' })) + expect(credentials.unset).toHaveBeenCalledWith({ ref: 'IMAGE_RECOGNITION_API_KEY' }) + }) + + it('ignores a stale model apiKeyEnv so the image-recognition key never overwrites the chat key', () => { + const { credentials } = renderCard({ apiKeyEnv: 'DEEPSEEK_API_KEY' }) + fireEvent.click(screen.getByRole('button', { name: 'imageRecognitionClearKey' })) + expect(credentials.unset).toHaveBeenCalledWith({ ref: 'IMAGE_RECOGNITION_API_KEY' }) + }) }) diff --git a/packages/vision/image-recognition-http/src/index.ts b/packages/vision/image-recognition-http/src/index.ts index e9b0291c4a..d18e141240 100644 --- a/packages/vision/image-recognition-http/src/index.ts +++ b/packages/vision/image-recognition-http/src/index.ts @@ -41,6 +41,9 @@ export const inject = ['imageRecognition'] // different provider (e.g. Aliyun DashScope) than the conversation LLM. const DEFAULT_API_KEY_ENV = 'IMAGE_RECOGNITION_API_KEY' +/** The main chat model's credential refs; image recognition must never use them. */ +const MODEL_API_KEY_REFS = new Set(['DEEPSEEK_API_KEY', 'DEEPSEEK_OFFICIAL_API_KEY']) + /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ @@ -78,7 +81,10 @@ export const IMAGE_RECOGNITION_HTTP_SETTINGS_NAMESPACE = settingsNamespace('imag * @returns options for one recognition. */ function resolveOptions(ctx: Context, config: Config): ImageRecognitionHttpProviderOptions { - const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV) + // A declared model ref (e.g. DEEPSEEK_API_KEY) would conflate this provider + // with the chat model; fall back to the image-recognition ref in that case. + const declared = config.apiKeyEnv ?? DEFAULT_API_KEY_ENV + const apiKeyEnv = credentialRef(MODEL_API_KEY_REFS.has(declared) ? DEFAULT_API_KEY_ENV : declared) const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 ? config.apiKey : undefined diff --git a/packages/vision/image-recognition-http/src/provider.ts b/packages/vision/image-recognition-http/src/provider.ts index 21dd6a6e5a..6d60ce2c1f 100644 --- a/packages/vision/image-recognition-http/src/provider.ts +++ b/packages/vision/image-recognition-http/src/provider.ts @@ -136,9 +136,13 @@ export class ImageRecognitionHttpProvider implements ImageRecognitionProvider { } options.recordRequest?.(payload) + // Normalize a trailing slash on the endpoint base so appending + // `/chat/completions` never produces a double-slash path (some providers + // 404 on that, e.g. Aliyun DashScope). + const base = options.baseURL.replace(/\/+$/, '') let response: Response try { - response = await fetch(`${options.baseURL}/chat/completions`, { + response = await fetch(`${base}/chat/completions`, { method: 'POST', redirect: 'error', headers: { diff --git a/packages/vision/image-recognition-http/tests/provider.spec.ts b/packages/vision/image-recognition-http/tests/provider.spec.ts index 725269db56..8a49f06068 100644 --- a/packages/vision/image-recognition-http/tests/provider.spec.ts +++ b/packages/vision/image-recognition-http/tests/provider.spec.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ImageRecognitionRequest } from '@deepseek-ai/dsh-image-recognition' import { @@ -54,6 +57,28 @@ describe('ImageRecognitionHttpProvider.recognize', () => { expect(body.messages[0]!.content[1]!.image_url.url).toBe('data:image/png;base64,aGVsbG8=') }) + it('normalizes a trailing slash on the endpoint base', async () => { + const fetchMock = vi.fn(async (_url: string, _init?: { body?: string }) => ({ ok: true, status: 200, json: async () => ({ choices: [{ message: { content: 'x' } }] }) })) + vi.stubGlobal('fetch', fetchMock) + await makeProvider({ baseURL: 'https://v.example.com/v1/' }).recognize(request) + expect(fetchMock.mock.calls[0]![0]).toBe('https://v.example.com/v1/chat/completions') + }) + + it('sends a file-path image as a base64 data URL', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-ir-')) + writeFileSync(join(dir, 'img.png'), Buffer.from('hello', 'utf8')) + const fetchMock = vi.fn(async (_url: string, _init?: { body?: string }) => ({ ok: true, status: 200, json: async () => ({ choices: [{ message: { content: 'x' } }] }) })) + vi.stubGlobal('fetch', fetchMock) + try { + await makeProvider().recognize({ image: { kind: 'file-path', filePath: join(dir, 'img.png') } }) + const [, init] = fetchMock.mock.calls[0]! + const body = JSON.parse(init?.body ?? '{}') as { messages: Array<{ content: Array<{ image_url?: { url: string } }> }> } + expect(body.messages[0]!.content[1]!.image_url!.url).toBe('data:image/png;base64,aGVsbG8=') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('throws IMAGE_RECOGNITION_PROVIDER_CREDENTIAL_MISSING without a key', async () => { await expect(makeProvider({ apiKey: '', resolveApiKey: async () => undefined }).recognize(request)) .rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_CREDENTIAL_MISSING' })) diff --git a/packages/vision/image-recognition-http/tests/settings.spec.ts b/packages/vision/image-recognition-http/tests/settings.spec.ts new file mode 100644 index 0000000000..865d1bd699 --- /dev/null +++ b/packages/vision/image-recognition-http/tests/settings.spec.ts @@ -0,0 +1,110 @@ +/** + * The `image-recognition-http` settings section layered over the composition + * entry. Asserts the provider's key stays on the image-recognition credential + * plane even when the stored section names the chat model's ref: a stale + * `apiKeyEnv` must never resolve (or overwrite) `DEEPSEEK_API_KEY`. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import { SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { ImageRecognitionRuntime, type ImageRecognitionRequest } from '@deepseek-ai/dsh-image-recognition' +import * as irHttpPlugin from '@deepseek-ai/dsh-image-recognition-http' +import { IMAGE_RECOGNITION_HTTP_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-image-recognition-http' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends SettingsProvider { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +const request: ImageRecognitionRequest = { + image: { kind: 'base64', base64: 'aGVsbG8=', mediaType: 'image/png' }, +} + +async function boot(): Promise<{ ctx: Context; fiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(ImageRecognitionRuntime, {}) + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin(irHttpPlugin, {}) + await fiber.await() + return { ctx, fiber } +} + +/** Recognize once and return the Authorization header the provider sent. */ +async function recognizeOnce(ctx: Context): Promise { + const fetchMock = vi.fn(async (_url: string, _init?: { headers?: Record }) => + jsonResponse({ choices: [{ message: { content: 'a cat' } }] })) + vi.stubGlobal('fetch', fetchMock) + await ctx.imageRecognition.recognize(request) + const init = fetchMock.mock.calls[0]?.[1] + return init?.headers?.['authorization'] +} + +afterEach(() => { + vi.unstubAllGlobals() + delete process.env.IMAGE_RECOGNITION_API_KEY + delete process.env.DEEPSEEK_API_KEY +}) + +describe('image-recognition-http settings section', () => { + it('resolves the image-recognition credential plane by default', async () => { + process.env.IMAGE_RECOGNITION_API_KEY = 'ir-secret' + const bench = await boot() + expect(await recognizeOnce(bench.ctx)).toBe('Bearer ir-secret') + await bench.ctx.fiber.dispose() + }) + + it('ignores a stored chat-model ref so image recognition never reads the model key', async () => { + process.env.IMAGE_RECOGNITION_API_KEY = 'ir-secret' + process.env.DEEPSEEK_API_KEY = 'model-secret' + const bench = await boot() + await bench.ctx.settings.update(IMAGE_RECOGNITION_HTTP_SETTINGS_NAMESPACE, { + apiKeyEnv: 'DEEPSEEK_API_KEY', + }) + expect(await recognizeOnce(bench.ctx)).toBe('Bearer ir-secret') + await bench.ctx.fiber.dispose() + }) + + it('serves a stored endpoint to the next recognition without re-registering the provider', async () => { + process.env.IMAGE_RECOGNITION_API_KEY = 'ir-secret' + const bench = await boot() + await bench.ctx.settings.update(IMAGE_RECOGNITION_HTTP_SETTINGS_NAMESPACE, { + baseURL: 'https://vision.stored.test/v1', + }) + const fetchMock = vi.fn(async (_url: string) => jsonResponse({ choices: [{ message: { content: 'x' } }] })) + vi.stubGlobal('fetch', fetchMock) + await bench.ctx.imageRecognition.recognize(request) + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://vision.stored.test/v1/chat/completions') + await bench.ctx.fiber.dispose() + }) + + it('releases the namespace when the plugin unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('image-recognition-http') + await bench.fiber.dispose() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('image-recognition-http') + await bench.ctx.fiber.dispose() + }) +})