dsh-tool-imagegen

August 31, 2026 · View on GitHub

CI License: MIT node

Image generation for DeepSeek Harness through OpenRouter's unified Image API (POST /api/v1/imagesnot the OpenAI-compatible /images/generations).

One tool, generate_image: it takes a prompt, optionally a few reference images, writes the result into the workspace and returns the paths and the exact cost. Generated images appear inline in the chat.

  • Models are aliases. The alias is the vocabulary the model uses and the allowlist — a model without an alias cannot be reached.
  • Parameters are gated against the model's capability record (GET /api/v1/images/models), not hard-wired. A call asking for something a model cannot do fails with a message naming the parameter and the model.
  • Image-to-image via input_references: workspace paths or URLs, up to what the model accepts (14 for Seedream, 16 for the GPT-Image family).
  • Editable at runtime. A card in the Plugins settings section edits the aliases and tunables; changes apply live, no restart.

Install

dsh plugin --profile web add dsh-tool-imagegen

That installs the package into the profile and lists it under dsh.profile.bundles; the shipped bundle patch inserts the plugin row. Then give it a key. apiKeyEnv names a credential reference, never the secret itself:

export OPENROUTER_API_KEY=sk-or-...

or, preferred, put it in $DSH_HOME/.credentials.yaml under refs.OPENROUTER_API_KEY. The credentials seam is tried first, the same-named environment variable second — the same convention as llm-pi-ai and dsh-github. The value never reaches config, logs, or model-visible text.

The plugin ships no model aliases; see Configuration.


Tool: generate_image

ParameterTypeNotes
promptstring, requiredWhat to depict.
modelstringConfigured alias; defaults to defaultModel.
resolutionstringe.g. 1K | 2K | 4K — model dependent.
aspect_ratiostringe.g. 1:1, 16:9 — model dependent.
nintegerHow many images (default 1, at most maxImagesPerCall).
seedintegerWhen the model supports it.
output_formatstringe.g. png | jpeg — most models decide the encoding themselves and list no descriptor.
input_referencesstring[]Reference images to edit or vary: workspace paths or http(s) URLs.
output_pathstringTarget for the first image, absolute or workspace-relative. The extension follows the returned encoding.

