@doany-ai/ai-sdk
v0.1.1
Published
Client SDK for doany-deployed sites to call the doany API Gateway with their site token (sk). Cloudflare Workers friendly.
Readme
@doany-ai/ai-sdk
Client SDK for doany-deployed sites to call the doany API Gateway with their site token (sk). Cloudflare Workers friendly (fetch + Web Crypto only — no node: builtins), zero runtime dependencies.
The gateway holds every AI supplier key (LLM providers, RunComfy image models, …). A site never sees them: it authenticates with the sk that the deploy plane injects as DOANY_SITE_TOKEN, and this SDK does the rest.
pnpm add @doany-ai/ai-sdk # npm i / yarn addQuick start (Cloudflare Worker)
import { DoanyAI } from '@doany-ai/ai-sdk'
export default {
async fetch(req: Request, env: Env) {
const ai = DoanyAI.fromEnv(env) // reads env.DOANY_SITE_TOKEN + env.DOANY_GW_URL
const res = await ai.chat.completions.create({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'Write a one-line slogan.' }],
})
return Response.json(res)
},
}fromEnv reads the bindings the doany deploy plane injects. To be explicit:
const ai = new DoanyAI({
token: env.DOANY_SITE_TOKEN,
baseUrl: env.DOANY_GW_URL, // defaults to https://gw.doany.ai
endUserId: session.userId, // optional default attribution (see below)
})Surface
Only the endpoints a site token can reach:
| Call | Gateway |
|---|---|
| ai.chat.completions.create(body, opts?) | POST /v1/llm/chat/completions (OpenAI-compatible) |
| ai.responses.create(body, opts?) | POST /v1/llm/responses (OpenAI Responses) |
| ai.images.generate(body, opts?) · ai.images.get(id) · ai.images.generateAndWait(...) | POST /v1/images/generate, GET /v1/images/{id} |
| ai.jobs.get(id) · ai.jobs.list(...) · ai.jobs.cancel(id) | GET/DELETE /v1/jobs/* |
| ai.raw(method, path, body?, opts?) | escape hatch → raw Response |
Search / reddit / resources / template presign are agent-only on the gateway and are intentionally not exposed here. Email (/v1/email/send) is reachable with a site token, but it is not an AI capability, so it is out of scope for this SDK.
Streaming (LLM)
const stream = await ai.chat.completions.create({ model: 'gpt-5.5', messages, stream: true })
for await (const chunk of stream) {
write(chunk.choices[0]?.delta?.content ?? '')
}To pipe the gateway's SSE straight to the browser, skip parsing and forward the raw response:
const upstream = await ai.raw('POST', '/v1/llm/chat/completions', { model, messages, stream: true }, { stream: true })
return new Response(upstream.body, { headers: { 'content-type': 'text/event-stream' } })Images
// submit + poll to completion
const job = await ai.images.generateAndWait(
{ input: { prompt: 'a mountain bike on a cliff at sunset', aspect_ratio: '16:9' } },
{ pollMs: 3000, waitMs: 180_000 },
)
const url = job.output?.image // RunComfy URL
// or drive the polling yourself
const submitted = await ai.images.generate({ input: { prompt: '…' } })
const status = await ai.images.get(submitted.id)Idempotency (safe retries)
images.generate is side-effecting. The SDK auto-attaches an Idempotency-Key when you don't supply one, so a network-retried call never double-charges. Provide your own to make "the same logical action" explicit:
await ai.images.generate(body, { idempotencyKey: `hero:${page.id}` })A replay surfaces result.idempotentReplay === true.
End-user attribution
Pass the site's logged-in user id and it lands on the gateway usage row (metadata.end_user_id) for per-user metering — it is attribution only, never auth:
new DoanyAI({ token, endUserId: user.id }) // client-wide default
await ai.chat.completions.create(body, { endUserId: user.id }) // per callErrors
Everything throws a typed DoanyError subclass with status, code, hint?, and requestId:
import { DoanyRateLimitError, DoanyQuotaError, DoanyError } from '@doany-ai/ai-sdk'
try {
await ai.images.generate(body)
} catch (e) {
if (e instanceof DoanyQuotaError) { /* per-project quota; e.retryAfter */ }
else if (e instanceof DoanyRateLimitError) { /* back off e.retryAfter */ }
else if (e instanceof DoanyError) { console.error(e.code, e.hint, e.requestId) }
}Subclasses: DoanyAuthError (401/403), DoanyNotFoundError (404), DoanyValidationError (400/422), DoanyConflictError (409), DoanyRateLimitError / DoanyQuotaError (429), DoanyUpstreamError (502), DoanyGatewayNotConfiguredError (503), DoanyTimeoutError, DoanyConnectionError, DoanyConfigError, DoanyAbortError.
Request correlation
Every result and error carries .requestId (the gateway's X-Request-Id). Use it to find the call in GCP Log Explorer: jsonPayload.request_id="<id>" under logName=doany-gateway-{int,prod}.
Retries & timeouts
- Auto-retries (default
maxRetries: 2, exp backoff + jitter): network errors,429(honorsRetry-After), and retry-safe5xx(GET or calls carrying an Idempotency-Key). A non-idempotent LLM5xxis not retried — the gateway already fails over across providers internally. - Timeouts default per operation (LLM ~305s, others ~30s); override with
opts.timeoutMsand cancel withopts.signal.
Options
new DoanyAI({ token, baseUrl?, endUserId?, maxRetries?, timeoutMs?, fetch?, defaultHeaders? })
// per call:
ai.<x>.<m>(body, { endUserId?, idempotencyKey?, requestId?, signal?, timeoutMs?, maxRetries?, headers? })Compatibility
Targets Cloudflare Workers; also runs on Node 18+ / modern browsers / other edge runtimes (anything with global fetch). SDK major tracks the gateway's /v1.
See DESIGN.md for the full design.
