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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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