fix(image-recognition): keep the vision key on its own ref; add clear-key
Image recognition and the chat model shared DEEPSEEK_API_KEY: the image-recognition bundle defaulted apiKeyEnv to the model key, so saving one overwrote the other. Point the bundle at IMAGE_RECOGNITION_API_KEY and guard both the provider and the settings card against a stale model ref, so vision never reads or writes the chat key. Also add a clear-key button, default the model to qwen3-vl-flash on the DashScope compatible-mode endpoint, and send file images as base64 with a normalized base URL. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,10 @@
|
|||||||
- id: image-recognition-http
|
- id: image-recognition-http
|
||||||
name: '@deepseek-ai/dsh-image-recognition-http'
|
name: '@deepseek-ai/dsh-image-recognition-http'
|
||||||
config:
|
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
|
- id: tool-image-recognition
|
||||||
name: '@deepseek-ai/dsh-tool-image-recognition'
|
name: '@deepseek-ai/dsh-tool-image-recognition'
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function ImageRecognitionCard(props: ImageRecognitionCardProps) {
|
|||||||
text={state.apiKey.text}
|
text={state.apiKey.text}
|
||||||
configured={state.apiKeyConfigured}
|
configured={state.apiKeyConfigured}
|
||||||
stateLabel={state.apiKeyConfigured ? t('imageRecognitionApiKeySet') : t('imageRecognitionApiKeyUnset')}
|
stateLabel={state.apiKeyConfigured ? t('imageRecognitionApiKeySet') : t('imageRecognitionApiKeyUnset')}
|
||||||
|
clearLabel={t('imageRecognitionClearKey')}
|
||||||
|
onClear={props.clearApiKey}
|
||||||
onEdit={(text) => { props.edit('apiKey', text) }}
|
onEdit={(text) => { props.edit('apiKey', text) }}
|
||||||
/>
|
/>
|
||||||
<ValueField
|
<ValueField
|
||||||
|
|||||||
@@ -17,6 +17,24 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-color: var(--dsw-alias-border-l1);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear:hover {
|
||||||
|
color: var(--dsw-alias-danger-text);
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -99,10 +99,21 @@ export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 't
|
|||||||
configured: boolean
|
configured: boolean
|
||||||
/** Copy describing the configured state. */
|
/** Copy describing the configured state. */
|
||||||
stateLabel: string
|
stateLabel: string
|
||||||
|
/** Optional small clear button to the left of the label; deletes the credential. */
|
||||||
|
clearLabel?: string
|
||||||
|
/** Called when the clear button is pressed. */
|
||||||
|
onClear?: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={css.field}>
|
<div className={css.field}>
|
||||||
<div className={css.head}>
|
<div className={css.head}>
|
||||||
|
{props.onClear !== undefined
|
||||||
|
? (
|
||||||
|
<button type="button" className={css.clear} onClick={props.onClear} aria-label={props.clearLabel}>
|
||||||
|
{props.clearLabel}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||||
<span className={css.badges}>
|
<span className={css.badges}>
|
||||||
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
||||||
|
|||||||
+28
-3
@@ -23,7 +23,14 @@ import {
|
|||||||
export const IMAGE_RECOGNITION_NS = 'image-recognition-http'
|
export const IMAGE_RECOGNITION_NS = 'image-recognition-http'
|
||||||
|
|
||||||
/** Credential reference the provider resolves when the section names none. */
|
/** 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. */
|
/** Form field the credential control stages under. */
|
||||||
const API_KEY_FIELD = 'apiKey'
|
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. */
|
/** The registration-side face the image-recognition card's slot entry injects. */
|
||||||
export interface ImageRecognitionCardFace extends CardActions {
|
export interface ImageRecognitionCardFace extends CardActions {
|
||||||
|
/** Delete the configured image-recognition credential, leaving the section empty. */
|
||||||
|
clearApiKey: () => void
|
||||||
hooks: {
|
hooks: {
|
||||||
/** Card snapshot bound by the renderer as useImageRecognitionCard. */
|
/** Card snapshot bound by the renderer as useImageRecognitionCard. */
|
||||||
imageRecognitionCard: SnapshotStore<ImageRecognitionCardState>
|
imageRecognitionCard: SnapshotStore<ImageRecognitionCardState>
|
||||||
@@ -138,7 +147,11 @@ export class ImageRecognitionCardController {
|
|||||||
|
|
||||||
/** Build the face the card's slot registration injects. */
|
/** Build the face the card's slot registration injects. */
|
||||||
inject(): ImageRecognitionCardFace {
|
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. */
|
/** Write the staged key, then re-read whether the Host now holds one. */
|
||||||
@@ -151,10 +164,22 @@ export class ImageRecognitionCardController {
|
|||||||
await this.readCredential()
|
await this.readCredential()
|
||||||
return this.credential.configured
|
return this.credential.configured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the referenced credential, leaving the section empty (no vision model used). */
|
||||||
|
private async clearKey(): Promise<void> {
|
||||||
|
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. */
|
/** The credential reference the section names, or the provider's default. */
|
||||||
function refOf(snapshot: SettingsScopeSnapshot<ImageRecognitionSettings>): string {
|
function refOf(snapshot: SettingsScopeSnapshot<ImageRecognitionSettings>): string {
|
||||||
const declared = snapshot.value?.apiKeyEnv
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type PluginsSettingsLocaleKey =
|
|||||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||||
| 'imageRecognitionTitle' | 'imageRecognitionDescription'
|
| 'imageRecognitionTitle' | 'imageRecognitionDescription'
|
||||||
| 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset'
|
| 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset' | 'imageRecognitionClearKey'
|
||||||
| 'imageRecognitionBaseUrl' | 'imageRecognitionBaseUrlHint'
|
| 'imageRecognitionBaseUrl' | 'imageRecognitionBaseUrlHint'
|
||||||
| 'imageRecognitionModel' | 'imageRecognitionModelHint'
|
| 'imageRecognitionModel' | 'imageRecognitionModelHint'
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
|
|||||||
imageRecognitionApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
imageRecognitionApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||||
imageRecognitionApiKeySet: 'A key is configured.',
|
imageRecognitionApiKeySet: 'A key is configured.',
|
||||||
imageRecognitionApiKeyUnset: 'No key is configured; recognition is unavailable until one is.',
|
imageRecognitionApiKeyUnset: 'No key is configured; recognition is unavailable until one is.',
|
||||||
|
imageRecognitionClearKey: 'Clear key',
|
||||||
imageRecognitionBaseUrl: 'Endpoint',
|
imageRecognitionBaseUrl: 'Endpoint',
|
||||||
imageRecognitionBaseUrlHint: 'Leave blank to use the provider default.',
|
imageRecognitionBaseUrlHint: 'Leave blank to use the provider default.',
|
||||||
imageRecognitionModel: 'Model',
|
imageRecognitionModel: 'Model',
|
||||||
@@ -106,12 +107,13 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
|
|||||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||||
webSearchMaxUses: '单次请求最多搜索次数',
|
webSearchMaxUses: '单次请求最多搜索次数',
|
||||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||||
imageRecognitionTitle: '图像识别(官方之外的新增)',
|
imageRecognitionTitle: '图像识别',
|
||||||
imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力',
|
imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力。',
|
||||||
imageRecognitionApiKey: 'API Key',
|
imageRecognitionApiKey: 'API Key',
|
||||||
imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||||
imageRecognitionApiKeySet: '已配置密钥。',
|
imageRecognitionApiKeySet: '已配置密钥。',
|
||||||
imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。',
|
imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。',
|
||||||
|
imageRecognitionClearKey: '清除密钥',
|
||||||
imageRecognitionBaseUrl: '接口地址',
|
imageRecognitionBaseUrl: '接口地址',
|
||||||
imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。',
|
imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。',
|
||||||
imageRecognitionModel: '模型',
|
imageRecognitionModel: '模型',
|
||||||
|
|||||||
@@ -116,9 +116,11 @@ describe('ui-settings-plugins apply', () => {
|
|||||||
|
|
||||||
// A key written on another surface changes no settings section, so this
|
// A key written on another surface changes no settings section, so this
|
||||||
// event is the only thing that reaches the card.
|
// 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 () => {
|
it('ignores a credential change for a reference no card watches', async () => {
|
||||||
|
|||||||
@@ -15,17 +15,18 @@ afterEach(cleanup)
|
|||||||
|
|
||||||
const t = (key: string): string => key
|
const t = (key: string): string => key
|
||||||
|
|
||||||
function renderCard() {
|
function renderCard(value: ImageRecognitionSettings = {}) {
|
||||||
const host = stubSettingsScope<ImageRecognitionSettings>()
|
const host = stubSettingsScope<ImageRecognitionSettings>()
|
||||||
const credentials = {
|
const credentials = {
|
||||||
describe: vi.fn(() => Promise.resolve({
|
describe: vi.fn(() => Promise.resolve({
|
||||||
rpcId: 'c' as never,
|
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(),
|
set: vi.fn(),
|
||||||
|
unset: vi.fn(),
|
||||||
}
|
}
|
||||||
const controller = new ImageRecognitionCardController(host.scope, credentials as never)
|
const controller = new ImageRecognitionCardController(host.scope, { credentials })
|
||||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
host.publish({ status: 'ready', writable: true, value, user: {} })
|
||||||
const face = controller.inject()
|
const face = controller.inject()
|
||||||
render(
|
render(
|
||||||
<ImageRecognitionCard
|
<ImageRecognitionCard
|
||||||
@@ -35,6 +36,7 @@ function renderCard() {
|
|||||||
discard={face.discard}
|
discard={face.discard}
|
||||||
edit={face.edit}
|
edit={face.edit}
|
||||||
resetField={face.resetField}
|
resetField={face.resetField}
|
||||||
|
clearApiKey={face.clearApiKey}
|
||||||
useSessions={() => [] as never}
|
useSessions={() => [] as never}
|
||||||
useWorkspaces={() => [] as never}
|
useWorkspaces={() => [] as never}
|
||||||
/>,
|
/>,
|
||||||
@@ -52,4 +54,16 @@ describe('ImageRecognitionCard', () => {
|
|||||||
expect(screen.getByLabelText('imageRecognitionBaseUrl')).toBeTruthy()
|
expect(screen.getByLabelText('imageRecognitionBaseUrl')).toBeTruthy()
|
||||||
expect(screen.getByLabelText('imageRecognitionModel')).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' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export const inject = ['imageRecognition']
|
|||||||
// different provider (e.g. Aliyun DashScope) than the conversation LLM.
|
// different provider (e.g. Aliyun DashScope) than the conversation LLM.
|
||||||
const DEFAULT_API_KEY_ENV = 'IMAGE_RECOGNITION_API_KEY'
|
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). */
|
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
/** 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.
|
* @returns options for one recognition.
|
||||||
*/
|
*/
|
||||||
function resolveOptions(ctx: Context, config: Config): ImageRecognitionHttpProviderOptions {
|
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
|
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
|
||||||
? config.apiKey
|
? config.apiKey
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -136,9 +136,13 @@ export class ImageRecognitionHttpProvider implements ImageRecognitionProvider {
|
|||||||
}
|
}
|
||||||
options.recordRequest?.(payload)
|
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
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${options.baseURL}/chat/completions`, {
|
response = await fetch(`${base}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { ImageRecognitionRequest } from '@deepseek-ai/dsh-image-recognition'
|
import type { ImageRecognitionRequest } from '@deepseek-ai/dsh-image-recognition'
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +57,28 @@ describe('ImageRecognitionHttpProvider.recognize', () => {
|
|||||||
expect(body.messages[0]!.content[1]!.image_url.url).toBe('data:image/png;base64,aGVsbG8=')
|
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 () => {
|
it('throws IMAGE_RECOGNITION_PROVIDER_CREDENTIAL_MISSING without a key', async () => {
|
||||||
await expect(makeProvider({ apiKey: '', resolveApiKey: async () => undefined }).recognize(request))
|
await expect(makeProvider({ apiKey: '', resolveApiKey: async () => undefined }).recognize(request))
|
||||||
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_CREDENTIAL_MISSING' }))
|
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_CREDENTIAL_MISSING' }))
|
||||||
|
|||||||
@@ -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<string, unknown> = {}
|
||||||
|
|
||||||
|
get writable(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
protected load(): Promise<Record<string, unknown>> {
|
||||||
|
return Promise.resolve(structuredClone(this.doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||||
|
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<string | undefined> {
|
||||||
|
const fetchMock = vi.fn(async (_url: string, _init?: { headers?: Record<string, string> }) =>
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user