d4551/cloudflare-dsh

cloudflare-dsh

Cloudflare tools, an AI Gateway model provider, and MCP passthrough for DeepSeek Harness.

Uncategorizedcloudflarecloudflare-apicloudflare-worker

cloudflare-dsh

Cloudflare tools, an AI Gateway model provider, and MCP passthrough for DeepSeek Harness.


Explain like I'm 5

Imagine you have a very capable assistant who can write and run code for you. That assistant is DeepSeek Harness (DSH).

Now imagine you also rent a huge amount of internet plumbing from Cloudflare: somewhere to keep files, somewhere to keep a database, a robot that can open web pages for you, and a bank of GPUs that can run AI models.

Your assistant cannot touch any of that on its own. It does not know your account exists.

This project is the set of hands. Install it, and three things happen:

  1. The assistant gets tools. It can now say "put this in the database", "read that web page", "run this AI model" — 36 specific, typed actions instead of a vague "call an API somewhere".
  2. The assistant can think using Cloudflare's own AI. Not just call Cloudflare models as a tool — actually be powered by them, routed through your AI Gateway.
  3. You get the receipts. Every one of those thinking-requests is stamped with which conversation it came from. So when you ask "what did that conversation cost me?", there is a real answer from Cloudflare's billing data, not an estimate.

That third one is the interesting part, and it is the reason this project exists rather than just being a list of API wrappers.

The same thing, one level up

DSH plugins are Cordis fibers. This repository ships a bundle — an npm package declaring dsh.bundle.patch, which contributes a layer of rows to a profile's plugin tree. One row provides a service (ctx.cloudflare); the others consume it to register tools and one LlmAdapter.

The provider is what makes session-level cost attribution possible: DSH passes sessionId on every GenerateOptions, the provider maps it into the cf-aig-metadata header, and AI Gateway then reports usage and cost keyed by that value. The correlation is exact, not inferred from timestamps.


Contents


What you get

36 toolsWorkers AI, AI Gateway, AI Search, Vectorize, KV, D1, Queues, R2, Browser Rendering, plus one bounded generic REST tool
A model providerTwo routes — cloudflare-workers-ai and cloudflare-ai-gateway — registered as a real LlmAdapter, streaming SSE into the harness chunk contract
Session cost attributioncf-aig-metadata carries the harness session id and call purpose, so gateway logs and billing join to sessions exactly
Web Client surfacesA settings card, a per-session usage chip, and three tool views, all WCAG 2.2 AA
MCP passthroughPatch rows for Cloudflare's hosted MCP servers, off by default

Packages

PackageRole
@d4551/dsh-cloudflare-coreThe ctx.cloudflare capability seam — credential resolution, scoping, request construction, error normalization, pagination, retry
cloudflare-dshThe bundle — four tool groups, the model provider, MCP rows, presets, and the cordis.patch.yml layer
@d4551/dsh-cloudflare-clientWeb Client surfaces — settings card, session usage chip, tool views

The three packages exist because they have genuinely different consumers. Another bundle may want ctx.cloudflare without any of these tools; the client package has its own build requirements (React, a client tsconfig, dsh.client) that the headless packages must not inherit.


Install

dsh plugin --profile <name> add cloudflare-dsh @d4551/dsh-cloudflare-core

Then supply the API token through the environment. The configuration names the variable; it never holds the value:

export CLOUDFLARE_API_TOKEN=...

For the Web Client surfaces, also install the client package into the profile:

dsh plugin --profile <name> add @d4551/dsh-cloudflare-client

API token scopes

Minimum permission groups for the shipped tools:

Workers AI Read/Write · AI Gateway Read/Write · Vectorize Write · Workers KV Storage Write · D1 Write · Workers R2 Storage Write · Queues Write · Account Settings Read

[!NOTE] The Cloudflare dashboard labels these groups Edit where the API calls them Write. Use Write when minting a token through the API.