The return value carries model, alias, images[] (path, mediaType, bytes), costUsd (exact, from the API's usage.cost), applied — the parameters actually sent — and droppedDefaults. applied is what lets the model see what was really used, e.g. after the gate dropped a config default, and adjust the next attempt.

output_path names the file, but the model decides the encoding. A call asking for bild.png from a model that emits JPEG gets bild.jpg: a file must not lie about its contents.


Capability gate

supported_parameters uses typed descriptors (enum, range, boolean); an absent key means the parameter is unsupported. Resolution order per parameter: call argument → alias defaults → omitted. Two error classes, deliberately different:

OriginParameter unsupportedValue outside the descriptor
Named in the callError naming parameter and modelError listing allowed values
Config defaultsSilently dropped (reported in droppedDefaults)Error attributed to the config

Defaults are wishes, not promises. An explicit call argument is the model asking for something specific, so it is never silently ignored.

The cache holds the /images/models listing for capabilityTtlMs (default 24 h) and is invalidated once on a 400, then re-gated and retried once — so a stale record self-heals without an extra round-trip per image.

The record is not the last word: Seedream 4.5 lists 1K as a valid resolution but rejects it at call time ("requires at least 3,686,400 output pixels"). The gate forwards what the record allows; the API's own error text is carried through verbatim.


Image-to-image

{
  "prompt": "Turn this into a soft watercolor painting, same composition.",
  "input_references": ["bilder/vorlage.png", "https://example.com/style.jpg"]
}

Each value is either an http(s) URL, passed through untouched, or a path (absolute or workspace-relative, resolved against the session cwd like output_path). A path is read, capped, identified by its magic bytes and inlined as a base64 data URL. The file name is never consulted: a name can lie about the encoding — this plugin used to write JPEG payloads under a .png name — and a mislabelled data URL fails at the provider with an error that points nowhere near the cause.

applied.input_references echoes what the call named, never the payload.

The capability descriptor is a range over the count, not over a value:

"input_references": { "type": "range", "min": 0, "max": 14 }

so it gets its own gate, with the same two error classes as everything else. Files are read only after that gate passes: a call against a model without the capability never touches the disk.

Caps are maxReferenceBytes (per file) and maxReferenceTotalBytes (all together), both measured on the bytes on disk; base64 adds roughly a third on the wire.

Support varies by model. Seedream 4.5 / 5.0 accept up to 14 references but list no output_format; the GPT-Image family accepts 16 and adds quality, background and output_compression; the Recraft vector models emit svg only and some require at least one reference (min: 1).


Configuration

No aliases ship by default, so the plugin does nothing until you name at least one. Put a config override into your profile's cordis.patch.yml:

- id: imagegen
  config:
    apiKeyEnv: OPENROUTER_API_KEY      # credential ref / env var NAME, never the secret
    baseURL: https://openrouter.ai/api/v1
    outputDir: .dsh/images             # workspace-relative
    defaultModel: seedream
    capabilityTtlMs: 86400000          # 24 h
    maxImagesPerCall: 4                # guard against a hallucinated n
    maxReferenceBytes: 8388608         # per reference image (8 MiB on disk)
    maxReferenceTotalBytes: 33554432   # all references of one call (32 MiB)
    showInChat: true
    models:
      seedream:
        id: bytedance-seed/seedream-4.5
        defaults: { resolution: "2K", aspect_ratio: "16:9" }
      seedream-pro:
        id: bytedance-seed/seedream-5-0-pro
      seedream-lite:
        id: bytedance-seed/seedream-5-0-lite

A config override replaces the row's config wholesale — it is never deep-merged — so every key that matters has to be repeated. Keys you omit fall back to their schema defaults, not to the bundle patch's values.

Slugs verified against GET /api/v1/images/models: the Seedream 5.0 models are seedream-5-0-pro / seedream-5-0-lite (dashes, not dots), and 5.0-pro accepts only n ≤ 1 and resolutions 1K|2K. An alias whose slug is wrong surfaces as an HTTP error carrying the API's body text.

Settings card

The plugin registers the settings namespace dsh-tool-imagegen, and the browser half contributes a card to the Plugins settings section: the alias table (alias, slug, defaults as JSON) plus the scalar tunables. The two byte caps are entered in MiB; the document keeps bytes.

Config and card are layered, not alternatives:

schema defaults  →  base (this plugin's cordis config)  →  user layer (the card)

so cordis.yml stays the deployment's stated intent, a card edit is an override on top of it, and Reset falls back to exactly the configured value rather than to a schema default nobody chose. Changes apply live.

The alias registry is a list in the settings layer even though the config uses a dict. That is not cosmetic: the layers merge plain objects recursively and replace arrays wholesale, so a dict in the user layer could never delete an alias the config declares — a removed row would silently re-inherit. As a list, what the card writes is the whole registry.

apiKeyEnv, baseURL and capabilityTtlMs stay config-only: they are deployment decisions, and the capability cache is built from the latter two once at apply time, so a live edit could not take effect.

Without a settings service (a headless deployment) the tool runs on the configured values — unconfigurable, but working.


In the chat

With showInChat (default true), every generated image is committed to the durable attachment store, and execute defers one plugin-sourced message so the model sees the picture too — useful for iteration; text-only adapters substitute their text placeholder.

What the reader sees is the tool card: lib/client.js registers the keyed tool.call.toolview for generate_image and renders the image inline, loaded through session.readAttachment from the durable refs in the result meta. A click opens the file through the Host opener.

The deferred message lands as a context-injection row rather than in the history gallery — a message whose source is not user is classified as injected context — so it declares the notice form and a one-line summary, and the collapsed row reads Image created: /path/to/file.jpg.

Every step is contained: an attachment-store outage never fails an otherwise successful generation, and non-attachable media (SVG) are skipped.

All user-facing copy is English, with a Simplified Chinese dictionary beside it, registered through the harness locale service — the UI offers 中文 and English, so the cards follow whichever is selected.


Policy boundary

No permission logic lives in this plugin. Allow/deny/ask belongs in a tools/pre-execute listener, a final deny in ctx.tools.guard(), and a cost cap in a separate hook plugin. maxImagesPerCall is a sanity guard against a hallucinated n, not a budget control.


Development

npm install          # `prepare` builds; tests import the compiled output
npm run build        # tsc → lib/
npm test             # node:test, offline: the host half + the browser half in jsdom
npm run typecheck    # tsc --noEmit

Tests never touch the network: fetch is faked, files land in a temp directory, and the browser half is mounted in jsdom and clicked — expanded, typed into, saved — because nothing else looks at that file (tsc ignores it and node --check only proves it parses).

The @deepseek-ai/* packages are peer dependencies: the harness provides them at runtime, and a second copy of an identity-sensitive contract would shadow the host's.

For a live install, link the checkout into a dsh profile (dsh plugin add); after npm run build, a profile restart reloads both halves.

applyWithDeps(ctx, config, { fetchImpl, workspaceRoot, attachments, settings }) is the injectable entry point used by the tests; apply(ctx, config) is what the harness loads.

Layout

src/
  index.ts         # apply(), tool registration, presenters, the execute flow
  config.ts        # Schemastery schema for the cordis entry config
  settings.ts      # the settings namespace behind the configuration card
  capabilities.ts  # capability cache + the parameter gate
  openrouter.ts    # HTTP client: /images, /images/models   (no DSH imports)
  references.ts    # reference images → data URLs           (no DSH imports)
  write.ts         # base64 → file, naming, collisions      (no DSH imports)
  chat.ts          # attachment store + the deferred message
  key.ts           # credential resolution
lib/client.js      # browser half: tool card + settings card (hand-written, no build step)
test/
  imagegen.test.mjs  # host half
  client.test.mjs    # browser half, in jsdom

License

MIT — see LICENSE.