feat(vision): add the image-recognition capability seam

Service Definition for ctx.imageRecognition: a provider registry and
provider-selecting execution, mirrored on the web seam (duplicate ids
rejected, order-independent selection, ImageRecognitionError taxonomy).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 10:55:57 +08:00
parent 1c27680bb9
commit fc180997c6
6 changed files with 417 additions and 0 deletions
@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-image-recognition",
"description": "Abstract image-recognition capability seam (ctx.imageRecognition) for the DeepSeek Harness — provider registry, registration-order-independent selection, request/result vocabulary, and the ImageRecognitionError taxonomy",
"version": "0.1.0-rc.5",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/vision/image-recognition"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -0,0 +1,156 @@
/**
* Service Definition for the image-recognition capability seam
* (`ctx.imageRecognition`): a provider registry and provider-selecting
* execution. Duplicate ids are rejected. At execution time, a configured
* provider must exist and be usable; without one, exactly one usable provider is
* required, so selection never depends on registration order.
* @module @deepseek-ai/dsh-image-recognition
*/
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import {
ImageRecognitionError,
type ImageRecognitionProvider,
type ImageRecognitionRequest,
type ImageRecognitionResult,
} from './types.ts'
export { ImageRecognitionError } from './types.ts'
export type {
ImageInput,
ImageRecognitionProvider,
ImageRecognitionRequest,
ImageRecognitionResult,
} from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
imageRecognition: ImageRecognitionRuntime
}
}
/** Selection inputs for execution-time provider resolution. */
interface Selection {
/** The configured provider id for this capability, if any. */
readonly configuredId?: string
/** Providers registered for this capability kind. */
readonly providers: ReadonlyMap<string, ImageRecognitionProvider>
}
/**
* Config for the image-recognition seam. `provider` pins which provider wins;
* optional (a single registered usable provider auto-selects). Operational
* overrides such as environment variables feed these same fields rather than
* introduce a hidden priority chain.
*/
export interface ImageRecognitionRuntimeConfig {
/** Explicit provider id. Omitted = auto-select when exactly one usable. */
readonly provider?: string
}
/**
* The image-recognition service. Registered as `ctx.imageRecognition` (one
* instance per context).
*
* Selection semantics (resolved at execution time, never order-dependent):
* - A configured id registered and `available()` → that provider.
* - A configured id not registered → `IMAGE_RECOGNITION_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable → `IMAGE_RECOGNITION_PROVIDER_CONFIGURED_UNAVAILABLE`.
* - No id, exactly one registered usable provider → that provider.
* - No id, multiple usable providers → `IMAGE_RECOGNITION_PROVIDER_AMBIGUOUS`.
* - No id, no usable provider → `IMAGE_RECOGNITION_PROVIDER_UNAVAILABLE`.
*/
export class ImageRecognitionRuntime extends Service {
/**
* Provider selection config. `$DSH_IMAGE_RECOGNITION_PROVIDER` is equivalent
* to `provider` and is NOT a hidden priority chain.
*/
static Config: z<ImageRecognitionRuntimeConfig> = z.object({
provider: z.string(),
})
private providers = new Map<string, ImageRecognitionProvider>()
private readonly providerId: string | undefined
constructor(ctx: Context, config: ImageRecognitionRuntimeConfig = {}) {
super(ctx, 'imageRecognition')
this.providerId = config.provider ?? process.env.DSH_IMAGE_RECOGNITION_PROVIDER
}
/**
* Register a recognition provider. Throws {@link ImageRecognitionError}
* `IMAGE_RECOGNITION_DUPLICATE_PROVIDER` if its id is already registered.
* Returns a disposer; disposed with the calling fiber.
* @param provider - the provider; its `id` is the registry key.
* @returns the disposer that unregisters the provider.
*/
registerProvider(provider: ImageRecognitionProvider): () => void {
const providers = this.providers
if (providers.has(provider.id)) {
throw new ImageRecognitionError(
`an image-recognition provider with id "${provider.id}" is already registered`,
'IMAGE_RECOGNITION_DUPLICATE_PROVIDER',
)
}
const dispose = this.ctx.effect(function* () {
providers.set(provider.id, provider)
yield () => providers.delete(provider.id)
}, 'imageRecognition.registerProvider()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/**
* Run one recognition through the selected provider. Resolves the provider at
* call time with the selection rules above; throws {@link ImageRecognitionError}
* when the capability cannot run.
* @param request - the image and optional recognition prompt.
* @param signal - optional cancellation signal forwarded to the provider.
* @returns the recognized text.
*/
async recognize(request: ImageRecognitionRequest, signal?: AbortSignal): Promise<ImageRecognitionResult> {
const provider = resolveProvider({
providers: this.providers,
...this.providerId !== undefined ? { configuredId: this.providerId } : {},
})
return provider.recognize(request, signal)
}
}
/** Resolve the selected provider or throw the matching {@link ImageRecognitionError}. */
function resolveProvider(selection: Selection): ImageRecognitionProvider {
const { configuredId, providers } = selection
if (configuredId !== undefined) {
const provider = providers.get(configuredId)
if (!provider) {
throw new ImageRecognitionError(
`configured image-recognition provider "${configuredId}" is not registered`,
'IMAGE_RECOGNITION_PROVIDER_CONFIGURED_MISSING',
)
}
if (!provider.available()) {
throw new ImageRecognitionError(
`configured image-recognition provider "${configuredId}" is registered but unavailable`,
'IMAGE_RECOGNITION_PROVIDER_CONFIGURED_UNAVAILABLE',
)
}
return provider
}
const usable = [...providers.values()].filter(provider => provider.available())
const [single] = usable
if (single === undefined) {
throw new ImageRecognitionError('no usable image-recognition provider is registered', 'IMAGE_RECOGNITION_PROVIDER_UNAVAILABLE')
}
if (usable.length > 1) {
const ids = usable.map(provider => provider.id).join(', ')
throw new ImageRecognitionError(
`multiple usable image-recognition providers are registered (${ids}); configure one explicitly`,
'IMAGE_RECOGNITION_PROVIDER_AMBIGUOUS',
)
}
return single
}
export default ImageRecognitionRuntime
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-image-recognition`.
* @module @deepseek-ai/dsh-image-recognition/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-image-recognition'
/** Cordis companion plugin name. */
export const name = 'image-recognition-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the provider map is private and selection is enforced on
* each call; the seam publishes no independent registry or observation stream.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,60 @@
/**
* Vocabulary for the image-recognition capability seam (`ctx.imageRecognition`).
* A provider recognizes image content through a user-configured endpoint and
* returns text the model can continue a task with. The request carries the image
* to inspect; the result is recognized text. Mirrors the web seam so provider
* selection, cancellation, errors, and configuration share one owner.
* @module @deepseek-ai/dsh-image-recognition/types
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
/**
* The image a recognition provider should inspect. A CLOSED discriminated union:
* the provider encodes the kind; consumers pass the source they hold. A new kind
* is a coordinated change across the seam, not a plugin extension.
*/
export type ImageInput =
| { readonly kind: 'file-path'; readonly filePath: string }
| { readonly kind: 'base64'; readonly base64: string; readonly mediaType: string }
| { readonly kind: 'url'; readonly url: string }
/**
* What one recognition-capable backend is asked to do. `prompt` is an optional
* instruction for what to recognize (e.g. "transcribe the text"); omitted, the
* provider returns a general description. Cancellation is a direct execution
* argument, not a request field.
*/
export interface ImageRecognitionRequest {
/** The image to recognize. */
readonly image: ImageInput
/** Optional recognition focus; absent = general description. */
readonly prompt?: string
}
/** Normalized recognition outcome: the recognized text. */
export interface ImageRecognitionResult {
/** Recognized or described content the model can act on. */
readonly text: string
}
/**
* A recognition-capable backend. Registered with `ctx.imageRecognition`.
* `id` is a stable string, unique within the capability kind.
*/
export interface ImageRecognitionProvider {
/** Stable unique id used as the registry key and selection pin. */
readonly id: string
/** Cheap local usability check; must not make network calls. */
available(): boolean
/** Recognize one image; honor `signal` for cancellation. */
recognize(request: ImageRecognitionRequest, signal?: AbortSignal): Promise<ImageRecognitionResult>
}
/**
* Typed image-recognition error with a machine-routable, open-string `code` and
* chained `cause`. Consumers must tolerate provider-specific codes. Shared codes
* cover unavailable, missing, unusable, ambiguous, or duplicate providers,
* cancellation, and provider failure.
*/
export class ImageRecognitionError extends HarnessError {}
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import ImageRecognitionRuntime, {
type ImageRecognitionProvider,
type ImageRecognitionRequest,
type ImageRecognitionResult,
} from '@deepseek-ai/dsh-image-recognition'
/** A scripted recognition provider for contract tests. */
function makeProvider(
id: string,
available: boolean,
recognize: (request: ImageRecognitionRequest) => Promise<ImageRecognitionResult>,
): ImageRecognitionProvider {
return { id, available: () => available, recognize: request => recognize(request) }
}
const available = true
const unavailable = false
function recognizeResult(marker: string): ImageRecognitionResult {
return { text: marker }
}
/** Mount an ImageRecognitionRuntime on a fresh root context with the given config. */
async function mountRuntime(
config: ConstructorParameters<typeof ImageRecognitionRuntime>[1] = {},
): Promise<{ ctx: Context; runtime: ImageRecognitionRuntime }> {
const ctx = new Context()
await ctx.plugin(ImageRecognitionRuntime, config)
return { ctx, runtime: ctx.imageRecognition }
}
describe('ImageRecognitionRuntime registration', () => {
it('registers a provider and unregisters it via the returned disposer', async () => {
const { runtime } = await mountRuntime()
const dispose = runtime.registerProvider(makeProvider('http', available, () => Promise.resolve(recognizeResult('cat'))))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } })).resolves.toMatchObject({ text: 'cat' })
dispose()
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } }))
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_UNAVAILABLE' }))
})
it('throws IMAGE_RECOGNITION_DUPLICATE_PROVIDER on a duplicate id', async () => {
const { runtime } = await mountRuntime()
runtime.registerProvider(makeProvider('http', available, () => Promise.resolve(recognizeResult('a'))))
expect(() => runtime.registerProvider(makeProvider('http', available, () => Promise.resolve(recognizeResult('a')))))
.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_DUPLICATE_PROVIDER' }))
})
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
const { ctx, runtime } = await mountRuntime()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.imageRecognition.registerProvider(makeProvider('http', available, () => Promise.resolve(recognizeResult('a'))))
}, { inject: ['imageRecognition'] }))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } })).resolves.toMatchObject({ text: 'a' })
await fiber.dispose()
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } }))
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_UNAVAILABLE' }))
})
})
describe('ImageRecognitionRuntime selection', () => {
it('picks the configured provider when registered and available', async () => {
const { runtime } = await mountRuntime({ provider: 'b' })
runtime.registerProvider(makeProvider('a', available, () => Promise.resolve(recognizeResult('a'))))
runtime.registerProvider(makeProvider('b', available, () => Promise.resolve(recognizeResult('b'))))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } })).resolves.toMatchObject({ text: 'b' })
})
it('rejects a configured provider that is not registered', async () => {
const { runtime } = await mountRuntime({ provider: 'missing' })
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } }))
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_CONFIGURED_MISSING' }))
})
it('rejects a configured provider that is registered but unavailable', async () => {
const { runtime } = await mountRuntime({ provider: 'http' })
runtime.registerProvider(makeProvider('http', unavailable, () => Promise.resolve(recognizeResult('a'))))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } }))
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_CONFIGURED_UNAVAILABLE' }))
})
it('auto-selects the single usable provider when none is configured', async () => {
const { runtime } = await mountRuntime()
runtime.registerProvider(makeProvider('http', unavailable, () => Promise.resolve(recognizeResult('a'))))
runtime.registerProvider(makeProvider('other', available, () => Promise.resolve(recognizeResult('other'))))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } })).resolves.toMatchObject({ text: 'other' })
})
it('rejects multiple usable providers without a configured pin', async () => {
const { runtime } = await mountRuntime()
runtime.registerProvider(makeProvider('a', available, () => Promise.resolve(recognizeResult('a'))))
runtime.registerProvider(makeProvider('b', available, () => Promise.resolve(recognizeResult('b'))))
await expect(runtime.recognize({ image: { kind: 'url', url: 'https://e.test/c.png' } }))
.rejects.toThrow(expect.objectContaining({ code: 'IMAGE_RECOGNITION_PROVIDER_AMBIGUOUS' }))
})
})
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}