The account id is optional when the token can reach exactly one account: it is discovered once and reused. A token that can reach several makes discovery ambiguous, so the plugin refuses to guess and asks for accountId, naming the accounts it saw.


Architecture

The bundle is organised around a single capability seam. Everything that talks to Cloudflare goes through ctx.cloudflare; everything else is a consumer of it. That is what keeps credentials in one place, error handling in one place, and the tool modules free of transport concerns.

graph TD
    subgraph harness["DeepSeek Harness"]
        agent["Agent loop"]
        tools["ctx.tools"]
        llm["ctx.llm"]
        slots["ctx.slots"]
        creds["ctx.credentials"]
    end

    subgraph seam["@d4551/dsh-cloudflare-core"]
        service["CloudflareService<br/>(ctx.cloudflare)"]
        client["HTTP client<br/>request · retry · paginate · errors"]
        service --> client
    end

    subgraph bundle["cloudflare-dsh"]
        tai["tools/ai — 18 tools"]
        tdata["tools/data — 13 tools"]
        tweb["tools/web — 3 tools"]
        tmeta["tools/meta — 2 tools"]
        provider["ai — CloudflareAiProvider"]
    end

    subgraph clientpkg["@d4551/dsh-cloudflare-client"]
        ui["SettingsCard · SessionCostChip · 3 tool views"]
    end

    cf["Cloudflare REST API<br/>api.cloudflare.com/client/v4"]
    gw["AI Gateway<br/>OpenAI-compatible endpoint"]

    creds -.->|"resolve per operation"| service
    tai & tdata & tweb & tmeta -->|"inject: cloudflare"| service
    tai & tdata & tweb & tmeta -->|register| tools
    provider -->|"inject: cloudflare"| service
    provider -->|registerAdapter| llm
    ui -->|register| slots
    agent --> tools
    agent --> llm
    client --> cf
    provider --> gw

The capability seam

DSH's own convention is that a capability is complete only when all three roles exist: a service definition, a provider, and a consumer. Here:

RoleWhere
DefinitionCloudflareService class and its typed surface
Providerpackages/core — registers itself as ctx.cloudflare
ConsumersEvery tool group and the model provider, via inject: ['cloudflare']

Why the pure/impure split

In packages/core, exactly one module dispatches a request: client.ts. The service supplies the default fetch and nothing else there touches the network. Everything else — path building, scope resolution, query serialization, error mapping, pagination stepping, retry policy — is a pure function. The same is true of the provider: transducer.ts holds the entire streaming contract as a state machine with no clock, no network and no randomness, and provider.ts is a thin shell around it.

This is not stylistic. It is what makes a 100% mutation score reachable honestly: every branch that matters can be driven from a literal array of fixture inputs, so a surviving mutant means a real gap rather than an untestable seam.

What one request can carry

Cloudflare's REST surface is not uniformly JSON, so a RequestSpec says how it is read and how it is written, and the client does exactly that.

DirectionShapes
ReadThe envelope (request, requestEnvelope); raw text, for a KV value (requestText); bytes with the media type the server declared, for a screenshot (requestBytes)
WriteA body the client serializes as JSON; or an encodedBody the caller already encoded, sent verbatim under its own media type — Vectorize's writes take NDJSON, not JSON

accept names what the caller can read, so an endpoint that answers with an image is asked for an image. Every one of these paths goes through the same retry policy and the same failure classification: an error body is read as an envelope when the endpoint sends one, whatever the successful shape would be.


How a tool call flows

