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:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user