sequenceDiagram
    participant A as Agent loop
    participant R as ctx.tools registry
    participant T as Tool (e.g. cloudflare_d1_query)
    participant S as ctx.cloudflare
    participant C as HTTP client
    participant CF as Cloudflare REST

    A->>R: call cloudflare_d1_query({...})
    R->>R: validate args against ParameterSchemaSpec
    R->>T: execute(args)
    T->>S: accountRequest(spec)
    S->>S: resolve account id — discovery, cached, not a credential
    S->>C: request(spec)
    C->>C: resolve API token from ctx.credentials
    Note over C: Re-resolved every request.<br/>Never cached, so rotation<br/>needs no restart.
    C->>CF: HTTPS with Bearer token
    CF-->>C: Cloudflare envelope
    C->>C: retry on 429/5xx and transport failures, capped backoff + jitter
    C->>C: normalize envelope to typed error or result
    C-->>T: result
    T-->>R: one canonical JSON value
    R->>R: snapshot, validate against output.schema, freeze
    R-->>A: frozen value
    R->>R: output.render(args, value) for the transcript

Two properties are worth calling out.

The canonical value is a programmatic API. DSH runs tools in PTC mode, meaning generated code can call await tools.cloudflare_kv_list_keys({...}) and receive the value directly. So results carry ids and cursors that feed the next call rather than prose a model has to parse. cloudflare_kv_list_keys returns { keys, cursor, complete } precisely so a generated loop can page, and every list tool pages the way its endpoint does: page-numbered endpoints take page and perPage and return page, perPage, total (when Cloudflare reports one, else null) and complete; cursor endpoints (cloudflare_kv_list_keys, cloudflare_r2_bucket_list) return cursor and complete; and cloudflare_queue_list, whose endpoint takes no paging parameters, returns the whole listing and says if the API reported more than it returned. Every tool declares a closed output object with each field described and required; the registry validates a value against it before the model or generated code sees it, and a presenter reads typed fields rather than casting.

Presenters are pure. output.render runs during session-log replay, so it performs no I/O, reads no clock and uses no randomness.

Cancellation reaches Cloudflare. timeoutMs on a tool is declarative — the registry does not interrupt a body — so every tool forwards exec.signal into its request, and a tool with a budget of its own (inferenceTimeoutMs, renderTimeoutMs) makes that budget the request deadline as well, so a 120 s inference is not cut off by the 30 s client default.


How a model call flows

The provider's job is to turn one HTTP response into the harness's StreamChunk sequence, honouring the ordering guarantees the harness depends on.

sequenceDiagram
    participant L as ctx.llm
    participant AD as CloudflareAiProvider
    participant EP as Endpoint resolver
    participant GW as AI Gateway / Workers AI
    participant TR as StreamTransducer

    L->>AD: prepareCall(provider, model)
    AD->>EP: resolveEndpoint + resolveModel, once per generation
    EP->>GW: GET ai-gateway gateways URL endpoint (first call only)
    Note over EP: Base URL comes from the API and is remembered<br/>per plugin instance. The token never is.
    EP-->>AD: { url, token } + model facts (context window, modalities)
    L->>AD: prepared.stream(GenerateOptions)
    AD->>AD: buildWireRequest — images projected to text, reasoning left out, unsupported options rejected
    AD->>AD: attributionHeaders() first, then buildGatewayHeaders — cf-aig-metadata carries sessionId + purpose
    AD->>GW: POST chat/completions (stream, caller AbortSignal forwarded)
    loop while the stream is alive
        GW-->>AD: SSE frames
        AD->>TR: push(parsed frame)
        TR-->>AD: StreamChunk[]
        AD-->>L: yield chunks
    end
    GW-->>AD: data: [DONE]
    AD->>AD: no content block opened? → EMPTY_RESPONSE
    AD->>TR: end()
    TR-->>AD: block-end… usage… finish
    AD-->>L: final chunks
    Note over AD: Reader is cancelled in `finally`,<br/>so an abandoned turn frees the connection.

Streaming contract

The transducer is where the contract lives, and it is enforced by construction rather than convention:

stateDiagram-v2
    [*] --> Open
    Open --> Open: text-delta / reasoning-delta / tool-call-delta
    Open --> Open: usage
    Open --> Closing: finish_reason seen
    Open --> Closing: transport ended, end called
    Closing --> Finished: block-end for every open block, in index order
    Finished --> [*]: finish
    Finished --> Finished: later input ignored
ObligationHow it holds
usage is emitted before finishThe finish reason closes the blocks but is held back until the stream ends, so a provider that reports usage in a trailing chunk — the shape stream_options.include_usage produces — is still observed, and finish is always last
Nothing follows finishA finished flag makes every later push/end a no-op
Block indices allocated in first-seen order and reusedOne allocator, one map keyed by wire index
Tool-call arguments stay raw JSON stringsFragments accumulate as argumentsDelta, re-joined at block-end, never parsed
One provider call is one attemptNo internal retry — the harness owns retry policy
options.signal is honouredForwarded to fetch unconditionally (null is the documented "no signal")
A quiet stream fails as a timeoutEvery read races the configurable idle budget
Unsupported options fail loudlyRejected before any request is issued
Every provider request carries attributionattributionHeaders() is the first thing set on the request; nothing after it can replace the header
A completion with no content is a failureEMPTY_RESPONSE, whether it ended with a finish reason, the [DONE] sentinel, or the socket; usage alone is not content
Every finish reason has a meaningstop, tool_calls and length map to the harness kinds; content_filter and anything unknown are an error finish naming the reason
Every content block has a stated fateText is sent; tool calls and results round-trip, text beside results included; images become the harness's text through its own helper; reasoning is left out, as OpenAI-compatible reasoning APIs refuse it in input
A tool call always has an idThe provider's when it sends one; a deterministic call_<index> when it never does, since an empty id cannot be correlated with its result

Per-session cost attribution

This is the feature the architecture is shaped around.

graph LR
    A["Agent turn<br/>sessionId: abc123"] --> B["stream(GenerateOptions)"]
    B --> C["cf-aig-metadata:<br/>{sessionId, purpose}"]
    C --> D["AI Gateway"]
    D --> E["Gateway logs<br/>tagged with metadata"]
    D --> F["Billing:<br/>usage · cost"]
    E --> G["cloudflare_aigateway_session_cost"]
    F --> G
    G --> H["SessionCostChip<br/>in the session header"]

GenerateOptions.purpose is the bonus: DSH marks auxiliary calls as compaction or session-title, so housekeeping is attributable separately from real agent turns.

[!IMPORTANT] The declarative path (presets/pi-ai.yaml, using the llm-pi-ai plugin DSH already ships) works on day one and costs nothing to adopt — but it cannot set cf-aig-* headers, so it gets no session correlation. That is the concrete reason this bundle ships a real provider rather than only a preset.


Tool catalogue

Tools are grouped into four modules, mounted as separate patch rows, so a profile takes only the groups it wants.

AI — cloudflare-dsh/tools/ai (18)

ToolPurpose
cloudflare_ai_runRun any Workers AI model, one complete response
cloudflare_ai_models_searchSearch the model catalogue
cloudflare_ai_model_schemaFetch a model's live JSON schema
cloudflare_aigateway_list / cloudflare_aigateway_getEnumerate and inspect gateways
cloudflare_aigateway_logsPage gateway request logs
cloudflare_aigateway_log_bodyFetch a logged request or response body
cloudflare_aigateway_routesDynamic routing configuration
cloudflare_aigateway_costCredit balance, usage history, invoice preview
cloudflare_aigateway_session_costUsage and cost for one harness session
cloudflare_aisearch_search / cloudflare_aisearch_chat / cloudflare_aisearch_syncAI Search query, chat completion, index sync
cloudflare_vectorize_index_list / cloudflare_vectorize_queryVector index listing and similarity query
cloudflare_vectorize_upsertWrite vectors as NDJSON, upsert or insert
cloudflare_vectorize_delete / cloudflare_vectorize_getDelete and read vectors back by id

Data — cloudflare-dsh/tools/data (13)

ToolPurpose